#' Normalise a raw edgelist to the standard three-column format
#'
#' Accepts a matrix or data frame with columns in order: time, sender,
#' receiver. Returns a data frame with named integer columns and an `eveid`
#' dyad key (`"sender_receiver"`).
#'
#' @param raw Matrix or data frame with at least 3 columns.
#'
#' @return A data frame with columns `time` (integer), `sender` (integer),
#'   `receiver` (integer), and `eveid` (character).
#' @examples
#' el <- prepare_edgelist(data.frame(t = 1:5, s = c(1,2,3,1,2), r = c(2,3,1,3,1)))
#' head(el)
#' @export
prepare_edgelist <- function(raw) {
  df <- as.data.frame(raw)
  if (ncol(df) < 3L)
    stop("`raw` must have at least 3 columns: time, sender, receiver.",
         call. = FALSE)
  colnames(df)[1:3] <- c("time", "sender", "receiver")
  df$time     <- as.integer(df$time)
  df$sender   <- as.integer(df$sender)
  df$receiver <- as.integer(df$receiver)
  df$eveid    <- paste0(df$sender, "_", df$receiver)
  df
}

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

get_nodes <- function(edgelist, n_hint = NULL) {
  nodes <- sort(unique(c(edgelist$sender, edgelist$receiver)))
  if (!is.null(n_hint) && n_hint > length(nodes)) nodes <- seq_len(n_hint)
  nodes
}

get_base_riskset <- function(base_riskset, all_nodes) {
  if (!is.null(base_riskset)) return(base_riskset)
  br     <- expand.grid(sender = all_nodes, receiver = all_nodes)
  br     <- br[br$sender != br$receiver, ]
  br$eid <- paste0(br$sender, "_", br$receiver)
  br
}

add_survival_cols <- function(pe) {
  pe$time1 <- 1L
  pe$time2 <- pe$time
  pe
}

# riskset_cov2 ----------------------------------------------------------------
# Compute REM covariates for one event given all past events.
#
# Covariates:
#   Repetition    dyad_occurrence / sender out-degree
#   Recency           time of last reverse-dyad event
#   RecRecencyLog     -log1p(Recency); 0 if j→i never fired
#   RecRecencyRank    1 / rank(j in i's reverse-dyad recency list); 0 if never fired
#   IOviolation   interaction-order violation (Simmelian sequence)
#   Imitation     (recInDg - dyad_occurrence) / per-sender max; cumulative in-degree of receiver from all other senders
#   OTP / ITP / OSP / ISP   raw two-path / shared-partner counts
#   NOTPSnd       OTP normalised by per-sender max (transitivity)
#   NITPSnd       ITP normalised by per-sender max (cyclic closure)
#   NOSPSnd       OSP normalised by per-sender max (outbound shared partners)
#   NISPSnd       ISP normalised by per-sender max (inbound shared partners)
#   NTriadic      normalised sum of all four (backward-compat composite)
#   OutDegSnd     normalised out-degree of sender:   senOutDg / (n-1)
#   OutDegRec     normalised out-degree of receiver: recOutDg / (n-1)
#   InDegSnd      normalised in-degree  of sender:   senInDg  / (n-1)
#   InDegRec      normalised in-degree  of receiver: recInDg  / (n-1)
#   TotDegSnd     normalised total degree of sender:   (senOutDg + senInDg) / (2*(n-1))
#   TotDegRec     normalised total degree of receiver: (recOutDg + recInDg) / (2*(n-1))
#   FrRecSnd      count(j→i prior events) / in-degree(i); cf. relevent FrRecSnd
#   PSABBA        p-shift AB→BA: was last event A→B and this is B→A? (turn receiving)
#   PSABBY        p-shift AB→BY: was last event A→B and this is B→Y? (turn receiving, new target)
#   PSABAY        p-shift AB→AY: was last event A→B and this is A→Y (Y≠B)? (turn continuing)
#   PSABXA        p-shift AB→XA: last event A→B, this is X→A (X≠B, X≠A)? (turn usurping, to A)
#   PSABXB        p-shift AB→XB: last event A→B, this is X→B (X≠A, X≠B)? (turn usurping, to B)
#   PSABXY        p-shift AB→XY: no shared sender/receiver with last event? (turn usurping, to new)
#   SndInitiation has this dyad occurred before? (binary)
#   RRecInitiation has the reverse dyad ever occurred? (reciprocity binary)
#   DyadRecencyLog  -log1p(time of last same-dyad event); 0 if never fired
#   WeightedRep      count of same-dyad events / per-sender max
#   WeightedRec      min(w_ij, w_ji) / max(w_ij, w_ji); Squartini et al. (2013)
#   occurred      1 for the focal event, 0 for non-events

