# discretize.jointly.R
#
# Created by Sajal Kumar
# Modified by Jiandong Wang
# Copyright (c) NMSU Song lab

#' Discretize Multivariate Continuous Data by a Cluster-Preserving Grid
#'
#' Discretize multivariate continuous data using a grid that captures the joint distribution via
#' preserving clusters in the original data
#'
#' @importFrom cluster silhouette
#' @importFrom stats kmeans
#' @importFrom fossil adj.rand.index
#' @importFrom stats dist
#' @importFrom dqrng dqsample
#' @importFrom Rdpack reprompt
#' @importFrom mclust Mclust mclustBIC
#' @importFrom Ckmeans.1d.dp MultiChannel.WUC ahist
#@importFrom Telescope telescope
#' @import Rcpp
#' @useDynLib GridOnClusters
#'
#' @param data a matrix containing two or more continuous variables.
#' Columns are variables, rows are observations.
#'
#' @param k either an integer, a vector of integers, or \code{Inf}, specifying 
#' different ways to find clusters in \code{data}. The default is a vector 
#' containing integers from 2 to 10. If 'k' is a single number, \code{data} will 
#' be grouped into into exactly 'k' clusters. If 'k' is an integer vector, an 
#' optimal 'k' is chosen from among the integers, that maximizes the average 
#' silhouette width. If 'k' is set to \code{Inf}, an optimal k is chosen among
#' 2 to \code{nrow(data)}. If \code{cluster_label} is specified, \code{k} is 
#' ignored.
#'
#' @param cluster_label a vector of user-specified cluster labels for each observation
#' in \code{data}. The user is free to choose any clustering.
#' If unspecified, k-means clustering is used by default.
#'
#' @param min_level integer or vector, signifying the minimum number of levels 
#' along each dimension. If a vector of size \code{ncol(data)}, then each element
#' will be mapped 1:1 to each dimension in order. If an integer, then all dimensions
#' will have the same minimum number of levels.
#'
#' @param max_level integer or vector, signifying the maximum number of levels 
#' along each dimension. Works in the same way as \code{min_level}. Max_level will
#' be set to the smaller one between the number of compressed zones and itself, if 
#' the grid_method is set to "dp compressed likelihood" or "dp compressed majority"
#'
#' @param grid_method a character string to specify the discretization method. 
#' Default: \code{"DP approx likelihood 1-way"}. The methods 
#' can be roughly separate into three different categories: 
#' by cluster likelihood, by density, and by SSE (Sum of Squared Errors).
#' 
#' See Details for more information.
#'  
#' @param noise adding jitter noise to the original data, only will be used when
#' the \code{cluster_method} set to "Ball+BIC". When the data are very dense, 
#' adding noise could improve the Mclust's result.  
#'  
#' @param cluster_method the clustering method to be used. Ignored if cluster 
#' labels are given.
#' 
#' \code{"kmeans+silhouette"} will use k-means to cluster \code{data} and the average 
#'       Silhouette score to select the number of clusters k.
#' 
#'\code{"Ball+BIC"}: Uses a custom, multithreaded spherical clustering method. 
#'       It builds isotropic, variable-radius clusters and uses the BIC score to 
#'       automatically select the optimal k.
#' 
#' \code{"mclust_VII"}: Uses the \code{mclust} package (\code{modelNames = "VII"}) 
#'       to perform spherical clustering, also using BIC for k selection.
#' 
#' \code{"PAM"} pam is partition around medoids   
#' 
#' See Details for more information.
#' 
#' @param cutoff a floating number. Tell the quality to decide whether cut that 
#' line or not. If the statistic, a value to evaluate the quality of a cut,is 
#' smaller then cut_off, no cut will be performed. Only will be used when the 
#' \code{grid_method} is set to "DP" or "DP Compressed majority"
#' 
#' @param entropy Boolean variable decide whether using entropy or likelihood, 
#' default value is false.
#' 
#' @param eval_method a character string to 
#'   specify the method to evaluate quality 
#'   of discretized data
#'   
#' @param dim_reduction a logical to turn on/off 
#'   dimension reduction. Default: \code{FALSE}.
#'   
#' @param scale a logical to specify linear 
#'   scaling of the variable in each dimension.
#'   Default: \code{FALSE}.
#'   
#' @param variance a numeric value to specify 
#'   noise variance to be added to the data

