test_that("stringdist_join / variants (data.table backend)", {
  skip_if_not_installed("ggplot2")
  skip_if_not_installed("stringdist")
  skip_if_not_installed("data.table")

  # Use ggplot2::diamonds (available when ggplot2 is installed)
  diamonds <- ggplot2::diamonds

  # ------------------------------------------------------------------
  # Setup helpers
  # ------------------------------------------------------------------
  d <- data.table::data.table(
    cut2 = c("Idea", "Premiums", "Premiom", "VeryGood", "VeryGood", "Faiir"),
    type = 1:6
  )

  d2 <- d[1:3]
  included <- c("Ideal", "Premium")
  notin <- c("Fair", "Good", "Very Good")

  d3 <- data.table::rbindlist(
    list(d2, data.table::data.table(cut2 = "NewType", type = 4L)),
    use.names = TRUE,
    fill = TRUE
  )

  # ------------------------------------------------------------------
  # INNER join (multiples + distance column)
  # ------------------------------------------------------------------
  j <- fuzzystring_inner_join(
    diamonds,
    d,
    by = c(cut = "cut2"),
    distance_col = "distance"
  )

  # count(cut, cut2) + arrange(cut)
  result <- data.table::as.data.table(j)[, .N, by = .(cut, cut2)]
  data.table::setorder(result, cut)

  expect_equal(
    as.character(result$cut),
    c("Fair", "Very Good", "Premium", "Premium", "Ideal")
  )
  expect_equal(
    result$cut2,
    c("Faiir", "VeryGood", "Premiums", "Premiom", "Idea")
  )

  # Premium and Very Good each match two entries in d (Premiom/Premiums, VeryGood twice)
  expect_equal(sum(j$cut == "Premium"), sum(diamonds$cut == "Premium") * 2L)
  expect_equal(sum(j$cut == "Very Good"), sum(diamonds$cut == "Very Good") * 2L)
  expect_equal(sum(j$cut2 == "Premiom"), sum(diamonds$cut == "Premium"))

  # With default max_dist=2, here all accepted should be distance 1
  expect_true(all(j$distance == 1))

  vg <- data.table::as.data.table(j)[cut == "Very Good", .N, by = type]
  data.table::setorder(vg, type)

  expect_equal(vg$type, c(4L, 5L))
  expect_equal(vg$N, rep.int(sum(diamonds$cut == "Very Good"), 2L))

  expect_true(all(j$type[j$cut2 == "Faiir"] == 6L))

  # ------------------------------------------------------------------
  # LEFT join
  # ------------------------------------------------------------------
  result_l <- fuzzystring_left_join(diamonds, d2, by = c(cut = "cut2"))

  expect_true(all(is.na(result_l$cut2[result_l$cut %in% notin])))
  expect_equal(sum(result_l$cut %in% notin), sum(diamonds$cut %in% notin))

  expect_equal(
    sum(result_l$cut2 == "Premiom", na.rm = TRUE),
    sum(diamonds$cut == "Premium")
  )
  expect_equal(
    sum(result_l$cut2 == "Premiom", na.rm = TRUE),
    sum(result_l$cut2 == "Premiums", na.rm = TRUE)
  )

  # ------------------------------------------------------------------
  # RIGHT join
  # ------------------------------------------------------------------
  result_r <- fuzzystring_right_join(diamonds, d3, by = c(cut = "cut2"))

  expect_equal(sum(result_r$cut2 == "NewType"), 1L)
  expect_equal(sum(is.na(result_r$cut)), 1L)
  expect_true(all(is.na(result_r$cut[result_r$cut2 == "NewType"])))

  expect_equal(
    sum(result_r$cut2 == "Premiom", na.rm = TRUE),
    sum(diamonds$cut == "Premium")
  )
  expect_equal(
    sum(result_r$cut2 == "Premiom", na.rm = TRUE),
    sum(result_r$cut2 == "Premiums", na.rm = TRUE)
  )

  # ------------------------------------------------------------------
  # FULL join
  # ------------------------------------------------------------------
  result_f <- fuzzystring_full_join(diamonds, d3, by = c(cut = "cut2"))

  expect_equal(sum(result_f$cut2 == "NewType", na.rm = TRUE), 1L)
  expect_equal(sum(is.na(result_f$cut)), 1L)
  expect_true(all(is.na(result_f$cut[result_f$cut2 == "NewType"])))

  expect_true(all(is.na(result_f$cut2[result_f$cut %in% notin])))
  expect_equal(sum(result_f$cut %in% notin), sum(diamonds$cut %in% notin))

  expect_equal(
    sum(result_f$cut2 == "Premiom", na.rm = TRUE),
    sum(diamonds$cut == "Premium")
  )
  expect_equal(
    sum(result_f$cut2 == "Premiom", na.rm = TRUE),
    sum(result_f$cut2 == "Premiums", na.rm = TRUE)
  )

  # ------------------------------------------------------------------
  # SEMI join
  # ------------------------------------------------------------------
  result_s <- fuzzystring_semi_join(diamonds, d2, by = c(cut = "cut2"))

  expect_equal(sort(as.character(unique(result_s$cut))), included)
  expect_equal(nrow(result_s), sum(result_s$cut %in% included))
  expect_false("cut2" %in% names(result_s))

  # ------------------------------------------------------------------
  # ANTI join
  # ------------------------------------------------------------------
  result_a <- fuzzystring_anti_join(diamonds, d2, by = c(cut = "cut2"))

  expect_equal(sort(as.character(unique(result_a$cut))), notin)
  expect_equal(nrow(result_a), sum(result_a$cut %in% notin))

  # ------------------------------------------------------------------
  # No matches cases (including overlapping column names)
  # ------------------------------------------------------------------
  d_nomatch <- data.table::data.table(
    cut2 = c(
      "Ideolll",
      "Premiumsss",
      "Premiomzzz",
      "VeryVeryGood",
      "VeryVeryGood",
      "FaiirsFair"
    ),
    type = 1:6
  )

  j1 <- fuzzystring_inner_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j1), 0L)
  expect_true(all(c("carat", "cut", "cut2", "type") %in% names(j1)))

  d_nomatch2 <- data.table::copy(d_nomatch)
  data.table::setnames(d_nomatch2, "cut2", "cut")
  j1_5 <- fuzzystring_inner_join(diamonds, d_nomatch2, by = c(cut = "cut"))
  expect_equal(nrow(j1_5), 0L)
  expect_true(all(c("carat", "cut.x", "cut.y", "type") %in% names(j1_5)))

  j2 <- fuzzystring_left_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j2), nrow(diamonds))
  expect_true(all(is.na(j2$cut2)))

  j3 <- fuzzystring_right_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j3), nrow(d_nomatch))
  expect_true(all(is.na(j3$carat)))
  expect_true(all(is.na(j3$cut)))

  j4 <- fuzzystring_full_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j4), nrow(diamonds) + nrow(d_nomatch))
  expect_true(all(is.na(j4$cut) | is.na(j4$cut2)))
  expect_true(all(is.na(j4$carat) | is.na(j4$type)))

  j5 <- fuzzystring_semi_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j5), 0L)
  expect_true("cut" %in% names(diamonds))
  expect_true("carat" %in% names(diamonds))
  expect_false("cut2" %in% names(diamonds))
  expect_false("type" %in% names(diamonds))

  j6 <- fuzzystring_anti_join(diamonds, d_nomatch, by = c(cut = "cut2"))
  expect_equal(nrow(j6), nrow(diamonds))
  expect_true("cut" %in% names(diamonds))
  expect_false("cut2" %in% names(diamonds))

  # ------------------------------------------------------------------
  # ignore_case
  # ------------------------------------------------------------------
  d_lowercase <- data.table::copy(d)
  d_lowercase[, cut2 := tolower(cut2)]

  j_ic_0 <- fuzzystring_inner_join(
    diamonds,
    d_lowercase,
    by = c(cut = "cut2"),
    distance_col = "distance",
    max_dist = 1
  )
  expect_equal(nrow(j_ic_0), 0L)

  j_ic <- fuzzystring_inner_join(
    diamonds,
    d_lowercase,
    by = c(cut = "cut2"),
    distance_col = "distance",
    ignore_case = TRUE,
    max_dist = 1
  )
  expect_gt(nrow(j_ic), 0L)
  expect_equal(sum(j_ic$cut == "Premium"), sum(diamonds$cut == "Premium") * 2L)
  expect_true(all(j_ic$distance[j_ic$cut2 == "idea"] == 1))

  # ------------------------------------------------------------------
  # soundex
  # ------------------------------------------------------------------
  j_sdx <- fuzzystring_inner_join(
    diamonds,
    d,
    by = c(cut = "cut2"),
    distance_col = "distance",
    method = "soundex"
  )
  expect_gt(nrow(j_sdx), 0L)
  expect_equal(sum(j_sdx$cut == "Premium"), sum(diamonds$cut == "Premium") * 2L)

  # ------------------------------------------------------------------
  # Renaming overlapping columns (.x / .y)
  # ------------------------------------------------------------------
  d_overlap <- data.table::data.table(
    cut = c("Idea", "Premiums", "Premiom", "VeryGood", "VeryGood", "Faiir"),
    price = 1:6
  )

  j_ov <- fuzzystring_inner_join(diamonds, d_overlap, by = "cut")

  expect_true("cut.x" %in% names(j_ov))
  expect_true("price.x" %in% names(j_ov))
  expect_true("cut.y" %in% names(j_ov))
  expect_true("price.y" %in% names(j_ov))
  expect_true(all(j_ov$cut.y %in% d_overlap$cut))
  expect_true(all(j_ov$price.y %in% d_overlap$price))

  # ------------------------------------------------------------------
  # Return type: data.frame in -> data.frame out
  # ------------------------------------------------------------------
  result_df <- fuzzystring_inner_join(
    as.data.frame(diamonds),
    d,
    by = c(cut = "cut2")
  )
  expect_s3_class(result_df, "data.frame")
  expect_false(inherits(result_df, "tbl_df"))

  result_df2 <- fuzzystring_inner_join(
    as.data.frame(diamonds),
    as.data.frame(d),
    by = c(cut = "cut2")
  )
  expect_s3_class(result_df2, "data.frame")
  expect_false(inherits(result_df2, "tbl_df"))

  # ------------------------------------------------------------------
  # One-column data.frames (regression)
  # ------------------------------------------------------------------
  onecol <- data.frame(
    cut2 = c("Idea", "Premiums", "Premiom", "VeryGood", "VeryGood", "Faiir")
  )
  diamonds_df <- as.data.frame(diamonds)
  res_onecol <- fuzzystring_inner_join(
    diamonds_df,
    onecol,
    by = c(cut = "cut2")
  )
  expect_s3_class(res_onecol, "data.frame")
  expect_true("cut2" %in% names(res_onecol))
  expect_gt(nrow(res_onecol), 0L)

  # ------------------------------------------------------------------
  # No common variables should error
  # ------------------------------------------------------------------
  expect_error(fuzzystring_inner_join(diamonds, d), "No common variables")

  # ------------------------------------------------------------------
  # Distance column existence when no overlaps (outer joins vs semi/anti)
  # ------------------------------------------------------------------
  a <- data.table::data.table(x = c("apple", "banana"))
  b <- data.table::data.table(y = c("orange", "mango"))

  res_left <- fuzzystring_left_join(
    a,
    b,
    by = c(x = "y"),
    max_dist = 1,
    distance_col = "distance"
  )
  expect_equal(names(res_left), c("x", "y", "distance"))
  expect_equal(nrow(res_left), 2L)
  expect_true(all(is.na(res_left$y)))
  expect_true(all(is.na(res_left$distance)))

  res_inner <- fuzzystring_inner_join(
    a,
    b,
    by = c(x = "y"),
    max_dist = 1,
    distance_col = "distance"
  )
  expect_equal(names(res_inner), c("x", "y", "distance"))
  expect_equal(nrow(res_inner), 0L)

  res_semi <- fuzzystring_semi_join(
    a,
    b,
    by = c(x = "y"),
    max_dist = 1,
    distance_col = "distance"
  )
  expect_equal(names(res_semi), "x")
  expect_equal(nrow(res_semi), 0L)

  res_anti <- fuzzystring_anti_join(
    a,
    b,
    by = c(x = "y"),
    max_dist = 1,
    distance_col = "distance"
  )
  expect_equal(as.data.frame(a), as.data.frame(res_anti))

  # ------------------------------------------------------------------
  # Overlapping names in outer joins keep .x / .y suffixes
  # ------------------------------------------------------------------
  x_overlap_outer <- data.table::data.table(
    key_value = c("alpha", "bravo"),
    shared = 1:2,
    when = as.Date(c("2024-01-01", "2024-01-02"))
  )
  data.table::setnames(x_overlap_outer, "key_value", "key")
  y_overlap_outer <- data.table::data.table(
    key_value = c("alphx", "charlie"),
    shared = 10:11,
    label = c("matched", "unmatched")
  )
  data.table::setnames(y_overlap_outer, "key_value", "key")

  outer_overlap <- fuzzystring_full_join(
    x_overlap_outer,
    y_overlap_outer,
    by = "key",
    max_dist = 1,
    distance_col = "distance"
  )

  expect_true(all(
    c(
      "key.x",
      "shared.x",
      "key.y",
      "shared.y",
      "when",
      "label",
      "distance"
    ) %in%
      names(outer_overlap)
  ))
  expect_s3_class(outer_overlap$when, "Date")
  expect_equal(sum(is.na(outer_overlap$key.x)), 1L)
  expect_equal(sum(is.na(outer_overlap$key.y)), 1L)

  # ------------------------------------------------------------------
  # Typed columns are preserved in compiled binding paths
  # ------------------------------------------------------------------
  x_typed <- data.table::data.table(
    name = c("alpha", "bravo"),
    when = as.Date(c("2024-01-01", "2024-01-02")),
    stamp = as.POSIXct(
      c("2024-01-01 10:00:00", "2024-01-02 11:30:00"),
      tz = "UTC"
    ),
    grp = factor(c("a", "b")),
    payload = I(list(list(id = 1L), list(id = 2L)))
  )
  y_typed <- data.table::data.table(
    approx_name = c("alphx", "charlie"),
    note = c("hit", "miss"),
    meta = I(list(list(code = 10L), list(code = 20L)))
  )

  typed_left <- fuzzystring_left_join(
    x_typed,
    y_typed,
    by = c(name = "approx_name"),
    max_dist = 1,
    distance_col = "distance"
  )

  expect_s3_class(typed_left$when, "Date")
  expect_s3_class(typed_left$stamp, "POSIXct")
  expect_equal(attr(typed_left$stamp, "tzone"), "UTC")
  expect_true(is.factor(typed_left$grp))
  expect_type(typed_left$payload, "list")
  expect_equal(typed_left$payload[[1]]$id, 1L)
  expect_true(is.na(typed_left$note[2]))

  typed_right <- fuzzystring_right_join(
    x_typed,
    y_typed,
    by = c(name = "approx_name"),
    max_dist = 1,
    distance_col = "distance"
  )

  expect_type(typed_right$meta, "list")
  expect_equal(typed_right$meta[[1]]$code, 10L)
  expect_true(is.na(typed_right$when[is.na(typed_right$name)][1]))

  # ------------------------------------------------------------------
  # data.frame inputs return plain data.frames
  # ------------------------------------------------------------------
  res_semi_df <- fuzzystring_semi_join(
    as.data.frame(a),
    as.data.frame(a),
    by = c(x = "x"),
    max_dist = 0
  )
  res_anti_df <- fuzzystring_anti_join(
    as.data.frame(a),
    as.data.frame(b),
    by = c(x = "y"),
    max_dist = 1
  )
  expect_s3_class(res_semi_df, "data.frame")
  expect_false(data.table::is.data.table(res_semi_df))
  expect_s3_class(res_anti_df, "data.frame")
  expect_false(data.table::is.data.table(res_anti_df))
})

