# -------------------------------------------------------------------------
# Internal helpers
# -------------------------------------------------------------------------

#' Safe difference for vectors with missing values
#'
#' Returns a vector of the same length as \code{x}. The first value is \code{0}.
#' Differences are only computed where both consecutive values are non-missing;
#' otherwise \code{NA} is returned for that position.
#'
#' @param x Numeric vector.
#'
#' @return Numeric vector of same length as \code{x}.
#' @keywords internal
#' @noRd
safe_diff <- function(x) {
  n <- length(x)
  out <- rep(NA_real_, n)

  if (n == 0) {
    return(out)
  }

  out[1] <- 0

  if (n == 1) {
    return(out)
  }

  prev <- x[-n]
  curr <- x[-1]
  ok <- !is.na(prev) & !is.na(curr)

  out[which(ok) + 1] <- curr[ok] - prev[ok]
  out
}

#' Estimate jump size
#'
#' Estimates the magnitude of a jump at index \code{j}. Two methods are supported:
#' \code{"point_diff"} uses the raw consecutive difference, while
#' \code{"window_median"} uses the difference between the median before and after
#' the jump and is more robust to noise.
#'
#' @param x Numeric vector.
#' @param j Integer index of the jump.
#' @param adjustment_method Either \code{"window_median"} or \code{"point_diff"}.
#' @param adjust_window Integer window size used for \code{"window_median"}.
#'
#' @return Numeric jump offset or \code{NA_real_}.
#' @keywords internal
#' @noRd
estimate_jump_offset <- function(x, j,
                                 adjustment_method = c("window_median", "point_diff"),
                                 adjust_window = 5) {
  adjustment_method <- match.arg(adjustment_method)

  if (length(x) == 0 || j <= 1 || j > length(x)) {
    return(NA_real_)
  }

  if (adjustment_method == "point_diff") {
    if (is.na(x[j]) || is.na(x[j - 1])) {
      return(NA_real_)
    }
    return(x[j] - x[j - 1])
  }

  n <- length(x)

  pre_idx <- seq.int(max(1, j - adjust_window), j - 1)
  post_idx <- seq.int(j, min(n, j + adjust_window - 1))

  pre_vals <- x[pre_idx]
  post_vals <- x[post_idx]

  pre_vals <- pre_vals[!is.na(pre_vals)]
  post_vals <- post_vals[!is.na(post_vals)]

  if (length(pre_vals) < 2 || length(post_vals) < 2) {
    if (is.na(x[j]) || is.na(x[j - 1])) {
      return(NA_real_)
    }
    return(x[j] - x[j - 1])
  }

  stats::median(post_vals) - stats::median(pre_vals)
}

#' Apply jump correction
#'
#' Subtracts the estimated jump offset from all non-missing values from index
#' \code{j} to the end of the series.
#'
#' @param x Numeric vector.
#' @param j Integer index of the jump.
#' @param offset Numeric jump offset.
#' @param set_jump_point_na Logical. If \code{TRUE}, the exact jump point is set to
#' \code{NA} after correction.
#'
#' @return Corrected numeric vector.
#' @keywords internal
#' @noRd
apply_jump_correction <- function(x, j, offset, set_jump_point_na = FALSE) {
  if (length(x) == 0 || is.na(offset) || j > length(x)) {
    return(x)
  }

  idx <- j:length(x)
  idx <- idx[!is.na(x[idx])]

  if (length(idx) > 0) {
    x[idx] <- x[idx] - offset
  }

  if (isTRUE(set_jump_point_na) && j <= length(x)) {
    x[j] <- NA_real_
  }

  x
}