#' @param nthread Integer. Number of CPU threads to use. 
#' Automatically adjusted if invalid or 
#' exceeds available cores. Default: 1
#'
#' @param min_pts an integer to specify the minimum number of points inside 
#'        a cluster. Default: 5
#'
#' @param debug logical. If \code{TRUE}, it enables the debug mode.
#'  Default: \code{FALSE}
#' @note
#' The default value of \code{grid_method} is changed
#' from "Sort+Split" (up to released package version 0.1.0.2) 
#' to "DP approx likelihood 1-way" representing a much 
#' improved approach.
#' 
#' For the default configuration, it will also add some jitter noise to the data randomly only for clustering
#' to avoid clustering failure by too many overlapping points in the data.
#' 
#' For more details, please check the documents.
#' 
#' 
#' @details 
#' 
#' The function implements both published algorithms described in 
#' \insertCite{Jwang2020BCB}{GridOnClusters} and new algorithms for
#' multivariate discretization.
#' 
#' The grid methods provided is roughly in three different kinds. 
#' 
#' * By Density
#'
#'     - \code{"Sort+split"} \insertCite{Jwang2020BCB}{GridOnClusters} 
#'     will sort the cluster by cluster mean in each dimension and then
#'  split consecutive pairs only if the sum of the error rate of each cluster is
#'  less than or equal to 50%. It is possible that no grid line will be added
#'  in a certain dimension. The maximum number of lines is the number of 
#'  clusters minus one.
#'  
#' * By SSE (Sum of Squared Errors)
#'     - \code{"MultiChannel.WUC"} will split each dimension by weighted with-in cluster
#'  sum of squared distances by "Ckmeans.1d.dp::MultiChannel.WUC". Applied in 
#'  each projection on each dimension. The channel of each point is defined by 
#'  its multivariate cluster label.
#'  
#'     - \code{"DP"} will order the label by the data in each dimension and then cut the data
#'  into max of max_level bins, then evaluating the quality of each cut to find 
#'  the best number of bins.
#'  
#'     - \code{"DP Compressed majority"} will order the label by the data in each dimension 
#'  and then compress the labels that neighbored by the same label to avoid trying 
#'  discretization within consecutive points of the same cluster label to greatly 
#'  reduce runtime of dynamic programming. Then cut the data into max of max_level 
#'  bins, and evaluating the quality of each cut by the majority of the data has 
#'  been spliced clearly to find the best number of bins.
#'  
#' * By cluster likelihood
#'     - \code{"DP exact likelihood"} will order the label by the data in each dimension
#'  and then compress the labels that neighbored by the same label to avoid trying 
#'  discretization within consecutive points of the same cluster label to greatly 
#'  reduce runtime of dynamic programming. Then cut the data into max of max_level 
#'  bins.
#'  
#'     - \code{"DP approx likelihood 1-way"} is a speed up version of the 
#'  \code{"DP exact likelihood"} method, but no optimal will be guaranteed.
#' 
#'     - \code{"DP approx likelihood 2-way"} is a bidirectional variant of the 
#'  \code{"DP approx likelihood"} method. It performs approximate dynamic 
#'  programming cuts in both the forward and backward directions and selects 
#'  the better of the two results. This approach provides additional robustness 
#'  compared to the one-directional version, but optimality is still not guaranteed.
#'  
#' The clsuter methods provided:
#' 
#'     - \code{"Ball+BIC"}: Employs a custom, high-performance spherical clustering 
#' algorithm. It models clusters as isotropic spheres (equal shape, variable volume) 
#' and automatically selects the optimal number of clusters (k) by maximizing 
#' the Bayesian Information Criterion (BIC). This implementation supports 
#' multithreading for scalable execution on high-dimensional data.
#' 
#'     - \code{"mclust_VII"}: Utilizes the \code{mclust} package to fit a Gaussian 
#' Mixture Model with a spherical, unequal volume covariance structure 
#' (\code{modelNames = "VII"}). It relies on the BIC score to automatically 
#' determine the optimal number of components.
#' 
#' @return
#'
#' A list that contains four items:
#' \item{\code{D}}{a matrix that contains the discretized version of the original \code{data}.
#' Discretized values are one(1)-based.}
#'
#' \item{\code{grid}}{a list of vectors containing decision boundaries for each variable/dimension.}
#'
#' \item{\code{clabels}}{a vector containing cluster labels for each observation in \code{data}.}
#'
#' \item{\code{csimilarity}}{a similarity score between clusters from joint discretization
#' \code{D} and cluster labels \code{clabels}. The score is the adjusted Rand index.}
#' 
#' @examples
#' # using a specified k
#' x = rnorm(100)
#' y = sin(x)
#' z = cos(x)
#' data = cbind(x, y, z)
#' discretized_data = discretize.jointly(data, k=5)$D
#'
#' # using a range of k
#' x = rnorm(100)
#' y = log1p(abs(x))
#' z = x^2 # tan(x)
#' data = cbind(x, y, z)
#' discretized_data = discretize.jointly(data, k=c(3:10))$D
#' 
#' # using k = Inf
#' x = c()
#' y = c()
#' mns = seq(0,1200,100)
#' for(i in 1:12){
#'   x = c(x,runif(n=20, min=mns[i], max=mns[i]+20))
#'   y = c(y,runif(n=20, min=mns[i], max=mns[i]+20))
#' }
#' data = cbind(x, y)
#' discretized_data = discretize.jointly(data, k=Inf)$D
#'
#' # using an alternate clustering method to k-means
#' library(cluster)
#' x = rnorm(100)
#' y = log1p(abs(x))
#' z = sin(x)
#' data = cbind(x, y, z)
#'
#' # pre-cluster the data using partition around medoids (PAM)
#' cluster_label = pam(x=data, diss = FALSE, metric = "euclidean", k = 5)$clustering
#' discretized_data = discretize.jointly(data, cluster_label = cluster_label)$D
#'
#' #
#' @references
#' \insertAllCited{}
#' 
#' @author 
#' 
#' Jiandong Wang, Sajal Kumar and Mingzhou Song
#' 
#' @seealso
#'
#' See \link[Ckmeans.1d.dp]{Ckmeans.1d.dp} for discretizing univariate continuous data.
#'
#' @md
#'
#' @export
discretize.jointly = function(
    data, k=c(2:10), min_level = 1, max_level= 100,
    cluster_method = c(
      "Ball+BIC",
      "mclust_VII", 
      "kmeans+silhouette", 
      "PAM" # , 
      # "telescope",
      # "Ball+Tele+BIC"
    ),
    grid_method = c(
      "DP approx likelihood 1-way", 
      "DP approx likelihood 2-way",
      "DP exact likelihood", 
      "DP Compressed majority", 
      "DP", 
      "Sort+split", 
      "MultiChannel.WUC"),
    eval_method = c(
      "ARI", "purity", "upsllion", "CAIR"
    ),
    cluster_label=NULL, cutoff=0.00, 
    entropy=FALSE,
    noise = FALSE, 
    dim_reduction = FALSE, 
    scale=FALSE,
    variance = 0.5, 
    min_pts = 5,
    nthread=1,
    debug=FALSE){

  # check if data provided is a matrix
  if( !("matrix" %in% class(data)) && !("data.frame" %in% class(data))){
    stop("'data' must be a matrix or data.frame.")
  }

  # check if all columns in 'data' are numeric or integer
  dim_class = apply(data, 2, class)
  if(!all(dim_class == "numeric" | dim_class == "integer")){
    stop("All columns in 'data' should be numeric or integer.")
  }

  # 'data' should have at least 10 observations
  dim_data = dim(data)
  #if(dim_data[1] < 10){
  #  stop("'data' should have at least 10 observations,")
  #}

  # 'cluster_label' should either be null or integers (or numeric) matching nrow(data)
  if(!is.null(cluster_label) && length(cluster_label) != nrow(data)){
    stop("'cluster_label' should either be null or a vector with nrow(data) elements.")
  }

  if(!is.null(cluster_label) && !class(cluster_label) %in% c("numeric","integer")){
    stop("'cluster_label' should be either null or a numeric/integer vector.")
  }
  
  # if "min_level" is a list or a integer
  if(length(min_level)==1){
    min_level = rep(min_level, ncol(data))
  } else if(length(min_level) > 1 && length(min_level) != ncol(data)){
    stop("'min_level' should either be an integer or a vector of size ncol(data)")
  }
  
  # if "max_level" is a list or a integer
  if(length(max_level)==1){
     max_level = rep(max_level, ncol(data))
  } else if(length(max_level) > 1 && length(max_level) != ncol(data)){
     stop("'min_level' should either be an integer or a vector of size ncol(data)")
  }
    
  # switching order, checking min_level < k after having a k
  
  # 'min_level' should smaller then the max of k
  if(max(k) < max(min_level)){
    k = c(min_level,min_level+5)
    warning("'min_level' should be in the range of k, k has been adapted")
   } 
  
  # the maximum of k range should be less then the number of point
  if(all(is.finite(k))){
    k = k[which(k<nrow(data))]  
  }
  
  #scale data
  data_scaled = data
  if(scale == TRUE){
     data_scaled = scale(data)
  }
  #check method
  cluster_method = match.arg(cluster_method)
  grid_method = match.arg(grid_method)
  eval_method = match.arg(eval_method)
  
  # if no cluster labels and methods are supplied, default to mclust(VII)
  if(is.null(cluster_label)){

    cluster_info =  cluster(data_scaled, k, cluster_method, noise, min_pts, nthread)

  } else { # cluster labels are supplied

    cluster_label = as.numeric(as.factor(cluster_label)) # making labels consecutive

    # # find medoids
    # centers = data[medoids(D = dist(data), cl = cluster_label),,drop=FALSE]

    cluster_info = list(#centers = as.matrix(centers),
                        clusters = cluster_label-1,
                        data = as.matrix(data), cluster_method = "user supply",
                        add_noise = FALSE)
  }

  #browser()
  if(grid_method == "MultiChannel.WUC"){  
    # prepare the weights
    # kstar = length(unique(cluster_info$clusters)) # MS 2022-01-17
    clusters.factor <- factor(cluster_info$clusters) # MS 2022-01-17
    kstar <- nlevels(clusters.factor)                # MS 2022-01-17
    weight = matrix(1/nrow(data), nrow = nrow(data), ncol = kstar)
    for (i in c(1:nrow(data))) {
      # weight[i, cluster_info$clusters[i]+1] = nrow(data)/(nrow(data)+1) # MS 2022-01-17
       weight[i, clusters.factor[i]] = nrow(data)/(nrow(data)+1) # MS 2022-01-17
    }
    grid_lines = vector("list", length = ncol(data))
    # for n dim
    for(dim in c(1:ncol(data))){
      result = Ckmeans.1d.dp::MultiChannel.WUC(x = data[,dim], y = weight, k = c(min_level[dim]:kstar))
      result$size = as.vector(table(result$cluster))
      res <- structure(result,class = "Ckmeans.1d.dp")
      
      res.hist = Ckmeans.1d.dp::ahist(res, data = data[,dim], style = "midpoints", plot = FALSE)
      
      lines = unique(res.hist$breaks)
      grid_lines[[dim]] = lines[-c(1,length(lines))]
    }
  }

  else if(grid_method == "Sort+split"){
    # get grid lines
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), nrow(data), 
                     ncol(data), min_level, max_level, "Density", cutoff, entropy, 
                     FALSE, nthread)
    # filter grid lines
    #grid_lines = lapply(grid_lines, function(i){ 
    #  mx = max(i) 
    #  return(i[-which(i == mx)])
    #  })
  }
  else if(grid_method == "DP"){
     # get grid lines
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), 
                     nrow(data), ncol(data), min_level, max_level, "DP majority",
                     cutoff, entropy, FALSE, nthread)
     
  }
  else if(grid_method == "DP Compressed majority"){
     # get grid lines
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), 
                     nrow(data), ncol(data), min_level, max_level, "DP compressed majority",
                     cutoff, entropy, FALSE, nthread)
     
  }
  else if(grid_method == "DP exact likelihood"){
     # get grid lines
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), 
                     nrow(data), ncol(data), min_level, max_level, "DP exact likelihood", 
                     0, entropy, dim_reduction, nthread)
  }
  else if(grid_method == "DP approx likelihood 1-way"){
     # get grid lines
     #browser()
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), 
                     nrow(data), ncol(data), min_level, max_level, "DP approx likelihood",
                     0, entropy, dim_reduction, nthread)
     #return(grid)
  }
  else if(grid_method == "DP approx likelihood 2-way"){
     # get grid lines
     #browser()
     grid = findgrid(cluster_info, length(unique(cluster_info$clusters)), 
                     nrow(data), ncol(data), min_level, max_level, "DP approx likelihood two way",
                     0, entropy, dim_reduction, nthread)
     #return(grid)
  }
  names(grid) <- c("lines", "table", "discr", "BIC", "purity", "lines_reduc", 
                   "table_reduc", "discr_reduc", "dimensional_bic", "BIC_reduc",
                   "purity_reduc", "line_removed", "upsilon", 
                   "distance_to_median", "distance_to_mean")
  
  names(grid$lines) <- colnames(data)
  if(FALSE){
  # discretize data
  discr_data = discretize_data(data, grid$lines)

  if(eval_method == "ARI"){
     # compute adjusted random index
     ndim_cluster_dist = discr_data[,1]
     if(ncol(discr_data) > 1){
        for(i in 2:ncol(discr_data)){
           ndim_cluster_dist = paste0(ndim_cluster_dist,",",discr_data[,i])
        }
     }
     #browser()
     block_ID = as.factor(ndim_cluster_dist)
     if(nlevels(block_ID) > 1){
        cluster_similarity = mclust::adjustedRandIndex(as.numeric(block_ID)-1, cluster_info$clusters)
     }
     else{
        cluster_similarity = 0
     }
  }
  else if(eval_method == "purity"){
     # compute adjusted random index
     ndim_cluster_dist = discr_data[,1]
     if(ncol(discr_data) > 1){
        for(i in 2:ncol(discr_data)){
           ndim_cluster_dist = paste0(ndim_cluster_dist,",",discr_data[,i])
        }
     }
     #browser()
     block_ID = as.factor(ndim_cluster_dist)
     #cluster_similarity = purity(cluster_info$clusters, as.numeric(block_ID)-1)
     #browser()
     cluster_similarity = ClusterPurity(as.numeric(block_ID)-1, cluster_info$clusters)
  }
  }
  
  # compute adjusted random index
  N = dim(data)[1]
  ndim = dim(data)[2]
  discr_data = grid$discr
  discr_data_after = grid$discr_reduc
  
  discr_data = matrix(unlist(discr_data), nrow=N, byrow = TRUE)
  if(dim_reduction){
     discr_data_after = matrix(unlist(discr_data_after), nrow=N, byrow = TRUE)
  }
  
  # browser()
  # compute adjusted random index for original data
  ndim_cluster_dist = discr_data[,1]
  if(ncol(discr_data) > 1){
     for(i in 2:ncol(discr_data)){
        ndim_cluster_dist = paste0(ndim_cluster_dist,",",discr_data[,i])
     }
  }
  block_ID = as.factor(ndim_cluster_dist)
  if(nlevels(block_ID) > 1){
     #cluster_similarity_o = mclust::adjustedRandIndex(as.numeric(block_ID)-1, cluster_info$clusters)
     cluster_similarity_o =  mclust::adjustedRandIndex(as.numeric(block_ID)-1, cluster_info$clusters)
  }
  else{
     cluster_similarity_o = 0
  }
  
  if(dim_reduction){
     # compute adjusted random index for removed data
     ndim_cluster_dist_r = discr_data_after[,1]
     if(ncol(discr_data_after) > 1){
        for(i in 2:ncol(discr_data_after)){
           ndim_cluster_dist_r = paste0(ndim_cluster_dist_r,",",discr_data_after[,i])
        }
     }
     block_ID = as.factor(ndim_cluster_dist_r)
     if(nlevels(block_ID) > 1){
        #cluster_similarity_r = mclust::adjustedRandIndex(as.numeric(block_ID)-1, cluster_info$clusters)
        cluster_similarity_r =  mclust::adjustedRandIndex(as.numeric(block_ID)-1, cluster_info$clusters)
     }
     else{
        cluster_similarity_r = 0
     }
  }
  else{
     cluster_similarity_r = 0
  }
  
  
  contig_table_o = matrix(unlist(grid$table), nrow=length(grid$table), byrow = TRUE)
  if(dim_reduction){
     contig_table_r = matrix(unlist(grid$table_reduc), nrow=length(grid$table_reduc), byrow = TRUE)
  }
  else{
     contig_table_r = 0
  }
  
  # calculate upsilon
  max_upsilon = (prod(apply(discr_data, 2, function(col) length(unique(col))))*(dim(data)[1]))/(2^(dim(data)[2]))
  u.res = hashed_upsilon(discr_data, TRUE)
  uval = u.res$statistic
  #urelt = uval / max_upsilon * (dim(data)[1])
  urelt = u.res$stat_relt
  pval = u.res$p.value
  #prelt = pchisq(urelt, df=1, lower.tail = FALSE, log.p = TRUE)
  prelt = u.res$p.value_relt
  
  # calculate p-val for upsilon
  #pval = 0
  #pval = pchisq(as.numeric(grid$upsilon), df = (dim(contig_table_o)[1]-1)*(dim(contig_table_o)[2]-1), 
  #              lower.tail = FALSE, log.p = TRUE)
  
  # print(cluster_similarity_o)
  # print(cluster_similarity_r)
  
  class = "GridOnClusters"
  # browser()
  
  # 1. Construct the base list with all the shared, standard outputs
  base_return <- list(
     D               = discr_data, 
     D_after         = discr_data_after, 
     grid            = grid$lines, 
     contig_table    = contig_table_o, 
     clabels         = cluster_info$clusters + 1, 
     dlabels         = as.numeric(block_ID) - 1, 
     dimensional_bic = grid$dimensional_bic, 
     csimilarity     = cluster_similarity_o, 
     BIC             = grid$BIC, 
     pval            = pval, 
     prelt           = prelt,
     upsilon         = uval, 
     upsilon_relt    = urelt, 
     cluster_method  = cluster_method, 
     upsilon_level   = u.res$upsilon_level, 
     add_noise       = cluster_info$add_noise,
     grid_method     = grid_method, 
     data            = data
  )
  
  # 2. Append the debug-specific elements if requested
  if (debug) {
     debug_elements <- list(
        grid_r              = grid$lines_reduc, 
        contig_table_r      = contig_table_r, 
        csimilarity_r       = cluster_similarity_r, 
        BIC_r               = grid$BIC_reduc,
        purity              = grid$purity, 
        purity_r            = grid$purity_reduc, 
        tsp                 = u.res$tsp, 
        tsm                 = u.res$tsm,
        distance_to_median  = grid$distance_to_median, 
        mean_dist_to_median = mean(unlist(grid$distance_to_median)),
        distance_to_mean    = grid$distance_to_mean, 
        mean_dist_to_mean   = mean(unlist(grid$distance_to_mean))
     )
     
     base_return <- c(base_return, debug_elements)
  }
  
  # 3. Apply the class structure once at the end
  res <- structure(base_return, class = class)
}