test_that("tbl_df inputs return tbl_df outputs", {
  skip_if_not_installed("tibble")

  x_tbl <- tibble::tibble(
    name = c("Idea", "Premiom", "Very Good"),
    id = 1:3
  )

  y_tbl <- tibble::tibble(
    approx_name = c("Ideal", "Premium", "VeryGood"),
    grp = c("A", "B", "C")
  )

  res_inner <- fuzzystring_inner_join(
    x_tbl,
    y_tbl,
    by = c(name = "approx_name"),
    max_dist = 2,
    distance_col = "distance"
  )
  expect_s3_class(res_inner, "tbl_df")
  expect_false(data.table::is.data.table(res_inner))

  res_left <- fuzzystring_left_join(
    x_tbl,
    y_tbl,
    by = c(name = "approx_name"),
    max_dist = 2,
    distance_col = "distance"
  )
  expect_s3_class(res_left, "tbl_df")
  expect_false(data.table::is.data.table(res_left))

  res_semi <- fuzzystring_semi_join(
    x_tbl,
    y_tbl,
    by = c(name = "approx_name"),
    max_dist = 2
  )
  expect_s3_class(res_semi, "tbl_df")
  expect_false(data.table::is.data.table(res_semi))

  res_anti <- fuzzystring_anti_join(
    tibble::tibble(name = c("alpha", "bravo")),
    tibble::tibble(approx_name = c("charlie", "delta")),
    by = c(name = "approx_name"),
    max_dist = 1
  )
  expect_s3_class(res_anti, "tbl_df")
  expect_false(data.table::is.data.table(res_anti))
})

