#' Print method for taxodist distance results
#'
#' @param x A list returned by [taxo_distance()].
#' @param ... Additional arguments (ignored).
#' @return Invisibly returns \code{x}. Called for side effects (printing).
#' @export
print.taxodist_result <- function(x, ...) {
  cli::cli_h2("Taxonomic Distance")
  cli::cli_bullets(c(
    "*" = "{x$taxon_a} vs {x$taxon_b}",
    " " = "Distance : {x$distance}",
    " " = "MRCA     : {x$mrca} (depth {x$mrca_depth})",
    " " = "Depth A  : {x$depth_a}",
    " " = "Depth B  : {x$depth_b}"
  ))
  invisible(x)
}

#' Compare lineages of two taxa side by side
#'
#' Prints the lineages of two taxa aligned at their most recent common
#' ancestor, making the point of divergence easy to identify.
#'
#' @param taxon_a A character string giving the first taxon name.
#' @param taxon_b A character string giving the second taxon name.
#' @param verbose Logical. If `TRUE`, prints progress messages. Default `FALSE`.
#'
#' @return Invisibly returns a list with elements `lineage_a`, `lineage_b`,
#'   and `mrca_depth`.
#'
#' @export
#' @examples
#' \donttest{
#' compare_lineages("Tyrannosaurus", "Velociraptor")
#' compare_lineages("Tyrannosaurus", "Triceratops")
#' }
compare_lineages <- function(taxon_a, taxon_b, verbose = FALSE) {
  lin_a <- get_lineage(taxon_a, verbose = verbose)
  lin_b <- get_lineage(taxon_b, verbose = verbose)

  if (is.null(lin_a) || is.null(lin_b)) {
    cli::cli_alert_danger("Could not retrieve one or both lineages")
    return(invisible(NULL))
  }

  result <- .compute_distance(lin_a, lin_b, taxon_a, taxon_b)
  mrca_d <- result$mrca_depth

  cli::cli_h2("Lineage Comparison")
  cli::cli_text("MRCA: {result$mrca} at depth {mrca_d}")
  cli::cli_text("")

  # shared trunk
  if (mrca_d > 0) {
    cli::cli_text("{.strong Shared lineage ({mrca_d} nodes):}")
    for (node in lin_a[seq_len(mrca_d)]) {
      cli::cli_bullets(c(" " = "{node}"))
    }
  }

  # divergence
  cli::cli_text("")
  cli::cli_text("{.strong {taxon_a} only ({length(lin_a) - mrca_d} nodes):}")
  if (length(lin_a) > mrca_d) {
    for (node in lin_a[(mrca_d + 1):length(lin_a)]) {
      cli::cli_bullets(c(">" = "{node}"))
    }
  }

  cli::cli_text("")
  cli::cli_text("{.strong {taxon_b} only ({length(lin_b) - mrca_d} nodes):}")
  if (length(lin_b) > mrca_d) {
    for (node in lin_b[(mrca_d + 1):length(lin_b)]) {
      cli::cli_bullets(c(">" = "{node}"))
    }
  }

  invisible(list(
    lineage_a  = lin_a,
    lineage_b  = lin_b,
    mrca_depth = mrca_d
  ))
}

#' List all clades shared between two taxa
#'
#' Returns the vector of clade names forming the shared trunk of two taxa's
#' lineages, from root down to (and including) their MRCA.
#'
#' @param taxon_a A character string giving the first taxon name.
#' @param taxon_b A character string giving the second taxon name.
#' @param verbose Logical. If `TRUE`, prints progress messages. Default `FALSE`.
#'
#' @return A character vector of shared clade names ordered from root to MRCA,
#'   or `NULL` if either taxon cannot be found.
#'
#' @export
#' @examples
#' \donttest{
#' shared_clades("Tyrannosaurus", "Velociraptor")
#' shared_clades("Tyrannosaurus", "Homo")
#' }
shared_clades <- function(taxon_a, taxon_b, verbose = FALSE) {
  lin_a <- get_lineage(taxon_a, verbose = verbose)
  lin_b <- get_lineage(taxon_b, verbose = verbose)
  if (is.null(lin_a) || is.null(lin_b)) return(NULL)

  result <- .compute_distance(lin_a, lin_b, taxon_a, taxon_b)
  if (result$mrca_depth == 0) return(character(0))
  lin_a[seq_len(result$mrca_depth)]
}