#' Cluster Multivariate Data
#' 
#' The function obtains clusters from data using the given
#'   number of clusters, which may be a range.
#'   
#' @param data input continuous multivariate data
#' @param k the number(s) of clusters 
#' @param method the method for clustering
#' @param noise adding jitter noise to the data or not
#' @param min_pts an integer to specify the minimum number of points inside a cluster.
#' @param nthread an integer to specify the number of threads
#'
#' @export
cluster = function(data, k, method, noise, min_pts, nthread){
  if(method == "Ball+BIC"){
    if(all(is.finite(k))){
      
       if(noise == TRUE){
          ### TODO: to be complate the part if add noise is needed
          # use noisy Mclust
          #mclust.res = Call_Noisy_Mclust(data, k)
       }else{
          VII.res = ballclust(data, G = k, threads = nthread,
                              min_pts = min_pts, verbose = FALSE)
          
          # add additional information to Mclust (non noisy data)
          if(!is.null(VII.res)){
             VII.res$isNoisy = FALSE  
          }
          else{
             ### TODO: to be complate the part if data too dense
             #mclust.res = Call_Noisy_Mclust(data, k)
          }
       }

      cluster_info = list(
        clusters = as.vector(as.numeric(as.factor(VII.res$classification))-1),
        data = as.matrix(data), method = "Ball+BIC", add_noise = VII.res$isNoisy)
        
    } else {
      ### TODO: to be complate the part if K is infinite
       
      range_k = c(2:8)
      
      # use noisy Mclust
      mclust.res = Call_Noisy_Mclust(data, range_k)
      
      # find the number of best clusters
      BIC_collect = mclust.res$BIC[,1]
      bestk = as.numeric(names(which.max(BIC_collect)))
      ogk = range_k
      while(bestk == max(range_k)){
        
        range_k = c((max(range_k)+1):min((max(range_k)*2), nrow(data)))
        ogk = c(ogk, range_k)
        
        mclust.res = Call_Noisy_Mclust(data, range_k)
        
        BIC_collect = c(BIC_collect, mclust.res$BIC[,1])  
        bestk = as.numeric(names(which.max(BIC_collect)))
        
      }
      
      # # let the user know that the user has reached the best k
      # message(paste0("Reached best k=",bestk,
      #                ". Re-clustering data using the best k."))
      
      # re-cluster using best k
      mclust.res = Call_Noisy_Mclust(data, bestk)
      
      cluster_info = list(
        clusters = as.vector(as.numeric(as.factor(mclust.res$classification))-1),
        data = as.matrix(data), method = method, add_noise = mclust.res$isNoisy)
      
    }
  } else if(method == "mclust_VII"){
     if(all(is.finite(k))){
        
        if(noise == TRUE){
           # use noisy Mclust
           mclust.res = Call_Noisy_Mclust(data, k)
        }else{
           mclust.res = mclust::Mclust(data, G = k, modelNames = "VII", 
                                       verbose = FALSE)
           
           # add additional information to Mclust (non noisy data)
           if(!is.null(mclust.res)){
              mclust.res$isNoisy = FALSE  
           }
           else{
              mclust.res = Call_Noisy_Mclust(data, k)
           }
        }
        
        cluster_info = list(
           clusters = as.vector(as.numeric(as.factor(mclust.res$classification))-1),
           data = as.matrix(data), method = "Ball+BIC", add_noise = mclust.res$isNoisy)
        
     } else {
        
        range_k = c(2:8)
        
        # use noisy Mclust
        mclust.res = Call_Noisy_Mclust(data, range_k)
        
        # find the number of best clusters
        BIC_collect = mclust.res$BIC[,1]
        bestk = as.numeric(names(which.max(BIC_collect)))
        ogk = range_k
        while(bestk == max(range_k)){
           
           range_k = c((max(range_k)+1):min((max(range_k)*2), nrow(data)))
           ogk = c(ogk, range_k)
           
           mclust.res = Call_Noisy_Mclust(data, range_k)
           
           BIC_collect = c(BIC_collect, mclust.res$BIC[,1])  
           bestk = as.numeric(names(which.max(BIC_collect)))
           
        }
        
        # # let the user know that the user has reached the best k
        # message(paste0("Reached best k=",bestk,
        #                ". Re-clustering data using the best k."))
        
        # re-cluster using best k
        mclust.res = Call_Noisy_Mclust(data, bestk)
        
        cluster_info = list(
           clusters = as.vector(as.numeric(as.factor(mclust.res$classification))-1),
           data = as.matrix(data), method = method, add_noise = mclust.res$isNoisy)
        
     }
  } else if(FALSE && method == "Ball+Tele+BIC"){
     # require(Telescope)
     # require(mclust)
     if(all(is.finite(k))){
        # tel = Telescope::telescope(data, TRUE)
        tele_tree = hc(data,modelName = "VII")
        tele_tree[,] = t(tele_tree) # t(my_tree)
        if(noise == TRUE){
           # use noisy Mclust
           mclust.res = Call_Noisy_Mclust(data, k) # , initialization = tele_tree)
        }else{
           mclust.res = mclust::Mclust(data, G = k, modelNames = "VII", 
                                       initialization = tele_tree, verbose = FALSE)
           
           # add additional information to Mclust (non noisy data)
           if(!is.null(mclust.res)){
              mclust.res$isNoisy = FALSE  
           }
        }
        
        cluster_info = list(
           clusters = as.vector(as.numeric(as.factor(mclust.res$classification))-1),
           data = as.matrix(data), method = "Ball+Tele+BIC")
        
     } else {
        
        range_k = c(2:8)
        
        # use noisy Mclust
        mclust.res = Call_Noisy_Mclust(data, range_k)
        
        # find the number of best clusters
        BIC_collect = mclust.res$BIC[,1]
        bestk = as.numeric(names(which.max(BIC_collect)))
        ogk = range_k
        while(bestk == max(range_k)){
           
           range_k = c((max(range_k)+1):min((max(range_k)*2), nrow(data)))
           ogk = c(ogk, range_k)
           
           mclust.res = Call_Noisy_Mclust(data, range_k)
           
           BIC_collect = c(BIC_collect, mclust.res$BIC[,1])  
           bestk = as.numeric(names(which.max(BIC_collect)))
           
        }
        
        # # let the user know that the user has reached the best k
        # message(paste0("Reached best k=",bestk,
        #                ". Re-clustering data using the best k."))
        
        # re-cluster using best k
        mclust.res = Call_Noisy_Mclust(data, bestk)
        
        cluster_info = list(
           clusters = as.vector(as.numeric(as.factor(mclust.res$classification))-1),
           data = as.matrix(data), method = method)
        
     }
     
  } 
   else if(method == "kmeans+silhouette"){
    # is k a single number or a range
    if(length(k) == 1 && is.finite(k)){
      
      # randomly generate centers
      centers = dqsample(which(!duplicated(data)), k)
      
      # get cluster information for 'k'
      kmeans.res = kmeans(data, centers = as.matrix(data[centers,]), 
                            iter.max = 100)
      
      # only keep cluster centers and labels
      cluster_info = list(#centers = as.matrix(kmeans.res$centers),
        clusters = as.vector(kmeans.res$cluster)-1,
        data = as.matrix(data), method = method)
      
    } else if(length(k) == 1 && !is.finite(k)) { # else if k is set to Inf
      
      data_dist = dist(data) # distance matrix for data
      
      # compute cluster info and silhouette score for the cluster range k, 
      # adaptively increasing k until optimal or each sample is placed in their 
      # own cluster
      
      # find unique number of samples
      unq = sum(!duplicated(data))
      if(unq > 8){
        range_k = c(2:8)  
      } else {
        range_k = c(2:unq)
      }
      
      # compute cluster info for original k range
      cluster_info = lapply(range_k, function(i){
        centers = dqsample(which(!duplicated(data)), i)
        data_clust = kmeans(data, centers = as.matrix(data[centers,]),
                            iter.max = 100)
        return(list(data_clust$cluster, data_clust$centers, 
                    mean(silhouette(data_clust$cluster, dist=data_dist)[,3])))
      })
      
      # find the max silhouette
      silhouette_scr = unlist(lapply(cluster_info, function(x){
        return(x[[3]])
      }), use.names = FALSE)
      max_silhouette = max(which.max(silhouette_scr)) # take the bigger k out of those with equal silhouette scores
      
      # while best k is equal to max k, keep computing scores
      bestk = range_k[max_silhouette]
      ogk = range_k
      while(bestk == max(range_k) && unq > 8){
        
        range_k = c((max(range_k)+1):min((max(range_k)*2), nrow(data)))
        ogk = c(ogk, range_k)
        
        # compute cluster info for original k range
        n_cluster_info = lapply(range_k, function(i){
          centers = dqsample(which(!duplicated(data)), i)
          data_clust = kmeans(data, centers = as.matrix(data[centers,]),
                              iter.max = 100)
          return(list(data_clust$cluster, data_clust$centers, 
                      mean(silhouette(data_clust$cluster, dist=data_dist)[,3])))
        })
        
        # append new cluster info to original
        cluster_info = c(cluster_info, n_cluster_info)
        
        # find the max silhouette
        silhouette_scr = unlist(lapply(cluster_info, function(x){
          return(x[[3]])
        }), use.names = FALSE)
        max_silhouette = max(which.max(silhouette_scr)) # take the bigger k out of those with equal silhouette scores
        
        # find best k
        bestk = ogk[max_silhouette]
      }
      
      # only keep cluster centers and labels for the 'k' with max silhouette
      cluster_info = list(#centers = as.matrix(cluster_info[[max_silhouette]][[2]]),
        clusters = as.vector(cluster_info[[max_silhouette]][[1]]-1),
        data = as.matrix(data), cluster_method = method)
      
    } else { # else k is a vector
      
      data_dist = dist(data) # distance matrix for data
      
      # compute cluster info and silhouette score for the cluster range k
      cluster_info = lapply(k, function(i){
        centers = dqsample(which(!duplicated(data)), i)
        data_clust = kmeans(data, centers = as.matrix(data[centers,]),
                            iter.max = 100)
        return(list(data_clust$cluster, data_clust$centers, 
                    mean(silhouette(data_clust$cluster, dist=data_dist)[,3])))
      })
      
      # find the max silhouette
      silhouette_scr = unlist(lapply(cluster_info, function(x){
        return(x[[3]])
      }), use.names = FALSE)
      max_silhouette = max(which.max(silhouette_scr)) # take the bigger k out of those with equal silhouette scores
      
      # only keep cluster centers and labels for the 'k' with max silhouette
      cluster_info = list(#centers = as.matrix(cluster_info[[max_silhouette]][[2]]),
        clusters = as.vector(cluster_info[[max_silhouette]][[1]]-1),
        data = as.matrix(data), cluster_method = method)
      
    }
  }
   else if(method == "PAM"){
      if(length(k) == 1 && is.finite(k)){
         # pre-cluster the data using partition around medoids (PAM)
         cluster_label = pam(x=data, diss = FALSE, metric = "euclidean", k = k)$clustering
      }
      else{
         # error
      }
      
      cluster_info = list(
         clusters = as.vector(cluster_label), data = as.matrix(data), method = method)
   }
   else if(FALSE && method == "telescope"){
      # require(Telescope)
      res <- NULL # Telescope::telescope(data,0)
      cluster_info = list(
         clusters = res$c_label, data = as.matrix(data), method = method)
   }
  return(cluster_info)
}