test_that("multi-column progressive join works correctly", {
  skip_if_not_installed("data.table")
  skip_if_not_installed("stringdist")

  a <- data.table::data.table(
    first = c("John", "Alice", "Bob"),
    last = c("Smith", "Smith", "Jones"),
    age = c(30, 25, 40)
  )
  b <- data.table::data.table(
    fname = c("Jon", "Alise", "Bob"),
    lname = c("Smit", "Smith", "Jones"),
    score = c(100, 200, 300)
  )

  # Inner join on first name and last name
  res <- fuzzystring_inner_join(
    a,
    b,
    by = c(first = "fname", last = "lname"),
    max_dist = 1,
    distance_col = "dist"
  )

  # John/Jon and Smith/Smit match (dist <= 1 on both)
  # Alice/Alise and Smith/Smith match (dist <= 1 on both)
  # Bob/Bob and Jones/Jones match (dist 0 on both)
  expect_equal(nrow(res), 3L)
  expect_equal(res$first, c("John", "Alice", "Bob"))
  expect_equal(res$fname, c("Jon", "Alise", "Bob"))

  # Semi join works
  res_semi <- fuzzystring_semi_join(
    a,
    b,
    by = c(first = "fname", last = "lname"),
    max_dist = 1
  )
  expect_equal(nrow(res_semi), 3L)
})