#' Test whether one taxon is nested within another
#'
#' Returns `TRUE` if `taxon` is a member of `clade` — i.e., if the complete
#' clade name appears in the taxon's lineage. Matching is case-insensitive and
#' ignores leading and trailing whitespace.
#'
#' @param taxon A character string giving the taxon name to test.
#' @param clade A character string giving the clade name to test membership in.
#' @param verbose Logical. If `TRUE`, prints progress messages. Default `FALSE`.
#'
#' @return A logical value, or `NULL` if the taxon cannot be found.
#'
#' @export
#' @examples
#' \donttest{
#' is_member("Tyrannosaurus", "Theropoda")    # TRUE
#' is_member("Triceratops", "Theropoda")      # FALSE
#' is_member("Homo", "Amniota")               # TRUE
#' }
is_member <- function(taxon, clade, verbose = FALSE) {
  lin <- get_lineage(taxon, verbose = verbose)
  if (is.null(lin)) return(NULL)
  normalized_lineage <- tolower(trimws(lin))
  normalized_clade   <- tolower(trimws(clade))
  any(normalized_lineage %in% normalized_clade)
}

#' Filter a vector of taxa to those belonging to a given clade
#'
#' Given a vector of taxon names and a clade name, returns only those taxa
#' whose lineage includes the specified clade.
#'
#' @param taxa A character vector of taxon names.
#' @param clade A character string giving the clade to filter by.
#' @param verbose Logical. If `TRUE`, prints progress messages. Default `FALSE`.
#'
#' @return A character vector of taxa that are members of the specified clade.
#'
#' @export
#' @examples
#' \donttest{
#' taxa <- c("Tyrannosaurus", "Triceratops", "Velociraptor",
#'           "Brachiosaurus", "Homo")
#' filter_clade(taxa, "Theropoda")
#' filter_clade(taxa, "Dinosauria")
#' }
filter_clade <- function(taxa, clade, verbose = FALSE) {
  keep <- purrr::map_lgl(taxa, function(t) {
    result <- is_member(t, clade, verbose = verbose)
    if (is.null(result)) FALSE else result
  })
  taxa[keep]
}

#' Plot method for taxodist_cluster objects
#'
#' Plots the hierarchical clustering dendrogram produced by [taxo_cluster()].
#'
#' @param x A `taxodist_cluster` object from [taxo_cluster()].
#' @param main Plot title. Default: `"Taxonomic Clustering"`.
#' @param xlab X-axis label. Default: `""`.
#' @param sub Subtitle. Default: clustering method used.
#' @param ... Additional arguments passed to [stats::plot.hclust()].
#'
#' @return Invisibly returns `x`. Called for side effects (plotting).
#' @export
#' @examples
#' \donttest{
#' taxa <- c("Tyrannosaurus", "Velociraptor", "Triceratops", "Brachiosaurus")
#' cl <- taxo_cluster(taxa)
#' plot(cl)
#' }
plot.taxodist_cluster <- function(x, main = "Taxonomic Clustering",
                                  xlab = "",
                                  sub  = paste("Method:", if(!is.null(x$hclust)) x$hclust$method else "None"),
                                  ...) {
  if (is.null(x$hclust)) return(invisible(x))
  plot(x$hclust, main = main, xlab = xlab, sub = sub, ...)
  invisible(x)
}

#' Plot method for taxodist_ord objects
#'
#' Plots the PCoA ordination produced by [taxo_ordinate()].
#'
#' @param x A `taxodist_ord` object from [taxo_ordinate()].
#' @param main Plot title. Default: `"Taxonomic Ordination (PCoA)"`.
#' @param xlab X-axis label. Default: `"PC1"`.
#' @param ylab Y-axis label. Default: `"PC2"`.
#' @param labels Character vector of labels. Default: rownames of points.
#' @param ... Additional arguments passed to [graphics::text()].
#'
#' @return Invisibly returns `x`. Called for side effects (plotting).
#' @export
#' @examples
#' \donttest{
#' taxa <- c("Tyrannosaurus", "Velociraptor", "Triceratops", "Brachiosaurus")
#' ord <- taxo_ordinate(taxa)
#' plot(ord)
#' }
plot.taxodist_ord <- function(x, main   = "Taxonomic Ordination (PCoA)",
                              xlab   = "PC1",
                              ylab   = "PC2",
                              labels = if(!is.null(x$points)) rownames(x$points) else NULL,
                              ...) {
  if (is.null(x$points)) return(invisible(x))
  gof <- round(x$GOF[1], 3)
  plot_points <- if (ncol(x$points) == 1L) {
    cbind(PC1 = x$points[, 1], PC2 = 0)
  } else {
    x$points[, 1:2, drop = FALSE]
  }
  graphics::plot(plot_points, type = "n",
                 main = paste0(main, "  (GOF = ", gof, ")"),
                 xlab = xlab, ylab = ylab)
  graphics::text(plot_points, labels = labels, ...)
  invisible(x)
}