# for internal use
# takes n-dimensional 'data' and clusters it into 'k' groups using Mclust
# adding noise whenever necessary
Call_Noisy_Mclust = function(data, k){
  
  mclust.res = mclust::Mclust(data, G = k, modelNames = "VII", 
                              verbose = FALSE)
  
  # add additional information to Mclust (non noisy data)
  if(!is.null(mclust.res)){
    mclust.res$isNoisy = FALSE  
  }
  
  # Mclust can return NULL result for sparse discrete data
  amt = 0.5
  noisy_data = data # don't change the original input
  while(is.null(mclust.res)){
    
    # add a little bit of noise to enable clustering
    for(j in 1:ncol(noisy_data)){
      noisy_data[,j] = jitter(as.numeric(noisy_data[,j]), amount = amt)
    }
    
    mclust.res = mclust::Mclust(noisy_data, G = k, modelNames = "VII", 
                                verbose = FALSE)
    
    # add additional information to Mclust (noisy data)
    if(!is.null(mclust.res)){
      mclust.res$isNoisy = TRUE
    }
    
    # double the amount of noise for the next iteration (if any)
    amt = amt*2
    
  }
  
  if(mclust.res$isNoisy){
    warning(paste0("Mclust could not cluster 'data', jitter noise 
            (factor=1, amount=",(amt/2),") was added."))
  }
  
  return(mclust.res)
  
}

# for internal use
# takes n-dimesional 'data' and gridlines to quantify each dimension
discretize_data = function(data, gridlines){
  
  # use gridlines for each dimension
  for(i in 1:ncol(data)){
    
    if(length(unique(gridlines))==0){
      discr = rep(1, nrow(data))
    }else{
      discr = rep(length(gridlines[[i]])+1, nrow(data))
      gridlines[[i]] = sort(gridlines[[i]])
      for(j in 1:length(gridlines[[i]])){ # determine discretization levels
        if(j == 1) {
          discr[data[,i] < gridlines[[i]][j]] = 1
        } else {
          discr[data[,i] < gridlines[[i]][j] & data[,i] >= gridlines[[i]][j-1]] = j
        }
      }
    }
    data[,i] = discr
  }
  
  return(data)
}