riskset_cov2 <- function(past_events, current_event,
                          n_nonevents = NULL, base_riskset = NULL,
                          covariates = NULL) {
  riskset <- base_riskset
  ev      <- match(current_event$eveid, riskset$eid)

  if (!is.null(n_nonevents) && n_nonevents < nrow(riskset)) {
    keep    <- unique(c(ev, sample.int(nrow(riskset), n_nonevents)))
    riskset <- riskset[keep, ]
  }

  # Repetition
  outd                    <- table(past_events$sender)
  riskset$senOutDg        <- outd[match(riskset$sender, names(outd))]
  docc                    <- table(past_events$eveid)
  riskset$dyad_occurrence <- docc[match(riskset$eid, names(docc))]
  riskset$Repetition      <- riskset$dyad_occurrence / riskset$senOutDg

  # Recency
  last_time             <- tapply(past_events$time, past_events$eveid, max)
  recip_ids             <- paste0(riskset$receiver, "_", riskset$sender)
  riskset$Recency       <- last_time[match(recip_ids, names(last_time))]
  riskset$Recency[is.na(riskset$Recency)] <- 0
  riskset$RecRecencyLog <- -log1p(riskset$Recency)
  # RecRecencyRank: 1 / recency rank of j among senders to i (most recent = rank 1).
  # Analogous to relevent's RRecSnd: coef*(1/rank) contribution to i->j log-hazard.
  # Uses a deduplicated sender list ordered most-recent-first (same as relevent rrl).
  recip_senders <- split(past_events$sender,
                         past_events$receiver)[as.character(riskset$sender)]
  recip_times   <- split(past_events$time,
                         past_events$receiver)[as.character(riskset$sender)]
  riskset$RecRecencyRank <- mapply(function(snd_list, t_list, j) {
    if (is.null(snd_list) || length(snd_list) == 0L) return(0)
    # Deduplicate: keep only the most recent event per sender
    ord  <- order(t_list, decreasing = TRUE)
    uniq <- !duplicated(snd_list[ord])
    ranked_senders <- snd_list[ord][uniq]
    pos <- match(j, ranked_senders)
    if (is.na(pos)) 0 else 1 / pos
  }, recip_senders, recip_times, riskset$receiver,
  SIMPLIFY = TRUE, USE.NAMES = FALSE)

  # Interaction-order violation
  lastsender   <- utils::tail(past_events$sender,   1L)
  lastreceiver <- utils::tail(past_events$receiver, 1L)
  riskset$PSABAB <- riskset$sender   == lastsender   & riskset$receiver == lastreceiver
  riskset$PSABBA <- riskset$receiver == lastsender   & riskset$sender   == lastreceiver
  riskset$PSABAY <- riskset$sender   == lastsender   & !riskset$PSABAB
  riskset$PSABXA <- riskset$receiver == lastsender   & !riskset$PSABBA
  riskset$PSABXB <- riskset$receiver == lastreceiver & !riskset$PSABAB
  riskset$PSABBY <- riskset$sender   == lastreceiver & !riskset$PSABBA
  riskset$PSABXY <- (riskset$PSABXB + riskset$PSABXA + riskset$PSABAY +
                       riskset$PSABAB + riskset$PSABBA + riskset$PSABBY) == 0
  riskset$IOviolation <- riskset$PSABXA | riskset$PSABXY | riskset$PSABXB

  # Imitation
  indg                 <- table(past_events$receiver)
  riskset$recInDg      <- indg[match(riskset$receiver, names(indg))]
  riskset$PreImitation <- riskset$recInDg - riskset$dyad_occurrence
  riskset$PreImitation[is.na(riskset$PreImitation)] <- 0
  imimx                <- tapply(riskset$PreImitation, riskset$sender, max)
  imimx                <- imimx[match(riskset$sender, names(imimx))]
  riskset$Imitation    <- riskset$PreImitation / imimx

  # Triadic closure (OSP, ISP, ITP, OTP) via C++
  adj              <- build_adjacency(past_events$sender, past_events$receiver)
  counts           <- triadic_counts(adj, riskset$sender, riskset$receiver)
  riskset$OSP <- counts[, "OSP"]
  riskset$ISP <- counts[, "ISP"]
  riskset$ITP <- counts[, "ITP"]
  riskset$OTP <- counts[, "OTP"]

  # Per-sender-max normalisation (mirrors relevent's *Snd convention)
  norm_by_sender_max <- function(x, sender) {
    mx <- tapply(x, sender, max)
    mx <- mx[match(sender, names(mx))]
    ifelse(mx > 0, x / mx, 0)
  }
  riskset$NOTPSnd <- norm_by_sender_max(riskset$OTP, riskset$sender)
  riskset$NITPSnd <- norm_by_sender_max(riskset$ITP, riskset$sender)
  riskset$NOSPSnd <- norm_by_sender_max(riskset$OSP, riskset$sender)
  riskset$NISPSnd <- norm_by_sender_max(riskset$ISP, riskset$sender)

  riskset$triadic_closure <- riskset$OSP + riskset$ISP + riskset$ITP + riskset$OTP
  riskset$triadic_closure[is.na(riskset$triadic_closure)] <- 0L
  mx_tri           <- max(riskset$triadic_closure)
  riskset$NTriadic <- if (mx_tri > 0) riskset$triadic_closure / mx_tri else 0

  # Degree statistics scaled to [0,1] by (n-1).
  # Denominator is fixed at (n-1), the maximum possible degree given n nodes.
  # This differs from relevent's NODSnd/NODRec/NIDSnd/NIDRec, which divide by
  # the event-index `it` — a running count that changes each step.
  n_norm                  <- max(length(unique(c(base_riskset$sender,
                                                 base_riskset$receiver))) - 1L,
                                 1L)
  senInDg                 <- table(past_events$receiver)
  recOutDg                <- table(past_events$sender)
  riskset$OutDegSnd <- as.numeric(riskset$senOutDg) / n_norm
  riskset$OutDegSnd[is.na(riskset$OutDegSnd)] <- 0
  riskset$OutDegRec <- as.numeric(recOutDg[match(riskset$receiver, names(recOutDg))]) / n_norm
  riskset$OutDegRec[is.na(riskset$OutDegRec)] <- 0
  riskset$InDegSnd <- as.numeric(senInDg[match(riskset$sender, names(senInDg))]) / n_norm
  riskset$InDegSnd[is.na(riskset$InDegSnd)] <- 0
  riskset$InDegRec <- as.numeric(riskset$recInDg) / n_norm
  riskset$InDegRec[is.na(riskset$InDegRec)] <- 0

  # TotDegSnd / TotDegRec: (out-degree + in-degree) / (2*(n-1)).
  # Matches relevent's NTDegSnd/NTDegRec formula (same normalisation convention).
  riskset$TotDegSnd <- (riskset$OutDegSnd + riskset$InDegSnd) / 2
  riskset$TotDegRec <- (riskset$OutDegRec + riskset$InDegRec) / 2

  # Individual p-shift indicators (one-step-back patterns from last event).
  # IOviolation is the composite PSABXA | PSABXB | PSABXY (already present).
  # Expose the six individual indicators for users who want finer resolution.
  riskset$PSAB_BA <- as.numeric(riskset$PSABBA)
  riskset$PSAB_BY <- as.numeric(riskset$PSABBY)
  riskset$PSAB_AY <- as.numeric(riskset$PSABAY)
  riskset$PSAB_XA <- as.numeric(riskset$PSABXA)
  riskset$PSAB_XB <- as.numeric(riskset$PSABXB)
  riskset$PSAB_XY <- as.numeric(riskset$PSABXY)

  riskset$occurred        <- as.numeric(riskset$eid %in% current_event$eveid)
  riskset$time            <- current_event$time
  riskset$SndInitiation   <- as.numeric(riskset$dyad_occurrence > 0)
  riskset$RRecInitiation  <- as.numeric(!is.na(last_time[match(recip_ids, names(last_time))]))
  snd_last                <- last_time[match(riskset$eid, names(last_time))]
  snd_last[is.na(snd_last)] <- 0
  # DyadRecencyLog: -log1p(clock-time of last i→j event); 0 if never fired.
  # Not comparable to relevent's RSndSnd (a recency rank, opposite sign).
  riskset$DyadRecencyLog  <- -log1p(snd_last)

  # WeightedRep: count of same-dyad (i→j) events, normalised by per-sender max.
  # WeightedRec: weighted reciprocity per Squartini et al. (2013):
  #   min(w_ij, w_ji) / max(w_ij, w_ji); 0 when both are 0.
  rev_docc                <- docc[match(recip_ids, names(docc))]
  rev_docc[is.na(rev_docc)] <- 0L
  riskset$w_ji            <- as.integer(rev_docc)
  w_ij                    <- as.integer(riskset$dyad_occurrence)
  w_ij[is.na(w_ij)]      <- 0L
  riskset$WeightedRep     <- norm_by_sender_max(w_ij, riskset$sender)
  w_max                   <- pmax(w_ij, riskset$w_ji)
  w_min                   <- pmin(w_ij, riskset$w_ji)
  riskset$WeightedRec     <- ifelse(w_max > 0L, w_min / w_max, 0)

  # FrRecSnd: count(j→i past events) / in-degree(i); zero when in-degree is 0.
  # Matches relevent's FrRecSnd: fraction of i's past receipts that came from j.
  # Distinct from WeightedRep which normalises by per-sender max instead.
  sen_indeg               <- as.numeric(senInDg[match(riskset$sender, names(senInDg))])
  sen_indeg[is.na(sen_indeg)] <- 0
  riskset$FrRecSnd <- ifelse(sen_indeg > 0,
                              as.numeric(riskset$w_ji) / sen_indeg, 0)

  if (!is.null(covariates))
    riskset <- attach_covariates(riskset, covariates, current_event$time)

  riskset
}