#' Summary method for taxodist_ord objects
#'
#' Prints a summary of the PCoA ordination, including the goodness-of-fit
#' and the proportion of variance explained by each principal coordinate.
#'
#' @param object A \code{taxodist_ord} object from \code{\link{taxo_ordinate}}.
#' @param ... Additional arguments (ignored).
#'
#' @return Invisibly returns a data frame containing the eigenvalues and
#'   variance explained by each dimension.
#' @export
summary.taxodist_ord <- function(object, ...) {
  if (is.null(object$points)) return(invisible(NULL))

  cli::cli_h2("Taxonomic Ordination Summary (PCoA)")
  gof <- round(object$GOF[1] * 100, 2)
  cli::cli_text("Goodness-of-Fit (GOF): {gof}%")
  cli::cli_text("")

  if (!is.null(object$eig)) {
    eig_pos <- object$eig[object$eig > 0]
    var_exp <- (eig_pos / sum(eig_pos)) * 100
    k <- ncol(object$points)

    df <- data.frame(
      Axis           = paste0("PC", 1:k),
      Eigenvalue     = object$eig[1:k],
      Variance_Pct   = var_exp[1:k],
      Cumulative_Pct = cumsum(var_exp)[1:k]
    )
    print(df, row.names = FALSE, digits = 4)
    invisible(df)
  } else {
    cli::cli_alert_warning("Eigenvalues not found in the object.")
    invisible(NULL)
  }
}

#' Plot a taxonomic heatmap
#'
#' Computes pairwise taxonomic distances and plots a heatmap with hierarchical
#' clustering dendrograms on the margins. Darker/hotter colors typically
#' represent smaller distances (closer relatives).
#'
#' @param taxa A character vector of taxon names, or a \code{dist} object from
#'   \code{\link{distance_matrix}}.
#' @param ... Additional arguments passed to \code{\link[stats]{heatmap}}.
#'
#' @return Invisibly returns the underlying \code{dist} object.
#'   Called primarily for its side effect (plotting).
#'   If fewer than two taxa are supplied, plotting is skipped.
#' @export
#' @examples
#' \donttest{
#' taxa <- c("Tyrannosaurus", "Velociraptor", "Homo", "Panthera", "Quercus")
#' taxo_heatmap(taxa)
#' }
taxo_heatmap <- function(taxa, ...) {
  d <- if (inherits(taxa, "dist")) taxa else distance_matrix(taxa, ...)
  if (any(is.na(d))) {
    cli::cli_warn("Distance matrix contains NA values. Heatmap skipped.")
    return(invisible(d))
  }
  if (any(!is.finite(d))) {
    cli::cli_warn("Distance matrix contains infinite values (no shared ancestor). Heatmap skipped.")
    return(invisible(d))
  }
  if (attr(d, "Size") < 2L) {
    cli::cli_warn("At least two taxa are required for a heatmap. Heatmap skipped.")
    return(invisible(d))
  }
  mat <- as.matrix(d)
  stats::heatmap(mat, symm = TRUE, margins = c(10, 10), ...)
  invisible(d)
}