#' Detect jump indices
#'
#' Detects jump locations either manually using a threshold on the differenced
#' series or automatically using \code{changepoint::cpt.mean()} with
#' \code{penalty = "Manual"}.
#'
#' @param diff_dm Numeric differenced series.
#' @param detection_method Either \code{"manual"} or \code{"auto"}.
#' @param manual_threshold Numeric threshold used in manual mode.
#' @param auto_method_penalty Numeric manual penalty value used in automatic mode.
#' @param auto_every_second Logical. If \code{TRUE}, keeps every second detected
#' changepoint to mimic the legacy behavior.
#'
#' @return Integer vector of jump indices.
#' @keywords internal
#' @noRd
detect_jump_indices <- function(diff_dm,
                                detection_method = c("manual", "auto"),
                                manual_threshold = NULL,
                                auto_method_penalty = 10,
                                auto_every_second = TRUE) {
  detection_method <- match.arg(detection_method)

  if (detection_method == "manual") {
    if (is.null(manual_threshold)) {
      stop("'manual_threshold' must be provided when detection_method = 'manual'")
    }
    return(which(!is.na(diff_dm) & abs(diff_dm) >= manual_threshold))
  }

  valid_idx <- which(!is.na(diff_dm))

  if (length(valid_idx) < 2) {
    return(integer(0))
  }

  cp <- tryCatch(
    changepoint::cpt.mean(
      diff_dm[valid_idx],
      method = "PELT",
      penalty = "Manual",
      pen.value = auto_method_penalty
    ),
    error = function(e) NULL
  )

  if (is.null(cp)) {
    return(integer(0))
  }

  locs <- changepoint::cpts(cp)

  if (length(locs) == 0 || all(is.na(locs))) {
    return(integer(0))
  }

  if (isTRUE(auto_every_second) && length(locs) >= 2) {
    locs <- locs[seq(2, length(locs), by = 2)]
  }

  if (length(locs) == 0) {
    return(integer(0))
  }

  valid_idx[locs]
}

#' Plot jumps in a dendrometer series
#'
#' Internal plotting helper for interactive inspection of jumps.
#'
#' @param time_vec POSIXct vector.
#' @param value_vec Numeric vector.
#' @param jump_times Optional POSIXct vector of jump times to mark with vertical lines.
#' @param title Character plot title.
#' @param line_color Character color for vertical lines.
#'
#' @return Invisibly prints a ggplot object.
#' @keywords internal
#' @noRd
plot_jump_series <- function(time_vec, value_vec,
                             jump_times = NULL,
                             title = "",
                             line_color = "red") {
  TIME <- dm <- NULL
  df_plot <- data.frame(TIME = time_vec, dm = value_vec)

  p <- ggplot2::ggplot(df_plot, ggplot2::aes(TIME, dm)) +
    ggplot2::geom_line(color = "black") +
    ggplot2::theme_minimal() +
    ggplot2::xlab("Time") +
    ggplot2::ylab("Stem size variation") +
    ggplot2::ggtitle(title)

  if (!is.null(jump_times) && length(jump_times) > 0) {
    p <- p + ggplot2::geom_vline(
      xintercept = as.numeric(jump_times),
      color = line_color,
      linewidth = 1,
      alpha = 0.6
    )
  }

  print(p)
  invisible(p)
}

# -------------------------------------------------------------------------
# Exported functions
# -------------------------------------------------------------------------

