#' Group points
#'
#' `group_pts` groups rows into spatial groups. The function expects a
#' `data.table` with relocation data, individual identifiers and a
#' threshold argument. The threshold argument is used to specify the criteria
#' for distance between points which defines a group. Relocation data should be
#' in two columns representing the X and Y coordinates, or in a
#' geometry column prepared by the helper function
#' [get_geometry()].
#'
#' The `DT` must be a `data.table`. If your data is a
#' `data.frame`, you can convert it by reference using
#' [data.table::setDT()] or by reassigning using
#' [data.table::data.table()].
#'
#' The `id`, `timegroup`  (and optional `splitBy`)
#' arguments expect the names of columns in `DT` which correspond to the
#' individual identifier, and timegroup (generated by
#' `group_times`) and additional grouping columns.
#'
#' The `threshold` provided should match the units of the coordinates. The
#' `threshold` can be provided with units specified using the units package (eg.
#' `threshold = units::set_units(10, m)`) which will be checked against the
#' units of the coordinates using the `crs`. If units are not specified, the
#' `threshold` is assumed to be in the units of the coordinates.
#'
#' The `timegroup` argument is required to define the temporal groups
#' within which spatial groups are calculated. The intended framework is to
#' group rows temporally with `group_times` then spatially with
#' `group_pts` (or `group_lines`, `group_polys`).
#' If you have already calculated temporal groups without
#' `group_times`, you can pass this column to the `timegroup`
#' argument. Note that the expectation is that each individual will be observed
#' only once per timegroup. Caution that accidentally including huge numbers of
#' rows within timegroups can overload your machine since all pairwise distances
#' are calculated within each timegroup.
#'
#' The `splitBy` argument offers further control over grouping. If within
#' your `DT`, you have multiple populations, subgroups or other distinct
#' parts, you can provide the name of the column which identifies them to
#' `splitBy`. The grouping performed by `group_pts` will only consider
#' rows within each `splitBy` subgroup.
#'
#' See below under "Interface" for details on providing coordinates and under
#' "Distance function" for details on underlying distance function used.
#'
#' @inheritSection distance_to_centroid Interface
#' @inheritSection distance_to_centroid Distance function
#'
#' @return `group_pts` returns the input `DT` appended with a
#'   `group` column.
#'
#'   This column represents the spatialtemporal group. As with the other
#'   grouping functions,  the actual value of `group` is arbitrary and
#'   represents the identity of a given group where 1 or more individuals are
#'   assigned to a group. If the data was reordered, the `group` may
#'   change, but the contents of each group would not.
#'
#'   A message is returned when a column named `group` already exists in
#'   the input `DT`, because it will be overwritten.
#'
#'   See details for appending outputs using modify-by-reference in the
#'   [FAQ](https://docs.ropensci.org/spatsoc/articles/faq.html).
#'
#' @param DT input data.table
#' @param threshold distance for grouping points, either numeric or units, in
#'   the units of the crs / the coordinates or geometry
#' @param id character string of ID column name
#' @param coords character vector of X coordinate and Y coordinate column names.
#' Note: the order is assumed X followed by Y column names
#' @param timegroup timegroup field in the DT within which the output will be
#'   calculated
#' @param crs numeric or character defining the coordinate reference
#'   system to be passed to [sf::st_crs]. For example, either
#'   `crs = "EPSG:32736"` or `crs = 32736`. Used only if coords are provided,
#'   see details under Interface
#' @param splitBy (optional) character string or vector of grouping column
#'   name(s) upon which the output will be calculated
#' @param geometry simple feature geometry list column name, generated by
#'   [get_geometry()]. Default 'geometry', see details under Interface
#'
#' @export
#'
#' @family Spatial grouping
#' @seealso `group_times`
#'
#' @examples
#' # Load data.table
#' library(data.table)
#' \dontshow{data.table::setDTthreads(1)}
#'
#' # Read example data
#' DT <- fread(system.file("extdata", "DT.csv", package = "spatsoc"))
#'
#' # Select only individuals A, B, C for this example
#' DT <- DT[ID %in% c('A', 'B', 'C')]
#'
#' # Cast the character column to POSIXct
#' DT[, datetime := as.POSIXct(datetime, tz = 'UTC')]
#'
#' # Temporal grouping
#' group_times(DT, datetime = 'datetime', threshold = '20 minutes')
#'
#' # Spatial grouping with timegroup
#' group_pts(DT, threshold = 5, id = 'ID',
#'           coords = c('X', 'Y'), timegroup = 'timegroup')
#'
#' # Spatial grouping with timegroup and splitBy on population
#' group_pts(DT, threshold = 5, id = 'ID', coords = c('X', 'Y'),
#'          timegroup = 'timegroup', splitBy = 'population')
#'
#' # Or, using the new geometry interface
#' get_geometry(DT, coords = c('X', 'Y'), crs = 32736)
#' group_pts(DT, threshold = 50, id = 'ID', timegroup = 'timegroup')
group_pts <- function(
    DT = NULL,
    threshold = NULL,
    id = NULL,
    coords = NULL,
    timegroup,
    crs = NULL,
    splitBy = NULL,
    geometry = 'geometry') {
  # due to NSE notes in R CMD check
  N <- withinGroup <- group <- geo <- x <- y <- NULL

  assert_not_null(DT)
  assert_is_data_table(DT)
  assert_not_null(id)
  assert_not_null(threshold)
  assert_not_missing(timegroup)
  check_colnames <- c(timegroup, id, splitBy)
  assert_are_colnames(DT, check_colnames)

  if (!is.null(timegroup)) {
    if (any(unlist(lapply(DT[, .SD, .SDcols = timegroup], class)) %in%
            c('POSIXct', 'POSIXlt', 'Date', 'IDate', 'ITime', 'character'))) {
      warning(
        strwrap(
          prefix = " ",
          initial = "",
          x = 'timegroup provided is a date/time
          or character type, did you use group_times?'
        )
      )
    }
  }

  if (DT[, .N, by = c(id, splitBy, timegroup)][N > 1, sum(N)] != 0) {
    warning(
      strwrap(
        prefix = " ",
        initial = "",
        x = 'found duplicate id in a
          timegroup and/or splitBy -
          does your group_times threshold match the fix rate?'
      )
    )
  }

  if (is.null(coords)) {
    if (!is.null(crs)) {
      message('crs argument is ignored when coords are null, using geometry')
    }

    assert_are_colnames(DT, geometry, ', did you run get_geometry()?')
    assert_col_inherits(DT, geometry, 'sfc_POINT')

    crs <- sf::st_crs(DT[[geometry]])
    assert_threshold(threshold, crs)

    use_dist <- isFALSE(sf::st_is_longlat(crs)) || identical(crs, sf::NA_crs_)

    if (!inherits(threshold, 'units') && !identical(crs, sf::NA_crs_) &&
        !use_dist) {
      threshold <- units::as_units(threshold, 'm')
    }

    DT[!sf::st_is_empty(geo), withinGroup := {
      distMatrix <- calc_distance(
        geometry_a = geo,
        use_dist = use_dist
      )
      graphAdj <-
        igraph::graph_from_adjacency_matrix(distMatrix <= threshold)
      igraph::components(graphAdj)$membership
    },
    by = c(splitBy, timegroup),
    env = list(geo = geometry)]

    if ('group' %in% colnames(DT)) {
      message('group column will be overwritten by this function')
      data.table::set(DT, j = 'group', value = NULL)
    }

    DT[!sf::st_is_empty(geo), group := .GRP,
      by = c(splitBy, timegroup, 'withinGroup'),
      env = list(geo = geometry)]
  } else {
    if (is.null(crs)) {
      crs <- sf::NA_crs_
    }

    assert_threshold(threshold, crs)
    assert_length(coords, 2)
    assert_are_colnames(DT, coords)
    assert_col_inherits(DT, coords, 'numeric')

    xcol <- data.table::first(coords)
    ycol <- data.table::last(coords)

    if ('group' %in% colnames(DT)) {
      message('group column will be overwritten by this function')
      data.table::set(DT, j = 'group', value = NULL)
    }

    use_dist <- isFALSE(sf::st_is_longlat(crs)) || identical(crs, sf::NA_crs_)

    if (!inherits(threshold, 'units') && !identical(crs, sf::NA_crs_) &&
        !use_dist) {
      threshold <- units::as_units(threshold, 'm')
    }

    DT[!is.na(x) & !is.na(y),
      withinGroup := {
        distMatrix <- calc_distance(
          x_a = x, y_a = y, crs = crs,
          use_dist = use_dist
        )
        graphAdj <-
          igraph::graph_from_adjacency_matrix(distMatrix <= threshold)
        igraph::components(graphAdj)$membership
      },
      by = c(splitBy, timegroup),
      env = list(x = xcol, y = ycol)
    ]

    DT[!is.na(x) & !is.na(y),
      group := .GRP,
      by = c(splitBy, timegroup, 'withinGroup'),
      env = list(x = xcol, y = ycol)
    ]
  }

  data.table::set(DT, j = 'withinGroup', value = NULL)

  return(DT[])
}