#' Get the taxonomic path between two taxa
#'
#' Returns the full node-by-node path from one taxon up to their most recent
#' common ancestor (MRCA) and back down to the other taxon. The result is a
#' data frame with one row per node, making it easy to inspect, filter, or
#' pipe into other functions.
#'
#' @param taxon_a A character string giving the first taxon name.
#' @param taxon_b A character string giving the second taxon name.
#' @param verbose Logical. If `TRUE`, prints progress messages. Default `FALSE`.
#'
#' @return A data frame of class `"taxodist_path"` with columns:
#' \describe{
#'   \item{`node`}{Character. The clade or taxon name at this step.}
#'   \item{`depth`}{Integer. The depth of this node in the full lineage of
#'     its side (or the shared lineage for the MRCA).}
#'   \item{`direction`}{Character. One of `"a"` (ascending from taxon A to
#'     MRCA), `"mrca"` (the shared ancestor), or `"b"` (descending from MRCA
#'     to taxon B).}
#' }
#' Returns `NULL` if either taxon cannot be found or if their lineages have no
#' common ancestor.
#'
#' @seealso [mrca()], [shared_clades()], [compare_lineages()]
#' @export
#' @examples
#' \donttest{
#' taxo_path("Tyrannosaurus", "Velociraptor")
#' taxo_path("Tyrannosaurus", "Homo")
#' }
taxo_path <- function(taxon_a, taxon_b, verbose = FALSE) {
  lin_a <- get_lineage(taxon_a, verbose = verbose)
  lin_b <- get_lineage(taxon_b, verbose = verbose)

  if (is.null(lin_a)) {
    cli::cli_alert_danger("Could not retrieve lineage for {taxon_a}")
    return(NULL)
  }
  if (is.null(lin_b)) {
    cli::cli_alert_danger("Could not retrieve lineage for {taxon_b}")
    return(NULL)
  }

  result  <- .compute_distance(lin_a, lin_b, taxon_a, taxon_b)
  mrca_d  <- result$mrca_depth

  if (mrca_d == 0L) {
    cli::cli_warn("No common ancestor found for {.val {taxon_a}} and {.val {taxon_b}}.")
    return(NULL)
  }

  a_idx <- if (mrca_d < length(lin_a)) {
    seq.int(length(lin_a), mrca_d + 1L)
  } else {
    integer(0)
  }

  side_a <- data.frame(
    node      = lin_a[a_idx],
    depth     = a_idx,
    direction = rep("a", length(a_idx)),
    stringsAsFactors = FALSE
  )

  mrca_row <- data.frame(
    node      = result$mrca,
    depth     = mrca_d,
    direction = "mrca",
    stringsAsFactors = FALSE
  )

  b_idx <- if (mrca_d < length(lin_b)) {
    seq.int(mrca_d + 1L, length(lin_b))
  } else {
    integer(0)
  }

  side_b <- data.frame(
    node      = lin_b[b_idx],
    depth     = b_idx,
    direction = rep("b", length(b_idx)),
    stringsAsFactors = FALSE
  )

  path <- rbind(side_a, mrca_row, side_b)
  rownames(path) <- NULL
  structure(path, class = c("taxodist_path", "data.frame"),
            taxon_a = taxon_a, taxon_b = taxon_b)
}

#' Print method for taxodist_path objects
#'
#' @param x A `taxodist_path` object from [taxo_path()].
#' @param ... Additional arguments (ignored).
#' @return Invisibly returns `x`. Called for side effects (printing).
#' @export
print.taxodist_path <- function(x, ...) {
  taxon_a <- attr(x, "taxon_a")
  taxon_b <- attr(x, "taxon_b")
  cli::cli_h2("Taxonomic Path: {taxon_a} \u2192 {taxon_b}")
  for (i in seq_len(nrow(x))) {
    node <- x$node[i]
    dir  <- x$direction[i]
    dep  <- x$depth[i]
    if (dir == "a") {
      cli::cli_bullets(c(" " = "{.emph {node}}  (depth {dep})  \u2191"))
    } else if (dir == "mrca") {
      cli::cli_bullets(c("*" = "{.strong {node}}  (MRCA, depth {dep})"))
    } else {
      cli::cli_bullets(c(" " = "{.emph {node}}  (depth {dep})  \u2193"))
    }
  }
  invisible(x)
}

#' Print method for taxodist_focal objects
#'
#' @param x A `taxodist_focal` object from [focal_distances()].
#' @param ... Additional arguments (ignored).
#' @return Invisibly returns `x`. Called for side effects (printing).
#' @export
print.taxodist_focal <- function(x, ...) {
  focal <- attr(x, "focal")
  cli::cli_h2("Focal distances from {focal}")
  cli::cli_text("{nrow(x)} taxa \u2022 sorted closest \u2192 most distant")
  cli::cli_text("")
  print.data.frame(x, ...)
  invisible(x)
}