#' @title Removing artefacts due to manual adjustments of dendrometers interactively
#'
#' @description Dendrometers generally have limited memory capacity beyond which
#' they stop recording. To keep the measurement ongoing, they should be adjusted
#' periodically, which can cause positive or negative jumps in the data. This
#' function locates these artefacts and interactively adjusts them one by one.
#'
#' @return A dataframe containing jump-free dendrometer data.
#'
#' @param df Data frame with first column containing date and time in the format
#' \code{yyyy-mm-dd HH:MM:SS} and the dendrometer data in following columns.
#' @param TreeNum Numerical value indicating the tree to be analysed. E.g. \code{1}
#' refers to the first dendrometer data column in \code{df}.
#' @param detection_method Either \code{"manual"} for threshold-based jump
#' detection or \code{"auto"} for automatic detection using
#' \code{changepoint::cpt.mean()} with \code{penalty = "Manual"}.
#' @param manual_threshold Numeric threshold considered as artefact when
#' \code{detection_method = "manual"}.
#' @param auto_method_penalty Numeric manual penalty value used in
#' \code{changepoint::cpt.mean()} when \code{detection_method = "auto"}.
#' Larger values generally lead to fewer detected jumps.
#' @param adjustment_method Either \code{"window_median"} (recommended) or
#' \code{"point_diff"}. The former estimates the jump offset from local medians
#' before and after the jump, while the latter uses the raw consecutive
#' difference at the jump.
#' @param adjust_window Integer window size used for robust jump-size estimation
#' when \code{adjustment_method = "window_median"}.
#' @param plot_window Integer number of points shown before and after each jump in
#' the interactive plot.
#' @param auto_every_second Logical. If \code{TRUE}, keeps every second detected
#' changepoint in automatic mode to mimic the legacy behavior.
#' @param set_jump_point_na Logical. If \code{TRUE}, sets the exact jump point to
#' \code{NA} after correction.
#'
#' @importFrom lubridate ymd_hms
#' @importFrom changepoint cpt.mean cpts
#' @importFrom ggplot2 ggplot aes geom_line geom_vline theme_minimal xlab ylab ggtitle
#' @importFrom stats median
#'
#' @export
i.jump.locator <- function(df,
                           TreeNum,
                           detection_method = c("manual", "auto"),
                           manual_threshold = NULL,
                           auto_method_penalty = 10,
                           adjustment_method = c("window_median", "point_diff"),
                           adjust_window = 5,
                           plot_window = 20,
                           auto_every_second = TRUE,
                           set_jump_point_na = FALSE) {
  detection_method <- match.arg(detection_method)
  adjustment_method <- match.arg(adjustment_method)

  if (TreeNum > ncol(df) - 1) {
    stop("Tree number does not exist in the provided dataset")
  }

  if (!inherits(df[[1]], "Date") && !inherits(df[[1]], "POSIXct")) {
    df[[1]] <- lubridate::ymd_hms(df[[1]])
  }

  series_name <- names(df)[TreeNum + 1]

  df1 <- data.frame(
    TIME = df[[1]],
    dm = df[[TreeNum + 1]]
  )

  diff_dm <- safe_diff(df1$dm)

  jl <- detect_jump_indices(
    diff_dm = diff_dm,
    detection_method = detection_method,
    manual_threshold = manual_threshold,
    auto_method_penalty = auto_method_penalty,
    auto_every_second = auto_every_second
  )

  if (length(jl) == 0) {
    message("There is no jump in the dendrometer.")
    names(df1)[2] <- series_name
    return(df1)
  }

  jump.info <- data.frame(
    Index = jl,
    Jump_at = df1$TIME[jl],
    Raw_jump = diff_dm[jl]
  )

  print(jump.info)

  suppressWarnings(
    plot_jump_series(
      time_vec = df1$TIME,
      value_vec = df1$dm,
      jump_times = jump.info$Jump_at,
      title = "Overview of jump(s)",
      line_color = "red"
    )
  )

  invisible(readline(prompt = "Overview of detected jumps shown. Press [enter] to continue..."))
  data <- df1$dm

  for (i in seq_along(jl)) {
    j <- jl[i]
    fl <- max(1, j - plot_window)
    fm <- min(length(data), j + plot_window)

    suppressWarnings(
      plot_jump_series(
        time_vec = df1$TIME[fl:fm],
        value_vec = data[fl:fm],
        jump_times = df1$TIME[j],
        title = paste(df1$TIME[j], ", before adjustment"),
        line_color = "red"
      )
    )

    user_input <- ""
    while (!user_input %in% c("yes", "no")) {
      user_input <- tolower(
        readline(prompt = paste(
          "Do you want to adjust the jump at",
          df1$TIME[j],
          "(yes/no): "
        ))
      )

      if (!user_input %in% c("yes", "no")) {
        print("Invalid input. Please enter 'yes' or 'no'.")
      }
    }

    if (user_input == "no") {
      print(paste("Skipping jump at", df1$TIME[j]))
      next
    }

    offset <- estimate_jump_offset(
      x = data,
      j = j,
      adjustment_method = adjustment_method,
      adjust_window = adjust_window
    )

    print(paste("Estimated jump at", df1$TIME[j], "=", round(offset, 4)))

    data <- apply_jump_correction(
      x = data,
      j = j,
      offset = offset,
      set_jump_point_na = set_jump_point_na
    )

    suppressWarnings(
      plot_jump_series(
        time_vec = df1$TIME[fl:fm],
        value_vec = data[fl:fm],
        jump_times = df1$TIME[j],
        title = paste(df1$TIME[j], ", after adjustment"),
        line_color = "green"
      )
    )
  }

  df1$dm <- data

  suppressWarnings(
    plot_jump_series(
      time_vec = df1$TIME,
      value_vec = df1$dm,
      jump_times = jump.info$Jump_at,
      title = "Overview after adjustment of jump(s)",
      line_color = "green"
    )
  )

  names(df1)[2] <- series_name
  df1
}