# remstats_generate -----------------------------------------------------------

remstats_generate <- function(edgelist, n = NULL, n_nonevents = NULL,
                               cl = NULL, base_riskset = NULL,
                               covariates = NULL) {
  if (is.null(n))
    n <- length(unique(c(edgelist$sender, edgelist$receiver)))
  if (is.null(n_nonevents))
    n_nonevents <- n * (n - 1L)

  if (is.null(base_riskset)) {
    base_riskset     <- expand.grid(sender = seq_len(n), receiver = seq_len(n))
    base_riskset     <- base_riskset[base_riskset$sender != base_riskset$receiver, ]
  }
  base_riskset$eid <- paste0(base_riskset$sender, "_", base_riskset$receiver)
  edgelist$eveid   <- paste0(edgelist$sender, "_", edgelist$receiver)

  if (!is.null(cl)) {
    parallel::clusterExport(
      cl, c("n", "n_nonevents", "edgelist", "riskset_cov2", "base_riskset",
            "covariates", "attach_covariates", ".node_vals", ".find_window"),
      envir = environment()
    )
    pre <- parallel::parLapply(cl, seq(2L, nrow(edgelist)), \(y)
      riskset_cov2(edgelist[seq_len(y - 1L), ], edgelist[y, ],
                   n_nonevents, base_riskset, covariates))
  } else {
    pre <- vector("list", nrow(edgelist))
    for (y in seq(2L, nrow(edgelist)))
      pre[[y]] <- riskset_cov2(edgelist[seq_len(y - 1L), ], edgelist[y, ],
                                n_nonevents, base_riskset, covariates)
  }

  rs <- do.call(rbind.data.frame, pre)
  for (i in which(vapply(rs, is.logical, logical(1L))))
    rs[[i]] <- as.numeric(rs[[i]])

  # NA and non-finite fill applies only to endogenous columns; exogenous
  # covariate columns keep their NAs so the analyst can see missing data.
  exog_cols <- if (!is.null(covariates))
    unlist(lapply(covariates$covariates, function(cv) {
      if (inherits(cv, c("cov_node_static", "cov_node_temporal")) &&
          cv$role == "both")
        c(paste0(cv$name, "_snd"), paste0(cv$name, "_rec"))
      else
        cv$name
    }))
  else
    character(0L)

  endog_cols <- setdiff(names(rs), exog_cols)
  rs[endog_cols] <- lapply(rs[endog_cols], function(x) {
    if (is.numeric(x)) { x[is.na(x)] <- 0; x[!is.finite(x)] <- 0 }
    x
  })
  rs
}