#' @title Removing artefacts due to manual adjustments of dendrometers automatically for more than one dendrometer
#'
#' @description Dendrometers generally have limited memory capacity beyond which
#' they stop recording. To keep the measurement ongoing, they should be adjusted
#' periodically, which can cause positive or negative jumps in the data. This
#' function locates these artefacts and adjusts them automatically. Unlike
#' \code{\link{ i.jump.locator }}, it can handle datasets with more than one
#' dendrometer.
#'
#' @return A dataframe containing jump-free dendrometer data.
#'
#' @param df Data frame with first column containing date and time in the format
#' \code{yyyy-mm-dd HH:MM:SS} and the dendrometer data in following columns.
#' @param detection_method Either \code{"manual"} for threshold-based jump
#' detection or \code{"auto"} for automatic detection using
#' \code{changepoint::cpt.mean()} with \code{penalty = "Manual"}.
#' @param manual_threshold Numeric threshold considered as artefact when
#' \code{detection_method = "manual"}.
#' @param auto_method_penalty Numeric manual penalty value used in
#' \code{changepoint::cpt.mean()} when \code{detection_method = "auto"}.
#' Larger values generally lead to fewer detected jumps.
#' @param adjustment_method Either \code{"window_median"} (recommended) or
#' \code{"point_diff"}.
#' @param adjust_window Integer window size used for robust jump-size estimation
#' when \code{adjustment_method = "window_median"}.
#' @param auto_every_second Logical. If \code{TRUE}, keeps every second detected
#' changepoint in automatic mode to mimic the legacy behavior.
#' @param set_jump_point_na Logical. If \code{TRUE}, sets the exact jump point to
#' \code{NA} after correction.
#'
#' @examples
#' \donttest{
#' library(dendRoAnalyst)
#' data(nepa)
#'
#' # Manual detection
#' jump_free_nepa <- jump.locator(
#'   df = nepa,
#'   detection_method = "manual",
#'   manual_threshold = 1
#' )
#'
#' # Automatic detection with cpt.mean() and penalty = "Manual"
#' jump_free_nepa2 <- jump.locator(
#'   df = nepa,
#'   detection_method = "auto",
#'   auto_method_penalty = 10
#' )
#' }
#'
#' @importFrom lubridate ymd_hms
#' @importFrom changepoint cpt.mean cpts
#' @importFrom stats median
#'
#' @export
jump.locator <- function(df,
                         detection_method = c("manual", "auto"),
                         manual_threshold = NULL,
                         auto_method_penalty = 10,
                         adjustment_method = c("window_median", "point_diff"),
                         adjust_window = 5,
                         auto_every_second = TRUE,
                         set_jump_point_na = FALSE) {
  detection_method <- match.arg(detection_method)
  adjustment_method <- match.arg(adjustment_method)

  if (!inherits(df[[1]], "Date") && !inherits(df[[1]], "POSIXct")) {
    df[[1]] <- lubridate::ymd_hms(df[[1]])
  }

  out <- as.data.frame(df, stringsAsFactors = FALSE)
  names(out)[1] <- "TIME"

  dendro_names <- names(out)[-1]

  for (cl in seq_along(dendro_names)) {
    nm <- dendro_names[cl]
    column <- out[[nm]]

    diff_dm <- safe_diff(column)

    jl <- detect_jump_indices(
      diff_dm = diff_dm,
      detection_method = detection_method,
      manual_threshold = manual_threshold,
      auto_method_penalty = auto_method_penalty,
      auto_every_second = auto_every_second
    )

    if (length(jl) == 0) {
      message(paste("There is no jump in", nm))
      next
    }

    offsets <- rep(NA_real_, length(jl))

    for (i in seq_along(jl)) {
      j <- jl[i]

      offset <- estimate_jump_offset(
        x = column,
        j = j,
        adjustment_method = adjustment_method,
        adjust_window = adjust_window
      )

      offsets[i] <- offset

      column <- apply_jump_correction(
        x = column,
        j = j,
        offset = offset,
        set_jump_point_na = set_jump_point_na
      )
    }

    jump_table <- data.frame(
      Index = jl,
      Jump_time = out$TIME[jl],
      Raw_jump = diff_dm[jl],
      Applied_offset = offsets
    )

    message(paste("Jump information in", nm))
    print(jump_table)

    out[[nm]] <- column
  }

  out
}
