R/print.efa.R

Defines functions .efa_paste_gof_ci .efa_format_plain_number .efa_loading_row_order .efa_collect_mi_values_impl .efa_collect_mi_values .print_efa_mi_diagnostics_section .print_efa_structure_section .efa_print_limited_bullets .efa_format_loading_pairs .efa_small_primary_gap_rows .efa_simple_structure_summary .print_efa_simple_structure_section .efa_high_factor_correlation_count .efa_largest_abs_residual .efa_small_primary_gap_count .efa_factors_below_indicator_count .efa_valid_target_rotation_summary .efa_print_key_value .print_efa_diagnostics_section .efa_factor_names .efa_variable_names .efa_main_loadings .efa_is_top_n .efa_alignment_summary .efa_valid_replicate_counts .efa_usable_replicate_text .efa_bootstrap_sample_text .efa_format_fit_value .efa_format_number .efa_fit_scalar .efa_is_missing_number .efa_as_scalar_number .efa_nonconvergence_banner .efa_iteration_nonconvergence .efa_validate_print_options .efa_check_flag .efa_is_whole_number .efa_check_opt_pos_int .efa_check_pos_int .efa_check_nonneg_int .efa_check_nonneg_number .print_efa_ci_table .efa_format_ci_num .efa_truncate_names .efa_phi_ci_title .efa_loading_ci_title .efa_ci_header_label .efa_valid_target_rotation_count_text .efa_target_rotation_component_text .efa_total_bootstrap_samples .efa_valid_target_rotations .efa_target_rotation_note .efa_valid_target_rotation_counts .efa_ci_source_text .efa_ci_level_text .efa_rmsea_ci_level .efa_ci_level .efa_setting_text .efa_n_imputations .efa_select_loading_cis .efa_is_ci_pair .efa_get_fit_ci .efa_get_component_ci .efa_should_print_ci .print_efa_rule .print_efa_heywood_warning .efa_heywood_value_text .efa_heywood_count .print_efa_phi_ci_section .print_efa_loading_ci_section .print_efa_residuals_section .print_efa_bootstrap_note .efa_fit_ci_labels .print_efa_model_fit_section .print_efa_identification_warning .efa_emit_bullets .efa_emit_lines .efa_corr_lines .print_efa_variances_section .print_efa_loading_block .print_efa_loadings_section .efa_pooled_settings_text .print_efa_pooled_header .efa_model_header_chunks .print_efa_header .efa_print_spec .render_efa format.summary.efa print.summary.efa summary.efa_mi summary.efa format.efa_mi format.efa print.efa_mi print.efa

Documented in format.efa format.efa_mi format.summary.efa print.efa print.efa_mi print.summary.efa summary.efa summary.efa_mi

#' Print and summarise an efa object
#'
#' `print()` shows a concise overview of an [efa_fit()] or [efa_mi()] solution:
#' a model header, the loading matrix (with the factor intercorrelations for
#' oblique solutions), the variances accounted for, and the model fit.
#' `summary()` returns a `summary.efa` object whose print method adds the full
#' diagnostics: model and simple-structure diagnostics, confidence-interval
#' tables, the structure matrix, multiple-imputation uncertainty (for pooled
#' objects), and residual diagnostics. `format()` assembles the same report and
#' returns it as a character vector; `print()` is `cat(format(x), sep = "\n")`.
#' The lines follow the active console theme, so they are plain when colours are
#' disabled (for example when captured into a file or stripped with
#' [cli::ansi_strip()]).
#'
#' @details
#' The methods are shared by single-imputation `efa` objects and pooled
#' `efa_mi` objects. For `efa_mi` objects the header reports the number
#' of imputations and the alignment/pooling settings; confidence intervals and a
#' multiple-imputation uncertainty summary are shown by `summary()` when the
#' pooled object carries bootstrap/MI quantities.
#'
#' In `summary()`, `ci_filter` controls which loading intervals are shown:
#' `"salient"` reports intervals for loadings whose absolute point estimate is at
#' least `cutoff`, `"nonzero"` reports intervals excluding zero, and `"all"`
#' reports every finite interval.
#'
#' @param x,object An object of class `efa` (from [efa_fit()]) or `efa_mi` (from [efa_mi()]); for
#'   the `summary.efa` methods, the object returned by `summary()`.
#' @param cutoff numeric. The absolute value at or above which loadings are
#'   emphasised in the loading table. Default is .3.
#' @param digits numeric. Number of decimal places for the printed tables.
#'   Default is 3.
#' @param max_name_length numeric. Maximum length of the variable names to
#'   display; longer names are cut from the right, or abbreviated where cutting
#'   would give two variables the same label. Applies to every table that names
#'   variables. `name_style` (see [print.efa_loadings()]) can be passed through
#'   `...` to choose the shortening explicitly, but it reaches the loading table
#'   only; the confidence-interval and simple-structure tables always shorten by
#'   cutting.
#' @param sort_loadings character. Optional row sorting for the loading table.
#'   See [print.efa_loadings()].
#' @param show_loading_legend logical. Whether to print a short legend for the
#'   loading-table styling. Default is `TRUE`. The legend is only printed when
#'   that styling is actually rendered (a colour-capable console); in plain
#'   output it is omitted and this argument has no effect.
#' @param max_factors_per_block numeric or `NULL`. Maximum number of factor
#'   columns per loading-table block. If `NULL`, chosen from the console width.
#' @param ci character. Which confidence intervals `summary()` shows, if
#'   available. `"auto"` and `"separate"` print CI sections when CIs were
#'   computed; `"none"` suppresses them. Default is `"auto"`.
#' @param ci_filter character. Which loading CIs `summary()` prints: `"salient"`
#'   (default), `"all"`, or `"nonzero"`; see Details.
#' @param diagnostics_top_n numeric. Maximum number of item-level entries
#'   `summary()` prints per simple-structure diagnostic.
#' @param residual_cutoff numeric. Absolute residual cutoff for the residual
#'   diagnostics in `summary()`. Default is .1.
#' @param residual_top_n numeric. Maximum number of residuals `summary()` prints.
#'   Use `Inf` to print all residuals above `residual_cutoff`.
#' @param show_structure logical. Whether `summary()` prints the structure matrix
#'   for oblique solutions when available. Default is `TRUE`.
#' @param cross_loading_cutoff numeric. Cutoff for counting cross-loadings in the
#'   `summary()` diagnostics. Defaults to `cutoff`.
#' @param min_primary_gap numeric. Minimum desired absolute difference between the
#'   largest and second-largest absolute loading of an item, used in the
#'   `summary()` diagnostics.
#' @param min_salient_per_factor numeric. Minimum number of salient indicators per
#'   factor used in the `summary()` diagnostics. Default is 3.
#' @param show_mi_diagnostics logical or `NULL`. Whether `summary()` prints a
#'   multiple-imputation uncertainty summary for pooled EFAs. `NULL` shows it for
#'   pooled objects.
#' @param ... Further arguments passed to [print.efa_loadings()].
#'
#' @returns `print()` and the print method for `summary.efa` objects return their
#'   argument invisibly. `format()` returns a character vector with the report
#'   lines. `summary()` returns an object of class `summary.efa`.
#'
#' @export
#'
#' @method print efa
#'
#' @examples
#' mod <- efa_fit(test_models$baseline$cormat, n_factors = 3, N = 500,
#'                estimator = "PAF", rotation = "promax")
#' mod
#'
#' # The full diagnostics, CI tables, and residual diagnostics:
#' summary(mod)
#'
#' # format() returns the same lines as a character vector, e.g. for a report file:
#' writeLines(format(mod))
#'
print.efa <- function(x, ...) {
  cat(format(x, ...), sep = "\n")
  invisible(x)
}

#' @rdname print.efa
#' @export
#' @method print efa_mi
print.efa_mi <- function(x, ...) {
  print.efa(x, ...)
}

#' @rdname print.efa
#' @export
#' @method format efa
format.efa <- function(x, cutoff = .3, digits = 3, max_name_length = 10,
                       sort_loadings = c("none", "primary", "clustered"),
                       show_loading_legend = TRUE,
                       max_factors_per_block = NULL, ...) {
  sort_loadings <- .match_arg_ci(sort_loadings)

  .render_efa(x,
    view = "brief",
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    sort_loadings = sort_loadings,
    show_loading_legend = show_loading_legend,
    max_factors_per_block = max_factors_per_block,
    ...
  )
}

#' @rdname print.efa
#' @export
#' @method format efa_mi
format.efa_mi <- function(x, ...) {
  format.efa(x, ...)
}

#' @rdname print.efa
#' @export
#' @method summary efa
summary.efa <- function(object, cutoff = .3, digits = 3, max_name_length = 10,
                        ci = c("auto", "none", "separate"),
                        ci_filter = c("salient", "all", "nonzero"),
                        diagnostics_top_n = 10,
                        residual_cutoff = .1,
                        residual_top_n = 10,
                        show_structure = TRUE,
                        sort_loadings = c("none", "primary", "clustered"),
                        show_loading_legend = TRUE,
                        cross_loading_cutoff = cutoff,
                        min_primary_gap = .20,
                        min_salient_per_factor = 3,
                        max_factors_per_block = NULL,
                        show_mi_diagnostics = NULL, ...) {
  ci <- .match_arg_ci(ci)
  ci_filter <- .match_arg_ci(ci_filter)
  sort_loadings <- .match_arg_ci(sort_loadings)

  opts <- list(
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    ci = ci,
    ci_filter = ci_filter,
    diagnostics_top_n = diagnostics_top_n,
    residual_cutoff = residual_cutoff,
    residual_top_n = residual_top_n,
    show_structure = show_structure,
    sort_loadings = sort_loadings,
    show_loading_legend = show_loading_legend,
    cross_loading_cutoff = cross_loading_cutoff,
    min_primary_gap = min_primary_gap,
    min_salient_per_factor = min_salient_per_factor,
    max_factors_per_block = max_factors_per_block,
    show_mi_diagnostics = show_mi_diagnostics
  )

  structure(list(efa = object, opts = opts), class = "summary.efa")
}

#' @rdname print.efa
#' @export
#' @method summary efa_mi
summary.efa_mi <- function(object, ...) {
  summary.efa(object, ...)
}

#' @rdname print.efa
#' @export
#' @method print summary.efa
print.summary.efa <- function(x, ...) {
  cat(format(x, ...), sep = "\n")
  invisible(x)
}

#' @rdname print.efa
#' @export
#' @method format summary.efa
format.summary.efa <- function(x, ...) {
  opts <- utils::modifyList(x$opts, list(...))
  do.call(.render_efa, c(list(x$efa, view = "full"), opts))
}

# Render the EFA report at the requested depth: "brief" (print) shows the header,
# loadings (with factor intercorrelations), variances accounted for, and model fit;
# "full" (summary) additionally shows model and simple-structure diagnostics, the
# CI tables, the structure matrix, MI uncertainty, and residual diagnostics.
.render_efa <- function(x, view = c("brief", "full"),
                        cutoff = .3, digits = 3, max_name_length = 10,
                        ci = c("auto", "none", "separate"),
                        ci_filter = c("salient", "all", "nonzero"),
                        diagnostics_top_n = 10,
                        residual_cutoff = .1,
                        residual_top_n = 10,
                        show_structure = TRUE,
                        sort_loadings = c("none", "primary", "clustered"),
                        show_loading_legend = TRUE,
                        cross_loading_cutoff = cutoff,
                        min_primary_gap = .20,
                        min_salient_per_factor = 3,
                        max_factors_per_block = NULL,
                        show_mi_diagnostics = NULL, ...) {
  view <- .match_arg_ci(view)
  ci <- .match_arg_ci(ci)
  ci_filter <- .match_arg_ci(ci_filter)
  sort_loadings <- .match_arg_ci(sort_loadings)
  full <- identical(view, "full")

  .efa_validate_print_options(
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    diagnostics_top_n = diagnostics_top_n,
    residual_cutoff = residual_cutoff,
    residual_top_n = residual_top_n,
    show_structure = show_structure,
    show_loading_legend = show_loading_legend,
    cross_loading_cutoff = cross_loading_cutoff,
    min_primary_gap = min_primary_gap,
    min_salient_per_factor = min_salient_per_factor,
    max_factors_per_block = max_factors_per_block,
    show_mi_diagnostics = show_mi_diagnostics
  )

  spec <- .efa_print_spec(x)
  show_mi <- if (!is.null(show_mi_diagnostics)) {
    isTRUE(show_mi_diagnostics)
  } else {
    isTRUE(spec$is_pooled)
  }

  # Assemble the whole report inside a single cli container so prose wraps to the console
  # width; `format()` returns these lines and `print()` is `cat(format(x), sep = "\n")`.
  cli::cli_format_method({
    .print_efa_header(spec)

    if (full) {
      .print_efa_diagnostics_section(x, spec,
        cutoff = cutoff,
        digits = digits,
        cross_loading_cutoff = cross_loading_cutoff,
        min_primary_gap = min_primary_gap,
        min_salient_per_factor = min_salient_per_factor
      )
    }

    .print_efa_loadings_section(x, spec,
      cutoff = cutoff,
      digits = digits,
      max_name_length = max_name_length,
      ci = if (full) ci else "none",
      ci_filter = ci_filter,
      show_structure = isTRUE(show_structure) && full,
      sort_loadings = sort_loadings,
      show_loading_legend = show_loading_legend,
      max_factors_per_block = max_factors_per_block,
      ...
    )

    if (full) {
      .print_efa_simple_structure_section(x, spec,
        cutoff = cutoff,
        digits = digits,
        max_name_length = max_name_length,
        cross_loading_cutoff = cross_loading_cutoff,
        min_primary_gap = min_primary_gap,
        min_salient_per_factor = min_salient_per_factor,
        diagnostics_top_n = diagnostics_top_n,
        sort_loadings = sort_loadings
      )
    }

    .print_efa_variances_section(x, spec, digits = digits)

    # A negative df (underidentified) stops the report after the warning; df == 0 (just
    # identified) emits its caveat but the fit/bootstrap/residual sections still follow.
    if (.print_efa_identification_warning(spec)) {
      .print_efa_model_fit_section(x, spec)
      .print_efa_bootstrap_note(x, spec, ci = if (full) ci else "none")

      if (full && isTRUE(show_mi)) {
        .print_efa_mi_diagnostics_section(x, spec, digits = digits)
      }

      if (full) {
        .print_efa_residuals_section(x,
          residual_cutoff = residual_cutoff,
          residual_top_n = residual_top_n,
          digits = digits
        )
      }
    }
  })
}

.efa_print_spec <- function(x) {
  settings <- x$settings
  if (is.null(settings)) {
    settings <- list()
  }

  se <- settings$se
  is_pooled <- inherits(x, "EFA_POOLED") || isTRUE(settings$pooled)

  # The two-stage pooled solution IS a single fit on the pooled inputs, so that fit's
  # Heywood detector is the pooled solution's own. Without it the count falls back to
  # `h2 >= 1`, which cannot see the boundary uniqueness an ML/ULS solution expresses an
  # improper solution as. The Rubin routes average several fits and have no such
  # detector, so they keep the fallback.
  heywood <- x[["heywood"]]
  if (is.null(heywood) && !is.null(x[["mi_fit"]])) {
    heywood <- x[["mi_fit"]][["heywood"]]
  }

  list(
    estimator = settings$estimator,
    rotation = settings$rotation,
    rotation_diagnostics = settings$rotation_diagnostics,
    cor_method = settings$cor_method,
    # `[[` rather than `$`: the slot is absent on every non-FIML fit, and `$` partial-matches, so
    # any future slot whose name merely starts with "fiml" would be picked up here instead.
    fiml = x[["fiml"]],
    se = se,
    N = settings$N,
    fit = x$fit_indices,
    h2 = x$h2,
    heywood = heywood,
    np_boot = identical(.efa_setting_text(se), "np-boot"),
    ci = .efa_ci_level(x),
    rmsea_ci_level = .efa_rmsea_ci_level(x),
    b_boot = settings$b_boot,
    max_iter = settings$max_iter,
    iter = x$iter,
    convergence = x$convergence,
    is_pooled = is_pooled,
    n_imputations = .efa_n_imputations(x),
    target_method = settings$target_method,
    align_unrotated = settings$align_unrotated,
    fit_pool_method = settings$fit_pool_method,
    component_se = settings$component_se,
    has_ci = !is.null(x$CI)
  )
}

.print_efa_header <- function(spec) {
  cli::cli_text("")

  if (isTRUE(spec$is_pooled)) {
    .print_efa_pooled_header(spec)
  } else {
    cli::cli_verbatim(.efa_wrap_chunks(c("EFA", .efa_model_header_chunks(spec))))
  }

  # Flag two-stage FIML correlations (saturated mean/covariance EM-estimated from raw data
  # with missing values; Yuan, Marshall, & Bentler, 2002) so the analysed matrix is not read
  # as an ordinary complete-case correlation. Emitted verbatim (plain, no ANSI).
  if (identical(.efa_setting_text(spec$cor_method), "fiml")) {
    cli::cli_verbatim("Correlations: FIML (two-stage, missing data)")

    # A Stage-1 EM that stopped at its cap leaves the analysed matrix at the last iterate
    # rather than the FIML estimate, which can differ materially at a high fraction of
    # missing information. The warning fires only while the fit runs -- and is suppressed
    # wherever fits are run in bulk -- so a saved object would otherwise carry no visible
    # trace of it. Shown next to the line that names the correlation method.
    if (isFALSE(spec$fiml$converged)) {
      cli::cli_alert_warning(
        "The FIML EM stopped after {spec$fiml$iter} iteration{?s} without converging; the
         analysed correlation matrix is its last iterate. Raise {.arg fiml_max_iter}, or
         relax {.arg fiml_tol}, in {.fn estimate_control}."
      )
    }
  }

  if (.efa_iteration_nonconvergence(spec)) {
    cli::cli_text("")
    cli::cli_alert_danger(.efa_nonconvergence_banner(spec$estimator))
  }

  invisible(NULL)
}


# The estimator/rotation tail both model headers end in, as `.efa_wrap_chunks()` chunks.
# Emitted verbatim rather than via cli_text() so a "setting = 'value'" token is never split
# across a line break -- which is also why the values are style_bold() rather than {.strong}
# -- while the packer still breaks the line around those tokens.
.efa_model_header_chunks <- function(spec) {
  c("performed", "with",
    paste0("estimator = '", cli::style_bold(spec$estimator), "'"),
    "and",
    paste0("rotation = '", cli::style_bold(spec$rotation), "'."))
}

.print_efa_pooled_header <- function(spec) {
  n_imputations <- .efa_setting_text(spec$n_imputations)

  across <- if (nzchar(n_imputations)) {
    c("across", cli::style_bold(n_imputations), "imputations")
  } else {
    character(0)
  }
  cli::cli_verbatim(.efa_wrap_chunks(
    c("Pooled EFA", across, .efa_model_header_chunks(spec))
  ))

  pooling_settings <- .efa_pooled_settings_text(spec)
  if (length(pooling_settings) > 0L) {
    cli::cli_verbatim(.efa_wrap_chunks(
      c("Pooling settings:", .efa_setting_chunks(pooling_settings, end = "."))
    ))
  }

  invisible(NULL)
}

.efa_pooled_settings_text <- function(spec) {
  out <- character(0)

  # The MI2S sandwich route fits a single model to the pooled inputs, so the
  # per-imputation alignment and chi-square pooling settings (target_method,
  # align_unrotated, fit_pool_method) do not apply and are not reported.
  if (!identical(spec$component_se, "sandwich")) {
    target_method <- .efa_setting_text(spec$target_method)
    if (nzchar(target_method) && !identical(spec$rotation, "none")) {
      out <- c(out, paste0("target_method = '", target_method, "'"))
    }

    align_unrotated <- .efa_setting_text(spec$align_unrotated)
    if (nzchar(align_unrotated)) {
      out <- c(out, paste0("align_unrotated = '", align_unrotated, "'"))
    }

    fit_pool_method <- .efa_setting_text(spec$fit_pool_method)
    if (nzchar(fit_pool_method)) {
      out <- c(out, paste0("fit_pool_method = '", fit_pool_method, "'"))
    }
  }

  if (is.finite(spec$rmsea_ci_level) &&
      abs(spec$rmsea_ci_level - .90) > sqrt(.Machine$double.eps)) {
    out <- c(out, paste0("rmsea_ci_level = ", .efa_ci_level_text(spec$rmsea_ci_level)))
  }

  out
}

.print_efa_loadings_section <- function(x, spec, cutoff, digits, max_name_length,
                                        ci, ci_filter, show_structure,
                                        sort_loadings, show_loading_legend,
                                        max_factors_per_block, ...) {
  if (identical(spec$rotation, "none")) {
    .print_efa_loading_block(x, spec,
      component = "unrot_loadings",
      label = "unrotated",
      title = "Unrotated Loadings",
      cutoff = cutoff,
      digits = digits,
      max_name_length = max_name_length,
      ci = ci,
      ci_filter = ci_filter,
      sort_loadings = sort_loadings,
      show_loading_legend = show_loading_legend,
      max_factors_per_block = max_factors_per_block,
      ...
    )
    return(invisible(NULL))
  }

  shown <- .print_efa_loading_block(x, spec,
    component = "rot_loadings",
    label = "rotated",
    title = "Rotated Loadings",
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    ci = ci,
    ci_filter = ci_filter,
    sort_loadings = sort_loadings,
    show_loading_legend = show_loading_legend,
    max_factors_per_block = max_factors_per_block,
    ...
  )
  if (!shown) {
    return(invisible(NULL))
  }

  if (!is.null(x$Phi)) {
    phi <- as.matrix(x$Phi)
    factor_names <- .efa_factor_names(phi)
    dimnames(phi) <- list(factor_names, factor_names)

    .print_efa_rule("Factor Intercorrelations")
    .efa_emit_lines(.efa_corr_lines(phi, digits = digits, lower_only = TRUE))
    .print_efa_phi_ci_section(x, spec, digits, ci)
  }

  if (!is.null(x$Structure) && isTRUE(show_structure)) {
    .print_efa_structure_section(x,
      cutoff = cutoff,
      digits = digits,
      max_name_length = max_name_length,
      sort_loadings = sort_loadings,
      max_factors_per_block = max_factors_per_block,
      ...
    )
  }

  invisible(NULL)
}

# Render one loading block -- its rule, the styled table, the Heywood warning, and the
# loading confidence intervals -- for either the unrotated or the rotated matrix.
# `component` names the slot on `x` and `label` the adjective used in the messages.
# Returns TRUE when a matrix was rendered, FALSE when the slot was empty, so the rotated
# caller knows whether to continue with the Phi and Structure blocks.
#
# Both call sites must pass every formal *by name*. The print methods forward the user's
# `...` down to here unfiltered, so an exactly-matched formal is what stops an abbreviated
# user argument (`ti =`, `lab =`) from partial-matching `title` or `label` instead of being
# passed on to format().
.print_efa_loading_block <- function(x, spec, component, label, title, cutoff, digits,
                                     max_name_length, ci, ci_filter, sort_loadings,
                                     show_loading_legend, max_factors_per_block, ...) {
  .print_efa_rule(title)

  loadings <- x[[component]]
  if (is.null(loadings)) {
    cli::cli_text("No {label} loading matrix available.")
    return(FALSE)
  }

  # format.efa_loadings() renders the styled table (h2/sorting/legend/column blocks) and
  # returns it as report lines, which .efa_emit_lines() embeds without reflowing. The
  # loading matrices are classed `efa_loadings`, so the generic dispatches there.
  .efa_emit_lines(format(loadings,
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    h2 = x$h2,
    sort_loadings = sort_loadings,
    legend = show_loading_legend,
    max_factors_per_block = max_factors_per_block,
    ...
  ))
  .print_efa_heywood_warning(spec$heywood, spec$h2)
  .print_efa_loading_ci_section(x, spec,
    component = component,
    loadings = loadings,
    loading_label = label,
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    ci = ci,
    ci_filter = ci_filter,
    sort_loadings = sort_loadings
  )

  TRUE
}

.print_efa_variances_section <- function(x, spec, digits = 3) {
  .print_efa_rule("Variances Accounted for")

  if (identical(spec$rotation, "none")) {
    vars_accounted <- x$vars_accounted
  } else {
    vars_accounted <- x$vars_accounted_rot
  }

  if (is.null(vars_accounted)) {
    cli::cli_text("No variance-accounted table available.")
    return(invisible(NULL))
  }

  .efa_emit_lines(.efa_corr_lines(vars_accounted, digits = digits))

  invisible(NULL)
}

# Format a "corr"-role matrix (factor intercorrelations or variances accounted for) into
# decimal-aligned lines for embedding in the cli report via `cli::cli_verbatim()`. Reuses
# the shared `.efa_format_matrix()` renderer, so these tables align consistently with the
# loading table; rows/columns are labelled from the matrix dimnames and `lower_only = TRUE`
# blanks the strictly-upper triangle (for symmetric matrices such as Phi).
.efa_corr_lines <- function(values, digits = 3, lower_only = FALSE, na = "NA") {
  values <- as.matrix(values)
  .efa_format_matrix(
    values = values,
    row_labels = .efa_variable_names(values),
    col_labels = .efa_factor_names(values),
    col_roles = rep("corr", ncol(values)),
    digits = digits,
    lower_only = lower_only,
    na = na
  )
}

# Emit pre-formatted table lines into the cli report. `cli::cli_verbatim()` silently drops
# interior empty-string elements, which would erase the blank line a loading table puts
# before its legend and the blank separators between column blocks of a wide table. So emit
# each run of non-empty lines verbatim (no reflow) and each blank line as `cli_text("")`,
# preserving the table's own vertical spacing.
.efa_emit_lines <- function(lines) {
  if (length(lines) < 1L) {
    return(invisible(NULL))
  }

  blank <- !nzchar(lines)
  run <- cumsum(c(TRUE, blank[-1L] != blank[-length(blank)]))
  for (r in unique(run)) {
    idx <- which(run == r)
    if (blank[idx[1L]]) {
      for (i in idx) cli::cli_text("")
    } else {
      cli::cli_verbatim(lines[idx])
    }
  }

  invisible(NULL)
}

# Emit a bullet list whose items embed user-supplied variable/factor names. `cli::cli_ul()`
# treats each item as a cli/glue template, so a name containing `{` or `}` would be evaluated
# as an expression (corrupting output or aborting). Double the braces so they render
# literally.
.efa_emit_bullets <- function(items) {
  items <- gsub("}", "}}", gsub("{", "{{", items, fixed = TRUE), fixed = TRUE)
  cli::cli_ul(items)
  invisible(NULL)
}

.print_efa_identification_warning <- function(spec) {
  df <- .efa_fit_scalar(spec$fit, "df")

  if (!is.finite(df)) {
    return(TRUE)
  }

  if (df == 0) {
    cli::cli_text("")
    cli::cli_alert_warning(
      "The model is just identified (df = 0). Goodness of fit indices may not be interpretable.",
      wrap = TRUE
    )
    return(TRUE)
  }

  if (df < 0) {
    # The residual summaries are populated in the object even here, so the note names what is
    # missing rather than claiming that nothing was computed; the wording is shared with the
    # warning efa_fit() raises for the same model.
    chisq_block <- .fit_unavailable_text("chisq_block")
    residuals_kept <- .fit_unavailable_text("residuals_kept")
    not_identification <- .fit_unavailable_text("residuals_not_identification")
    cli::cli_text("")
    cli::cli_alert_warning(
      "The model is underidentified (df < 0); {chisq_block}. {residuals_kept} {not_identification}",
      wrap = TRUE
    )
    return(FALSE)
  }

  TRUE
}

.print_efa_model_fit_section <- function(x, spec) {
  fit <- spec$fit
  fit_ci <- .efa_fit_ci_labels(x, spec)

  .print_efa_rule("Model Fit")

  if (is.null(fit) || length(fit) < 1L) {
    cli::cli_text("No model-fit indices available.")
    return(invisible(NULL))
  }

  # One "name[ CI-tag]: value[ CI]" data line. The CI tag is emitted only for
  # indices whose own CI suffix is available, so partially populated fit-index
  # CI lists do not label plain point estimates as interval estimates.
  fit_line <- function(name, key, ci = fit_ci[[key]], tag = TRUE, ...) {
    if (is.null(ci)) ci <- ""
    label <- if (isTRUE(tag) && nzchar(ci)) fit_ci$label else ""
    paste0(name, label, ": ", .efa_format_fit_value(fit, key, pad = FALSE, ...), ci)
  }
  is_finite_fit <- function(key) is.finite(.efa_fit_scalar(fit, key))

  estimator <- .efa_setting_text(spec$estimator)
  df <- .efa_fit_scalar(fit, "df")
  chi <- .efa_fit_scalar(fit, "chi")
  p_chi <- .efa_fit_scalar(fit, "p_chi")

  if (identical(estimator, "PAF") || .efa_is_missing_number(spec$N) ||
      !is.finite(chi) || !is.finite(df)) {
    lines <- fit_line("CAF", "CAF")
    if (is_finite_fit("SRMR")) {
      lines <- c(lines, fit_line("SRMR", "SRMR"))
    }
    lines <- c(lines, paste0("df: ",
      .efa_format_fit_value(fit, "df", digits = 0, print_zero = TRUE, pad = FALSE)))
    .efa_emit_lines(lines)
    # DWLS has no unscaled chi-square: the statistic exists only in the scaled-and-shifted
    # form the robust covariance supplies, so without se = "sandwich" the three lines above
    # are the whole of the fit output and nothing else says why. Name what is missing with
    # the wording shared with the other routes that lose the same block. The note is held to
    # a model that a scaled statistic would apply to: with df = 0 the caveat above already
    # covers it, and a non-finite df is not a DWLS matter. PAF gets no such line -- it has no
    # discrepancy function to test, so no setting restores the block for it.
    if (identical(estimator, "DWLS") && !identical(spec$se, "sandwich") &&
        is.finite(df) && df > 0) {
      chisq_block <- .fit_unavailable_text("chisq_block")
      cli::cli_text("")
      cli::cli_text("{.emph Note:} With {.code estimator = \"DWLS\"}, {chisq_block}; refit
                     with {.code se = \"sandwich\"} for the scaled chi-square.")
    }
    return(invisible(NULL))
  }

  p_text <- if (!is.finite(p_chi)) {
    " = NA"
  } else if (p_chi < .001) {
    " < .001"
  } else {
    paste0(" = ", .efa_format_number(p_chi, digits = 3, pad = FALSE))
  }

  df_text <- .efa_format_fit_value(fit, "df", digits = 0, print_zero = TRUE, pad = FALSE)
  chi_text <- .efa_format_fit_value(fit, "chi", digits = 2, print_zero = TRUE, pad = FALSE)

  rmsea_label <- paste0("RMSEA [", .efa_ci_level_text(spec$rmsea_ci_level), " CI]")
  rmsea_value <- paste0(
    .efa_format_fit_value(fit, "RMSEA", pad = FALSE), " [",
    .efa_format_fit_value(fit, "RMSEA_LB", pad = FALSE), "; ",
    .efa_format_fit_value(fit, "RMSEA_UB", pad = FALSE), "]"
  )

  # When the sandwich path supplies a scaled (Satorra-Bentler) chi-square, mark the line so
  # it is not read as an ordinary chi-square (the CFI/TLI/RMSEA below are derived from it).
  # A two-stage FIML fit whose correction could not be formed reports the plain
  # likelihood-ratio statistic under the "uncorrected.lrt" tag; it must NOT be labelled
  # scaled, because it carries no correction at all -- it is only approximately
  # chi-square(df) under the two-stage estimator, which is what the label says.
  # A D2-pooled fit is flagged the same way: its chi-square is the Li et al. (1991)
  # asymptotic statistic df * F_D2, whose p-value comes from the D2 reference F(df1, df2)
  # rather than the chi-square(df) tail (the note below spells this out when they diverge).
  is_d2 <- isTRUE(spec$is_pooled) && identical(fit[["pool_method"]], "D2")
  scaled_type <- fit[["chi_scaled_type"]]
  chi_prefix <- if (!is.null(scaled_type) && !is.na(scaled_type)) {
    if (identical(scaled_type, "uncorrected.lrt")) "uncorrected " else "scaled "
  } else if (is_d2) {
    "D2-pooled "
  } else {
    ""
  }

  # The chi-square line carries an italic "p"; emit verbatim (with style_italic) so the
  # styling survives and the line is not reflowed.
  lines <- paste0(chi_prefix, "\u03c7\u00b2(", df_text, ") = ", chi_text, ", ",
                  cli::style_italic("p"), p_text)
  # A D2-pooled fit reports CFI and TLI as the average of the per-imputation indices
  # rather than from the pooled statistic on the line above, so label them: they are the
  # quantities that will not reconcile against a reference which pools the model and
  # baseline noncentralities separately (see the note below). Only an index that is
  # actually reported is labelled, so an undefined one is not described as an average.
  inc_averaged <- if (is_d2) {
    c("CFI", "TLI")[c(is_finite_fit("CFI"), is_finite_fit("TLI"))]
  } else {
    character(0)
  }
  inc_tag <- function(key) {
    if (key %in% inc_averaged) " (avg. over imputations)" else ""
  }
  lines <- c(lines, fit_line(paste0("CFI", inc_tag("CFI")), "CFI"))
  if (is_finite_fit("TLI")) {
    lines <- c(lines, fit_line(paste0("TLI", inc_tag("TLI")), "TLI"))
  }
  lines <- c(lines, paste0(rmsea_label, fit_ci$label, ": ", rmsea_value, fit_ci$RMSEA))
  lines <- c(lines,
             fit_line("AIC", "AIC", fit_ci$AIC, print_zero = TRUE),
             fit_line("BIC", "BIC", fit_ci$BIC, print_zero = TRUE))
  if (is_finite_fit("ECVI")) {
    lines <- c(lines, fit_line("ECVI", "ECVI", print_zero = TRUE))
  }
  lines <- c(lines, fit_line("CAF", "CAF"))
  if (is_finite_fit("SRMR")) {
    lines <- c(lines, fit_line("SRMR", "SRMR"))
  }

  .efa_emit_lines(lines)

  # The D2-pooled chi-square (Li, Meng, Raghunathan & Rubin, 1991) is the asymptotic
  # statistic df * F_D2 and its p-value comes from the reference F(df1, df2); state that
  # so the reported p is not mistaken for the chi-square(df) tail probability. The two
  # references coincide only as df2 -> Inf (no between-imputation variance), so the note is
  # shown when df2 is finite.
  if (is_d2) {
    d2_df2 <- if (is.null(x$mi_diagnostics)) NA_real_ else x$mi_diagnostics$D2_df2
    if (is.finite(d2_df2)) {
      d2_df2_text <- .efa_format_plain_number(d2_df2, digits = 1)
      cli::cli_text(
        "{.emph Note:} the pooled \u03c7\u00b2 is the D2 statistic; its {.emph p} uses the D2 reference F({df_text}, {d2_df2_text}), not the \u03c7\u00b2({df_text}) tail."
      )
    }
    # The incremental indices are averaged over the imputations rather than formed from
    # separately pooled model and baseline statistics, which is what lavaan.mi / semTools
    # do; say so, because these are the indices a reader is most likely to cross-check.
    # Names only the indices that were reported above.
    if (length(inc_averaged) > 0L) {
      cli::cli_text(
        "{.emph Note:} {inc_averaged} {?is/are} averaged over the imputations, not formed from the separately pooled model and baseline statistics in {.code mi_diagnostics}."
      )
    }
  }

  invisible(NULL)
}

.efa_fit_ci_labels <- function(x, spec) {
  out <- list(
    label = "",
    CAF = "",
    RMSR = "",
    SRMR = "",
    CFI = "",
    TLI = "",
    RMSEA = "",
    AIC = "",
    BIC = "",
    ECVI = ""
  )

  if (!isTRUE(spec$has_ci)) {
    return(out)
  }

  fitind_ci <- .efa_get_fit_ci(x, spec)
  if (is.null(fitind_ci)) {
    return(out)
  }

  out$label <- paste0(" [", .efa_ci_header_label(spec), "-CI]")
  out$CAF <- .efa_paste_gof_ci(fitind_ci, "CAF")
  out$RMSR <- .efa_paste_gof_ci(fitind_ci, "RMSR")
  out$SRMR <- .efa_paste_gof_ci(fitind_ci, "SRMR")
  out$CFI <- .efa_paste_gof_ci(fitind_ci, "CFI")
  out$TLI <- .efa_paste_gof_ci(fitind_ci, "TLI")
  out$RMSEA <- .efa_paste_gof_ci(fitind_ci, "RMSEA")
  out$AIC <- .efa_paste_gof_ci(fitind_ci, "AIC")
  out$BIC <- .efa_paste_gof_ci(fitind_ci, "BIC")
  out$ECVI <- .efa_paste_gof_ci(fitind_ci, "ECVI")

  out
}

.print_efa_bootstrap_note <- function(x, spec, ci = NULL) {
  if (!isTRUE(spec$has_ci)) {
    return(invisible(NULL))
  }

  # Analytic SEs produce Wald intervals, not resampled ones; describe their source rather
  # than a bootstrap-sample count. For a NON-pooled analytic fit the Wald CIs are attached
  # only to the unrotated loadings (displayed only for an unrotated solution with CIs
  # enabled), so suppress the note when no such table is shown. Pooled objects, however,
  # attach and display rotated-loading CIs on both the information and MI2S routes, so the
  # note must still appear for a rotated pooled solution. The wording follows the SE method:
  # se = "information" draws Wald CIs from the expected information matrix (Rubin-pooled with
  # plain Rubin (1987) df when pooled), while se = "sandwich" draws them from the robust (Godambe)
  # sandwich covariance (built on the multiple-imputation-pooled asymptotic covariance when
  # pooled), so the pooled MI2S note must not claim information-matrix Rubin pooling.
  if (!isTRUE(spec$np_boot)) {
    if (identical(ci, "none") ||
        (!identical(spec$rotation, "none") && !isTRUE(spec$is_pooled))) {
      return(invisible(NULL))
    }
    cli::cli_text("")
    if (isTRUE(spec$is_pooled) && identical(spec$component_se, "sandwich")) {
      cli::cli_text("{.emph Note:} Wald CIs from the robust sandwich covariance built on the multiple-imputation-pooled asymptotic covariance.")
    } else if (isTRUE(spec$is_pooled)) {
      cli::cli_text("{.emph Note:} Wald CIs from the expected information matrix, Rubin-pooled across imputations (Rubin's 1987 degrees of freedom).")
    } else if (identical(.efa_setting_text(spec$cor_method), "fiml")) {
      # Under cor_method = "fiml" both analytic settings yield the corrected two-stage sandwich, so
      # the note describes that covariance rather than the expected information matrix.
      cli::cli_text("{.emph Note:} Wald CIs from the corrected two-stage (FIML) sandwich covariance.")
    } else if (identical(spec$se, "sandwich")) {
      cli::cli_text("{.emph Note:} Wald CIs from the robust (Godambe) sandwich covariance.")
    } else {
      cli::cli_text("{.emph Note:} Wald CIs from the expected information matrix.")
    }
    return(invisible(NULL))
  }

  note_label <- if (isTRUE(spec$is_pooled)) {
    "Bootstrap/MI CIs"
  } else {
    "Bootstrap CIs"
  }

  note <- note_label

  b_text <- .efa_bootstrap_sample_text(x, spec)
  if (nzchar(b_text)) {
    note <- paste0(note, " based on ", b_text)
  }

  target_rotation_note <- .efa_target_rotation_note(x, spec)
  if (nzchar(target_rotation_note)) {
    note <- paste0(note, "; ", target_rotation_note)
  }

  note <- paste0(note, ".")

  cli::cli_text("")
  cli::cli_text("{.emph Note:} {note}")

  invisible(NULL)
}

.print_efa_residuals_section <- function(x, residual_cutoff = .1,
                                     residual_top_n = 10, digits = 3) {
  .print_efa_rule("Residual Diagnostics")

  if (is.null(x$residuals)) {
    cli::cli_text("No residual matrix available.")
    return(invisible(NULL))
  }

  residuals <- as.matrix(x$residuals)
  idx <- which(upper.tri(residuals) & is.finite(residuals), arr.ind = TRUE)

  if (nrow(idx) < 1L) {
    cli::cli_text("No finite off-diagonal residuals available.")
    return(invisible(NULL))
  }

  values <- residuals[idx]
  keep <- abs(values) > residual_cutoff
  n_large <- sum(keep, na.rm = TRUE)
  largest_abs <- max(abs(values), na.rm = TRUE)

  cutoff_text <- .efa_format_plain_number(residual_cutoff, digits)
  largest_text <- .efa_format_plain_number(largest_abs, digits)
  cli::cli_text("Residual cutoff: |r| > {cutoff_text}")
  cli::cli_text("Number of large residuals: {n_large}")
  cli::cli_text("Largest absolute residual: {largest_text}")

  if (n_large < 1L) {
    cli::cli_text("")
    cli::cli_text("No absolute residuals > {cutoff_text} occurred.")
    cli::cli_text("")
    cli::cli_text("Inspect the residual matrix for details (e.g., with residuals()).")
    return(invisible(NULL))
  }

  large_idx <- idx[keep, , drop = FALSE]
  large_values <- values[keep]
  ord <- order(abs(large_values), decreasing = TRUE)
  large_idx <- large_idx[ord, , drop = FALSE]
  large_values <- large_values[ord]

  if (is.finite(residual_top_n)) {
    n_print <- min(length(large_values), as.integer(residual_top_n))
  } else {
    n_print <- length(large_values)
  }

  large_idx <- large_idx[seq_len(n_print), , drop = FALSE]
  large_values <- large_values[seq_len(n_print)]

  # The residual matrix is symmetric, so prefer column names but fall back to row
  # names (unlike `.efa_variable_names()`, which only consults row names).
  var_names <- colnames(residuals)
  if (is.null(var_names)) {
    var_names <- rownames(residuals)
  }
  if (is.null(var_names)) {
    var_names <- paste0("V", seq_len(ncol(residuals)))
  }

  heading <- if (n_print < n_large) {
    paste0("Largest residuals (top ", n_print, " of ", n_large, "):")
  } else {
    "Largest residuals:"
  }

  bullets <- vapply(seq_along(large_values), function(i) {
    paste0(var_names[large_idx[i, 1]], " ~~ ", var_names[large_idx[i, 2]],
           ": ", .efa_format_plain_number(large_values[i], digits))
  }, character(1L))

  cli::cli_text("")
  cli::cli_text("{heading}")
  .efa_emit_bullets(bullets)
  cli::cli_text("")
  cli::cli_text("Inspect the residual matrix for details (e.g., with residuals()).")

  invisible(NULL)
}

.print_efa_loading_ci_section <- function(x, spec, component, loadings, loading_label,
                                          cutoff, digits, max_name_length,
                                          ci, ci_filter,
                                          sort_loadings = c("none", "primary", "clustered")) {
  sort_loadings <- .match_arg_ci(sort_loadings)

  if (!.efa_should_print_ci(x, ci)) {
    return(invisible(NULL))
  }

  loading_ci <- .efa_get_component_ci(x, component)
  if (is.null(loading_ci)) {
    return(invisible(NULL))
  }

  loadings <- as.matrix(loadings)
  lower <- as.matrix(loading_ci$lower)
  upper <- as.matrix(loading_ci$upper)

  if (!all(dim(loadings) == dim(lower)) || !all(dim(loadings) == dim(upper))) {
    return(invisible(NULL))
  }

  row_order <- .efa_loading_row_order(loadings, sort_loadings)
  loadings <- loadings[row_order, , drop = FALSE]
  lower <- lower[row_order, , drop = FALSE]
  upper <- upper[row_order, , drop = FALSE]

  keep <- .efa_select_loading_cis(loadings, lower, upper, cutoff, ci_filter)
  title <- .efa_loading_ci_title(spec, loading_label, ci_filter)
  .print_efa_rule(title)

  if (!any(keep, na.rm = TRUE)) {
    cli::cli_text("No loading CIs matched ci_filter = '{ci_filter}'.")
    return(invisible(NULL))
  }

  idx <- which(keep, arr.ind = TRUE)
  row_names <- .efa_truncate_names(.efa_variable_names(loadings), max_name_length)
  factor_names <- .efa_factor_names(loadings)

  .print_efa_ci_table(
    key = row_names[idx[, 1]],
    second_key = factor_names[idx[, 2]],
    estimate = loadings[idx],
    lower = lower[idx],
    upper = upper[idx],
    digits = digits,
    key_header = "Variable",
    second_key_header = "Factor",
    highlight = abs(loadings[idx]) >= cutoff
  )

  invisible(NULL)
}

.print_efa_phi_ci_section <- function(x, spec, digits, ci) {
  if (!.efa_should_print_ci(x, ci)) {
    return(invisible(NULL))
  }

  phi_ci <- .efa_get_component_ci(x, "Phi")
  if (is.null(phi_ci) || is.null(x$Phi)) {
    return(invisible(NULL))
  }

  phi <- as.matrix(x$Phi)
  lower <- as.matrix(phi_ci$lower)
  upper <- as.matrix(phi_ci$upper)

  if (!all(dim(phi) == dim(lower)) || !all(dim(phi) == dim(upper))) {
    return(invisible(NULL))
  }

  keep <- lower.tri(phi) & is.finite(phi) & is.finite(lower) & is.finite(upper)
  if (!any(keep, na.rm = TRUE)) {
    return(invisible(NULL))
  }

  idx <- which(keep, arr.ind = TRUE)
  factor_names <- .efa_factor_names(phi)

  .print_efa_rule(.efa_phi_ci_title(spec))
  .print_efa_ci_table(
    key = paste0(factor_names[idx[, 2]], " ~~ ", factor_names[idx[, 1]]),
    second_key = NULL,
    estimate = phi[idx],
    lower = lower[idx],
    upper = upper[idx],
    digits = digits,
    key_header = "Factors",
    second_key_header = NULL,
    highlight = NULL
  )

  invisible(NULL)
}

# Count Heywood cases: prefer the detector's `heywood` field (covers communalities
# >= 1 and ML/ULS boundary uniquenesses); fall back to communalities >= 1 for objects
# that carry no such field (e.g. pooled solutions).
.efa_heywood_count <- function(heywood, h2 = NULL) {
  if (!is.null(heywood)) {
    length(heywood)
  } else if (!is.null(h2)) {
    sum(h2 >= 1 + .Machine$double.eps, na.rm = TRUE)
  } else {
    0L
  }
}

# The Heywood count as it is reported in the diagnostics section. A pooled solution
# counts the cases in the pooled matrix, and averaging aligned solutions pulls boundary
# communalities back inside the admissible range, so that count can be 0 for a pool whose
# component fits were improper. Where the components recorded Heywood cases, report them
# beside the pooled count rather than leaving the pooled number to read as an all-clear.
.efa_heywood_value_text <- function(x, count) {
  adm <- x[["mi_admissibility"]]
  if (is.null(adm) || is.null(adm$n_heywood_items)) {
    return(count)
  }

  n_flags <- sum(adm$n_heywood_items)
  if (n_flags < 1L) {
    return(count)
  }

  # A variable flagged in several imputations counts once per imputation, so the total is
  # a number of flags rather than of distinct variables.
  paste0(count, " pooled (", n_flags, if (n_flags == 1L) " flag" else " flags",
         " across ", length(adm$heywood_imputations), " of ", adm$m, " imputations)")
}

.print_efa_heywood_warning <- function(heywood, h2 = NULL) {
  n_heywood <- .efa_heywood_count(heywood, h2)

  if (n_heywood >= 1) {
    cli::cli_text("")
    cli::cli_alert_danger("{n_heywood} Heywood case{?s} {?was/were} detected!")
  }

  invisible(NULL)
}

.print_efa_rule <- function(title) {
  cli::cli_text("")
  cli::cli_rule(left = "{.strong {title}}")
  cli::cli_text("")

  invisible(NULL)
}

.efa_should_print_ci <- function(x, ci) {
  !identical(ci, "none") && !is.null(x$CI)
}

.efa_get_component_ci <- function(x, component) {
  if (is.null(x$CI)) {
    return(NULL)
  }

  ci <- x$CI[[component]]
  if (.efa_is_ci_pair(ci)) {
    return(ci)
  }

  NULL
}

.efa_get_fit_ci <- function(x, spec = .efa_print_spec(x)) {
  if (is.null(x$CI)) {
    return(NULL)
  }

  components <- if (isTRUE(spec$is_pooled)) {
    c("fit_indices_descriptive", "fit_indices")
  } else {
    c("fit_indices", "fit_indices_descriptive")
  }

  for (component in components) {
    ci <- x$CI[[component]]
    if (.efa_is_ci_pair(ci)) {
      return(ci)
    }
  }

  NULL
}

.efa_is_ci_pair <- function(x) {
  is.list(x) && !is.null(x$lower) && !is.null(x$upper)
}

.efa_select_loading_cis <- function(loadings, lower, upper, cutoff, ci_filter) {
  finite <- is.finite(loadings) & is.finite(lower) & is.finite(upper)

  if (identical(ci_filter, "all")) {
    return(finite)
  }

  if (identical(ci_filter, "nonzero")) {
    return(finite & (lower > 0 | upper < 0))
  }

  finite & abs(loadings) >= cutoff
}

.efa_n_imputations <- function(x) {
  n_imputations <- x$settings$n_imputations

  if ((is.null(n_imputations) || length(n_imputations) < 1L || is.na(n_imputations[1])) &&
      !is.null(x$fits)) {
    n_imputations <- length(x$fits)
  }

  if (is.null(n_imputations) || length(n_imputations) < 1L || is.na(n_imputations[1])) {
    return(NA_integer_)
  }

  as.integer(n_imputations[1])
}

.efa_setting_text <- function(x) {
  if (is.null(x) || length(x) < 1L || is.na(x[1])) {
    return("")
  }

  as.character(x[1])
}

.efa_ci_level <- function(x) {
  val <- x$settings$ci
  if (is.null(val) || length(val) < 1L || is.na(val[1])) {
    return(NA_real_)
  }

  out <- suppressWarnings(as.numeric(val[1]))
  if (length(out) < 1L || is.na(out)) {
    return(NA_real_)
  }

  out
}

.efa_rmsea_ci_level <- function(x) {
  val <- x$settings$rmsea_ci_level
  if (is.null(val) || length(val) < 1L || is.na(val[1])) {
    return(.90)
  }

  out <- suppressWarnings(as.numeric(val[1]))
  if (length(out) < 1L || !is.finite(out) || out <= 0 || out >= 1) {
    return(.90)
  }

  out
}

.efa_ci_level_text <- function(level) {
  if (!is.finite(level)) {
    return("")
  }

  paste0(formatC(level * 100, format = "fg", digits = 4, width = -1), "%")
}

.efa_ci_source_text <- function(spec) {
  if (isTRUE(spec$is_pooled)) {
    # Label the pooled CI source by route. The information route Rubin-pools the
    # per-imputation Wald intervals across imputations ("Wald/MI"). The sandwich
    # (MI2S) route fits a single model to the pooled inputs and carries the
    # imputation uncertainty in the pooled asymptotic covariance rather than by
    # per-parameter Rubin pooling, so its intervals are Wald intervals from that
    # single sandwich fit ("sandwich/MI") -- not Rubin-pooled. The bootstrap
    # route's within-imputation variance is bootstrap-based ("bootstrap/MI").
    if (identical(spec$component_se, "information")) {
      return("Wald/MI")
    }
    if (identical(spec$component_se, "sandwich")) {
      return("sandwich/MI")
    }
    return("bootstrap/MI")
  }

  # Analytic intervals are Wald (estimate +/- z * SE); only the bootstrap CIs are percentile.
  if (!isTRUE(spec$np_boot)) {
    return("Wald")
  }

  "bootstrap"
}

# Reportable valid target-rotation counts: the non-NA counts as integers, or NULL when
# there is nothing to report. Shared by the CI-source note and the diagnostics summary,
# which pass the same counts through different wording.
.efa_valid_target_rotation_counts <- function(x) {
  counts <- .efa_valid_target_rotations(x)
  if (is.null(counts) || length(counts) < 1L || all(is.na(counts))) {
    return(NULL)
  }

  counts <- as.integer(stats::na.omit(counts))
  if (length(counts) < 1L) {
    return(NULL)
  }

  counts
}

.efa_target_rotation_note <- function(x, spec) {
  counts <- .efa_valid_target_rotation_counts(x)
  if (is.null(counts)) {
    return("")
  }

  total <- .efa_total_bootstrap_samples(x, spec)
  if (!is.na(total) && !any(counts < total)) {
    return("")
  }

  component_text <- .efa_target_rotation_component_text(x)
  count_text <- .efa_valid_target_rotation_count_text(counts, spec)
  total_text <- if (!is.na(total)) {
    paste0(" out of ", total)
  } else {
    ""
  }
  scope_text <- if (isTRUE(spec$is_pooled)) {
    " per imputation"
  } else {
    ""
  }

  paste0(
    component_text,
    " based on ", count_text,
    " valid target-rotated bootstrap samples",
    total_text,
    scope_text
  )
}

.efa_valid_target_rotations <- function(x) {
  if (!is.null(x$SE$valid_target_rotations)) {
    return(x$SE$valid_target_rotations)
  }

  if (!is.null(x$MI$bootstrap_rotation_valid)) {
    return(x$MI$bootstrap_rotation_valid)
  }

  NULL
}

.efa_total_bootstrap_samples <- function(x, spec) {
  if (!is.null(spec$b_boot) && length(spec$b_boot) >= 1L &&
      is.finite(spec$b_boot[1])) {
    return(as.integer(spec$b_boot[1]))
  }

  if (!is.null(x$replicates$unrot_loadings)) {
    arr <- x$replicates$unrot_loadings
    if (length(dim(arr)) >= 3L) {
      return(dim(arr)[3L])
    }
  }

  NA_integer_
}

.efa_target_rotation_component_text <- function(x) {
  if (!is.null(x$Phi)) {
    return("rotated-loading, factor-intercorrelation, and structure-coefficient CIs")
  }

  "rotated-loading CIs"
}

.efa_valid_target_rotation_count_text <- function(counts, spec) {
  counts <- as.integer(counts)

  if (!isTRUE(spec$is_pooled) || length(unique(counts)) == 1L) {
    return(as.character(counts[1L]))
  }

  paste0("between ", min(counts), " and ", max(counts))
}

.efa_ci_header_label <- function(spec) {
  level <- .efa_ci_level_text(spec$ci)
  source <- .efa_ci_source_text(spec)

  if (nzchar(level)) {
    paste(level, source)
  } else {
    source
  }
}

.efa_loading_ci_title <- function(spec, loading_label, ci_filter) {
  filter_text <- switch(ci_filter,
    salient = "salient",
    all = "all",
    nonzero = "nonzero",
    ci_filter
  )

  paste0(
    .efa_ci_header_label(spec),
    " CIs for ", filter_text, " ", loading_label, " loadings"
  )
}

.efa_phi_ci_title <- function(spec) {
  paste0(.efa_ci_header_label(spec), " CIs for factor intercorrelations")
}

# Variable labels for the report's name columns (CI tables, simple-structure bullets).
# Shares the loading table's shortening, so a variable carries the same label wherever the
# report names it -- including the collision handling, without which the loading table and
# the sections below it could label the same item differently.
.efa_truncate_names <- function(x, max_name_length) {
  .shorten_loadings_names(x, max_length = max_name_length, name_style = "truncate")
}

# Format a vector of CI numbers for the CI tables. `.efa_num` is vectorized, so one
# call formats the whole vector; the result feeds data.frame columns that drop names.
.efa_format_ci_num <- function(x, digits) {
  .efa_num(round(x, digits = digits), digits = digits)
}

.print_efa_ci_table <- function(key, second_key = NULL, estimate, lower, upper,
                                digits, key_header, second_key_header = NULL,
                                highlight = NULL) {
  has_second_key <- !is.null(second_key)

  # Right-pad each cell to its column width (base equivalent of str_pad side = "right").
  pad <- function(s, w) formatC(s, width = w, flag = "-")

  estimate_chr <- .efa_format_ci_num(estimate, digits)
  lower_chr <- .efa_format_ci_num(lower, digits)
  upper_chr <- .efa_format_ci_num(upper, digits)

  if (has_second_key) {
    plain_rows <- data.frame(
      key = as.character(key),
      second_key = as.character(second_key),
      estimate = estimate_chr,
      lower = lower_chr,
      upper = upper_chr,
      stringsAsFactors = FALSE
    )
    headers <- c(key_header, second_key_header, "est", "lower", "upper")
  } else {
    plain_rows <- data.frame(
      key = as.character(key),
      estimate = estimate_chr,
      lower = lower_chr,
      upper = upper_chr,
      stringsAsFactors = FALSE
    )
    headers <- c(key_header, "est", "lower", "upper")
  }

  widths <- vapply(seq_along(headers), function(j) {
    max(nchar(c(headers[j], plain_rows[[j]])), na.rm = TRUE)
  }, numeric(1))

  header <- paste(
    mapply(pad, headers, widths, USE.NAMES = FALSE),
    collapse = "  "
  )

  rows <- vapply(seq_len(nrow(plain_rows)), function(i) {
    cells <- as.character(unlist(plain_rows[i, , drop = FALSE], use.names = FALSE))
    padded <- mapply(pad, cells, widths, USE.NAMES = FALSE)

    if (has_second_key && !is.null(highlight) && isTRUE(highlight[i])) {
      padded[3] <- cli::style_bold(padded[3])
    }

    paste(padded, collapse = "  ")
  }, character(1L))

  # A fixed-width table; emit verbatim so cli does not reflow the aligned columns.
  .efa_emit_lines(c(header, rows))

  invisible(NULL)
}


# Argument checks shared by the print/format methods (print.efa() and its summary, and
# print.efa_loadings()), which validate the same display options with the same wording.
# `arg` names the offender and `class` carries its condition class, both of which are part
# of each method's contract; `call` defaults to the calling validator's frame, so routing
# the abort through a helper reports the same call as an inline check would.
#
# The whole-number tests below compare against `round()` rather than `as.integer()`: a
# value beyond the integer range coerces to NA with a warning, and the resulting NA in the
# `||` chain aborts with "missing value where TRUE/FALSE needed" instead of the classed
# condition each caller documents. `round()` is exact for every finite double, and the
# separate integer-range bound then rejects the out-of-range value through the proper
# condition (no display option has a legitimate use above 2^31).

# A single finite number, zero or greater.
.efa_check_nonneg_number <- function(value, arg, class, call = rlang::caller_env()) {
  if (!is.numeric(value) || length(value) != 1L || !is.finite(value) || value < 0) {
    cli::cli_abort("{.arg {arg}} must be a single finite non-negative number.",
                   class = class, call = call)
  }
  invisible(TRUE)
}

# A single finite whole number, zero or greater.
.efa_check_nonneg_int <- function(value, arg, class, call = rlang::caller_env()) {
  if (!.efa_is_whole_number(value) || value < 0) {
    cli::cli_abort("{.arg {arg}} must be a single finite non-negative integer.",
                   class = class, call = call)
  }
  invisible(TRUE)
}

# A single finite whole number, one or greater.
.efa_check_pos_int <- function(value, arg, class, call = rlang::caller_env()) {
  if (!.efa_is_whole_number(value) || value < 1) {
    cli::cli_abort("{.arg {arg}} must be a single finite positive integer.",
                   class = class, call = call)
  }
  invisible(TRUE)
}

# As .efa_check_pos_int(), but `NULL` (the "choose it from the console width" default) passes.
.efa_check_opt_pos_int <- function(value, arg, class, call = rlang::caller_env()) {
  if (!is.null(value) && (!.efa_is_whole_number(value) || value < 1)) {
    cli::cli_abort("{.arg {arg}} must be {.val NULL} or a single finite positive integer.",
                   class = class, call = call)
  }
  invisible(TRUE)
}

# TRUE for a single finite whole number inside the integer range; never NA.
.efa_is_whole_number <- function(value) {
  is.numeric(value) && length(value) == 1L && is.finite(value) &&
    abs(value) <= .Machine$integer.max && value == round(value)
}

# A single non-missing TRUE/FALSE.
.efa_check_flag <- function(value, arg, class, call = rlang::caller_env()) {
  if (!is.logical(value) || length(value) != 1L || is.na(value)) {
    cli::cli_abort("{.arg {arg}} must be {.val TRUE} or {.val FALSE}.",
                   class = class, call = call)
  }
  invisible(TRUE)
}

.efa_validate_print_options <- function(cutoff, digits, max_name_length,
                                        diagnostics_top_n,
                                        residual_cutoff, residual_top_n,
                                        show_structure, show_loading_legend,
                                        cross_loading_cutoff, min_primary_gap,
                                        min_salient_per_factor,
                                        max_factors_per_block,
                                        show_mi_diagnostics) {
  .efa_check_nonneg_number(cutoff, "cutoff", "efa_print_invalid_cutoff")
  .efa_check_nonneg_int(digits, "digits", "efa_print_invalid_digits")
  .efa_check_pos_int(max_name_length, "max_name_length",
                     "efa_print_invalid_max_name_length")

  if (!.efa_is_top_n(diagnostics_top_n)) {
    cli::cli_abort("{.arg diagnostics_top_n} must be a positive integer or {.val Inf}.",
                   class = "efa_print_invalid_diagnostics_top_n")
  }

  .efa_check_nonneg_number(residual_cutoff, "residual_cutoff",
                           "efa_print_invalid_residual_cutoff")

  if (!.efa_is_top_n(residual_top_n)) {
    cli::cli_abort("{.arg residual_top_n} must be a positive integer or {.val Inf}.",
                   class = "efa_print_invalid_residual_top_n")
  }

  .efa_check_flag(show_structure, "show_structure",
                  "efa_print_invalid_show_structure")
  .efa_check_flag(show_loading_legend, "show_loading_legend",
                  "efa_print_invalid_show_loading_legend")
  .efa_check_nonneg_number(cross_loading_cutoff, "cross_loading_cutoff",
                           "efa_print_invalid_cross_loading_cutoff")
  .efa_check_nonneg_number(min_primary_gap, "min_primary_gap",
                           "efa_print_invalid_min_primary_gap")
  .efa_check_pos_int(min_salient_per_factor, "min_salient_per_factor",
                     "efa_print_invalid_min_salient_per_factor")
  .efa_check_opt_pos_int(max_factors_per_block, "max_factors_per_block",
                         "efa_print_invalid_max_factors_per_block")

  if (!is.null(show_mi_diagnostics) &&
      (!is.logical(show_mi_diagnostics) || length(show_mi_diagnostics) != 1L ||
       is.na(show_mi_diagnostics))) {
    cli::cli_abort("{.arg show_mi_diagnostics} must be {.val TRUE}, {.val FALSE}, or {.val NULL}.",
                   class = "efa_print_invalid_show_mi_diagnostics")
  }

  invisible(TRUE)
}

.efa_iteration_nonconvergence <- function(spec) {
  # All current fits report a convergence code (0 = converged); a non-zero code
  # means the estimator stopped before meeting its tolerance. The iteration-count
  # fallback below covers objects that carry no convergence code but do report iter
  # and max_iter (e.g. solutions saved by an older package version).
  convergence <- .efa_as_scalar_number(spec$convergence)
  if (is.finite(convergence) && convergence != 0) {
    return(TRUE)
  }

  iter <- .efa_as_scalar_number(spec$iter)
  max_iter <- .efa_as_scalar_number(spec$max_iter)

  is.finite(iter) && is.finite(max_iter) && iter >= max_iter
}

# Message for the non-convergence banner (shared by print.efa() and
# print.efa_schmid_leiman()).
# PAF non-convergence always means the iteration cap was hit; the optimiser-based
# estimators report a convergence code that need not be an iteration limit.
.efa_nonconvergence_banner <- function(estimator) {
  if (identical(estimator, "PAF")) {
    "Maximum number of iterations reached without convergence"
  } else {
    "The optimiser did not converge"
  }
}

.efa_as_scalar_number <- function(x) {
  if (is.null(x) || length(x) < 1L) {
    return(NA_real_)
  }

  out <- tryCatch(
    suppressWarnings(as.numeric(x[1])),
    error = function(e) NA_real_
  )
  if (length(out) < 1L) {
    return(NA_real_)
  }

  out
}

.efa_is_missing_number <- function(x) {
  val <- .efa_as_scalar_number(x)
  !is.finite(val)
}

.efa_fit_scalar <- function(fit, name) {
  if (is.null(fit) || is.null(fit[[name]]) || length(fit[[name]]) < 1L) {
    return(NA_real_)
  }

  out <- tryCatch(
    suppressWarnings(as.numeric(fit[[name]][1])),
    error = function(e) NA_real_
  )
  if (length(out) < 1L) {
    return(NA_real_)
  }

  out
}

.efa_format_number <- function(x, digits = 2, print_zero = FALSE, pad = TRUE) {
  if (length(x) != 1L || !is.finite(x)) {
    return("NA")
  }

  .efa_num(round(x, digits = digits), digits = digits,
           print_zero = print_zero, pad = pad)
}

# Fit indices are reported to two decimals, not the three the loading, correlation and
# coefficient tables use. That is deliberate: CFI/TLI/RMSEA/SRMR/CAF are conventionally
# reported to two decimals in the literature, and the third digit is well inside the
# sampling error of any of them. The leading-zero convention is shared (via .efa_num()).
.efa_format_fit_value <- function(fit, name, digits = 2,
                                  print_zero = FALSE, pad = TRUE) {
  .efa_format_number(
    .efa_fit_scalar(fit, name),
    digits = digits,
    print_zero = print_zero,
    pad = pad
  )
}

.efa_bootstrap_sample_text <- function(x, spec) {
  b_boot <- .efa_as_scalar_number(spec$b_boot)
  if (!is.finite(b_boot)) {
    return("")
  }

  label <- if (isTRUE(spec$is_pooled)) {
    "bootstrap samples per imputation"
  } else {
    "bootstrap samples"
  }

  paste0(as.integer(b_boot), " ", label, .efa_usable_replicate_text(x, spec))
}

# " (n usable)" whenever fewer replicates survived than were requested, else "". The count is the
# one the unrotated-loading, residual and fit-index statistics are actually based on, so without it
# the printed sample count claims a precision the intervals do not have. The rotated block carries
# its own count (`valid_target_rotations`) and is reported separately. The requested total is
# resolved here rather than by the caller, so both call sites mean the same number by it.
.efa_usable_replicate_text <- function(x, spec) {
  counts <- .efa_valid_replicate_counts(x)
  total <- .efa_total_bootstrap_samples(x, spec)
  if (is.null(counts) || !is.finite(total) || !any(counts < total)) {
    return("")
  }

  scope <- if (isTRUE(spec$is_pooled)) " per imputation" else ""
  paste0(" (", .efa_valid_target_rotation_count_text(counts, spec), " usable", scope, ")")
}

# Replicates that were fitted and aligned successfully, as an integer vector (one entry per
# imputation for a pooled fit, one entry otherwise), or NULL when the object records none. A single
# fit stores the count; the pooled bootstrap route stores per-imputation FAILURE counts instead, so
# they are subtracted from the requested total there. Mirrors `.efa_valid_target_rotations()`, which
# resolves the rotated block's count from the same two places.
.efa_valid_replicate_counts <- function(x) {
  if (!is.null(x$SE$valid_replicates)) {
    counts <- x$SE$valid_replicates
  } else if (!is.null(x$MI$bootstrap_source_failures) &&
             !is.null(x$settings$b_boot)) {
    counts <- x$settings$b_boot[1] - x$MI$bootstrap_source_failures
  } else {
    return(NULL)
  }

  counts <- suppressWarnings(as.integer(stats::na.omit(as.numeric(counts))))
  if (length(counts) < 1L) {
    return(NULL)
  }

  counts
}

.efa_alignment_summary <- function(x) {
  alignment <- x$alignment
  if (is.null(alignment)) {
    return("")
  }

  out <- character(0)

  method <- .efa_setting_text(alignment$method)
  if (!nzchar(method)) {
    method <- .efa_setting_text(x$settings$target_method)
  }
  if (nzchar(method)) {
    out <- c(out, paste0("method = '", method, "'"))
  }

  if (!is.null(alignment$converged)) {
    converged <- isTRUE(alignment$converged)
    out <- c(out, if (converged) "converged" else "not converged")
  }

  failures <- alignment$point_rotation_failures
  if (!is.null(failures) && length(failures) > 0L) {
    failures <- failures[!is.na(failures)]
    if (length(failures) > 0L) {
      out <- c(out, paste0("point-alignment failures = ", length(failures)))
    }
  }

  paste(out, collapse = ", ")
}

.efa_is_top_n <- function(x) {
  is.numeric(x) && length(x) == 1L && !is.na(x) &&
    (is.infinite(x) || (x >= 1 && x == as.integer(x)))
}

.efa_main_loadings <- function(x, spec) {
  if (identical(spec$rotation, "none")) {
    if (is.null(x$unrot_loadings)) {
      return(NULL)
    }
    return(as.matrix(x$unrot_loadings))
  }

  if (is.null(x$rot_loadings)) {
    return(NULL)
  }

  as.matrix(x$rot_loadings)
}

.efa_variable_names <- function(x) {
  rn <- rownames(x)
  if (is.null(rn)) {
    rn <- paste0("V", seq_len(nrow(x)))
  }
  rn
}

.efa_factor_names <- function(x) {
  cn <- colnames(x)
  if (is.null(cn)) {
    cn <- paste0("F", seq_len(ncol(x)))
  }
  cn
}

.print_efa_diagnostics_section <- function(x, spec, cutoff, digits,
                                           cross_loading_cutoff,
                                           min_primary_gap,
                                           min_salient_per_factor) {
  loadings <- .efa_main_loadings(x, spec)

  title <- if (isTRUE(spec$is_pooled)) {
    "Pooled Model Diagnostics"
  } else {
    "Model Diagnostics"
  }
  .print_efa_rule(title)

  if (is.null(loadings)) {
    cli::cli_text("No loading matrix available for diagnostics.")
    return(invisible(NULL))
  }

  .efa_print_key_value("Factors", ncol(loadings))
  .efa_print_key_value("Variables", nrow(loadings))

  n_text <- .efa_setting_text(spec$N)
  if (nzchar(n_text) && !identical(n_text, "NA")) {
    .efa_print_key_value("N", n_text)
  }

  if (isTRUE(spec$is_pooled)) {
    n_imp <- .efa_setting_text(spec$n_imputations)
    if (nzchar(n_imp)) {
      .efa_print_key_value("Imputations", n_imp)
    }
    pooling <- .efa_pooled_settings_text(spec)
    if (length(pooling) > 0L) {
      # The only diagnostics entry whose value is a list of settings rather than a single
      # number, so it is the only one long enough to need the console-width packing the
      # pooled header uses; the rest stay one-line key/value pairs.
      cli::cli_verbatim(.efa_wrap_chunks(c("Pooling:", .efa_setting_chunks(pooling))))
    }

    alignment_text <- .efa_alignment_summary(x)
    if (nzchar(alignment_text)) {
      .efa_print_key_value("Alignment", alignment_text)
    }
  }

  if (isTRUE(spec$np_boot) && !is.null(spec$b_boot)) {
    sample_label <- if (isTRUE(spec$is_pooled)) {
      "Bootstrap samples per imputation"
    } else {
      "Bootstrap samples"
    }
    # Take the requested count from the same resolver the "(n usable)" suffix compares against, so
    # the two can never disagree about what was asked for or render it differently.
    b_total <- .efa_total_bootstrap_samples(x, spec)
    b_text <- if (is.na(b_total)) {
      .efa_setting_text(spec$b_boot)
    } else {
      as.character(b_total)
    }
    .efa_print_key_value(
      sample_label,
      paste0(b_text, .efa_usable_replicate_text(x, spec))
    )

    valid_text <- .efa_valid_target_rotation_summary(x, spec)
    if (nzchar(valid_text)) {
      .efa_print_key_value("Valid target-rotated samples", valid_text)
    }
  }

  # Distinct local optima the rotation found across its random starts, counted over the
  # starts that converged (only present for the gradient-projection rotations that use
  # random starts; omitted when none converged). Both counts are reported because the
  # screen-and-triage strategy optimizes only a few of the available starts, and only an
  # optimized start can yield a local optimum.
  # The n_starts_total check keeps an object serialized before the two-count diagnostics
  # existed (it carried a single `n_starts`) from rendering a garbled line: paste0() would
  # silently drop the missing counts. Such an object omits the line instead.
  rot_diag <- spec$rotation_diagnostics
  if (!is.null(rot_diag) && isTRUE(rot_diag$n_converged >= 1L) &&
      !is.null(rot_diag$n_starts_total)) {
    .efa_print_key_value(
      "Rotation local optima",
      paste0(rot_diag$n_distinct_minima, " distinct from ",
             rot_diag$n_optimized, " of ", rot_diag$n_starts_total, " starts")
    )
  }

  # Count Heywood cases the same way as the printed Heywood note (see
  # `.efa_heywood_count()`), qualified by the component fits on a pooled solution.
  heywood <- .efa_heywood_count(spec$heywood, spec$h2)
  .efa_print_key_value("Heywood cases", .efa_heywood_value_text(x, heywood))

  cross_salient <- abs(loadings) >= cross_loading_cutoff
  cross_salient[is.na(cross_salient)] <- FALSE
  cross_loading_items <- sum(rowSums(cross_salient) > 1L)

  salient <- abs(loadings) >= cutoff
  salient[is.na(salient)] <- FALSE
  no_salient_items <- sum(rowSums(salient) < 1L)
  .efa_print_key_value(
    paste0("Cross-loading items (|loading| >= ",
           .efa_format_plain_number(cross_loading_cutoff, digits), ")"),
    cross_loading_items
  )
  .efa_print_key_value(
    paste0("Items without salient loading (|loading| >= ",
           .efa_format_plain_number(cutoff, digits), ")"),
    no_salient_items
  )

  weak_factors <- .efa_factors_below_indicator_count(
    loadings,
    cutoff = cutoff,
    min_salient_per_factor = min_salient_per_factor
  )
  .efa_print_key_value(
    paste0("Factors with fewer than ", min_salient_per_factor,
           " salient indicators"),
    length(weak_factors)
  )

  gap_count <- .efa_small_primary_gap_count(loadings,
    cutoff = cutoff,
    min_primary_gap = min_primary_gap
  )
  .efa_print_key_value(
    paste0("Items with primary-loading gap < ",
           .efa_format_plain_number(min_primary_gap, digits)),
    gap_count
  )

  if (!is.null(x$residuals)) {
    largest_resid <- .efa_largest_abs_residual(x$residuals)
    if (is.finite(largest_resid)) {
      .efa_print_key_value("Largest |residual|",
        .efa_format_plain_number(largest_resid, digits)
      )
    }
  }

  if (!is.null(x$Phi)) {
    high_phi <- .efa_high_factor_correlation_count(x$Phi, cutoff = .85)
    value <- if (high_phi > 0L) {
      as.character(high_phi)
    } else {
      "none"
    }
    .efa_print_key_value("Factor intercorrelations > .85", value)
  }

  invisible(NULL)
}

.efa_print_key_value <- function(key, value) {
  # A "label: value" data line; emit verbatim so a long key or a settings value (e.g. the
  # pooled "Pooling: ..." line) is never reflowed mid-token.
  cli::cli_verbatim(paste0(key, ": ", as.character(value)))
  invisible(NULL)
}

.efa_valid_target_rotation_summary <- function(x, spec) {
  counts <- .efa_valid_target_rotation_counts(x)
  if (is.null(counts)) {
    return("")
  }

  total <- .efa_total_bootstrap_samples(x, spec)
  count_text <- .efa_valid_target_rotation_count_text(counts, spec)

  if (!is.na(total)) {
    count_text <- paste0(count_text, " out of ", total)
  }

  if (isTRUE(spec$is_pooled)) {
    count_text <- paste0(count_text, " per imputation")
  }

  count_text
}

.efa_factors_below_indicator_count <- function(loadings, cutoff,
                                               min_salient_per_factor) {
  salient <- abs(loadings) >= cutoff
  salient[is.na(salient)] <- FALSE
  counts <- colSums(salient)
  which(counts < min_salient_per_factor)
}

.efa_small_primary_gap_count <- function(loadings, cutoff, min_primary_gap) {
  length(.efa_small_primary_gap_rows(loadings, cutoff = cutoff,
                                     min_primary_gap = min_primary_gap))
}

.efa_largest_abs_residual <- function(residuals) {
  residuals <- as.matrix(residuals)
  values <- residuals[upper.tri(residuals)]
  values <- values[is.finite(values)]
  if (length(values) < 1L) {
    return(NA_real_)
  }
  max(abs(values), na.rm = TRUE)
}

.efa_high_factor_correlation_count <- function(phi, cutoff = .85) {
  phi <- as.matrix(phi)
  values <- phi[lower.tri(phi)]
  values <- values[is.finite(values)]
  sum(abs(values) > cutoff, na.rm = TRUE)
}

.print_efa_simple_structure_section <- function(x, spec, cutoff, digits,
                                                max_name_length,
                                                cross_loading_cutoff,
                                                min_primary_gap,
                                                min_salient_per_factor,
                                                diagnostics_top_n,
                                                sort_loadings) {
  loadings <- .efa_main_loadings(x, spec)
  row_order <- .efa_loading_row_order(loadings, sort_loadings)
  loadings <- loadings[row_order, , drop = FALSE]

  summary <- .efa_simple_structure_summary(
    loadings = loadings,
    cutoff = cutoff,
    cross_loading_cutoff = cross_loading_cutoff,
    min_primary_gap = min_primary_gap,
    min_salient_per_factor = min_salient_per_factor,
    digits = digits,
    max_name_length = max_name_length
  )

  if (!summary$has_output) {
    return(invisible(NULL))
  }

  .print_efa_rule("Simple Structure Diagnostics")

  # The diagnostics print as bulleted lists in this fixed order, each under its own
  # heading and each omitted when it has no entries. Only the weak-factor list is never
  # truncated: it holds at most one entry per factor.
  blocks <- list(
    list(values = summary$no_salient,
         top_n = diagnostics_top_n,
         heading = paste0("Items with no salient loading, |loading| >= ",
                          .efa_format_plain_number(cutoff, digits), ":")),
    list(values = summary$cross_loadings,
         top_n = diagnostics_top_n,
         heading = paste0("Items with cross-loadings, |loading| >= ",
                          .efa_format_plain_number(cross_loading_cutoff, digits),
                          " on multiple factors:")),
    list(values = summary$small_gaps,
         top_n = diagnostics_top_n,
         heading = paste0("Items with primary-loading gap < ",
                          .efa_format_plain_number(min_primary_gap, digits), ":")),
    list(values = summary$weak_factors,
         top_n = Inf,
         heading = paste0("Factors with fewer than ", min_salient_per_factor,
                          " salient indicators:"))
  )

  for (block in blocks) {
    if (length(block$values) > 0L) {
      .efa_print_limited_bullets(
        heading = block$heading,
        values = block$values,
        top_n = block$top_n
      )
    }
  }

  invisible(NULL)
}

.efa_simple_structure_summary <- function(loadings, cutoff, cross_loading_cutoff,
                                          min_primary_gap,
                                          min_salient_per_factor,
                                          digits, max_name_length) {
  var_names <- .efa_truncate_names(.efa_variable_names(loadings), max_name_length)
  factor_names <- .efa_factor_names(loadings)

  abs_loadings <- abs(loadings)
  salient_for_items <- abs_loadings >= cutoff
  salient_for_items[is.na(salient_for_items)] <- FALSE

  cross_salient <- abs_loadings >= cross_loading_cutoff
  cross_salient[is.na(cross_salient)] <- FALSE

  no_salient <- var_names[rowSums(salient_for_items) < 1L]

  cross_rows <- which(rowSums(cross_salient) > 1L)
  cross_loadings <- vapply(cross_rows, function(i) {
    idx <- which(cross_salient[i, ])
    paste0(var_names[i], ": ",
      .efa_format_loading_pairs(loadings[i, idx], factor_names[idx], digits)
    )
  }, character(1L))

  small_gap_rows <- .efa_small_primary_gap_rows(loadings,
    cutoff = cutoff,
    min_primary_gap = min_primary_gap
  )
  small_gaps <- vapply(small_gap_rows, function(i) {
    ord <- order(abs(loadings[i, ]), decreasing = TRUE)
    idx <- ord[seq_len(min(2L, length(ord)))]
    paste0(var_names[i], ": ",
      .efa_format_loading_pairs(loadings[i, idx], factor_names[idx], digits)
    )
  }, character(1L))

  factor_counts <- colSums(salient_for_items)
  weak_factor_idx <- which(factor_counts < min_salient_per_factor)
  if (length(weak_factor_idx) > 0L) {
    weak_factors <- paste0(
      factor_names[weak_factor_idx],
      ": ",
      factor_counts[weak_factor_idx],
      " salient indicator",
      ifelse(factor_counts[weak_factor_idx] == 1L, "", "s")
    )
  } else {
    weak_factors <- NULL
  }


  list(
    no_salient = no_salient,
    cross_loadings = cross_loadings,
    small_gaps = small_gaps,
    weak_factors = weak_factors,
    has_output = length(no_salient) > 0L ||
      length(cross_loadings) > 0L ||
      length(small_gaps) > 0L ||
      length(weak_factors) > 0L
  )
}

.efa_small_primary_gap_rows <- function(loadings, cutoff, min_primary_gap) {
  if (ncol(loadings) < 2L) {
    return(integer(0))
  }

  abs_loadings <- abs(loadings)
  abs_loadings[is.na(abs_loadings)] <- -Inf
  sorted <- t(apply(abs_loadings, 1L, sort, decreasing = TRUE))
  primary <- sorted[, 1L]
  secondary <- sorted[, 2L]

  which(is.finite(primary) & primary >= cutoff &
          is.finite(secondary) & (primary - secondary) < min_primary_gap)
}

.efa_format_loading_pairs <- function(values, factor_names, digits) {
  ord <- order(abs(values), decreasing = TRUE)
  values <- values[ord]
  factor_names <- factor_names[ord]

  paste0(
    factor_names,
    " = ",
    vapply(values, .efa_format_plain_number, character(1L), digits = digits),
    collapse = ", "
  )
}

.efa_print_limited_bullets <- function(heading, values, top_n) {
  cli::cli_text("{heading}")

  if (length(values) < 1L) {
    return(invisible(NULL))
  }

  n_print <- if (is.finite(top_n)) {
    min(length(values), as.integer(top_n))
  } else {
    length(values)
  }

  bullets <- values[seq_len(n_print)]
  if (n_print < length(values)) {
    bullets <- c(bullets, paste0("... ", length(values) - n_print, " more not shown"))
  }

  .efa_emit_bullets(bullets)
  cli::cli_text("")
  invisible(NULL)
}

.print_efa_structure_section <- function(x, cutoff, digits, max_name_length,
                                         sort_loadings, max_factors_per_block,
                                         ...) {
  .print_efa_rule("Structure Matrix")
  .efa_emit_lines(format(x$Structure,
    cutoff = cutoff,
    digits = digits,
    max_name_length = max_name_length,
    h2 = NULL,
    sort_loadings = sort_loadings,
    legend = FALSE,
    max_factors_per_block = max_factors_per_block,
    ...
  ))
  invisible(NULL)
}

.print_efa_mi_diagnostics_section <- function(x, spec, digits = 3) {
  if (!isTRUE(spec$is_pooled) || is.null(x$MI)) {
    return(invisible(NULL))
  }

  fmi_values <- .efa_collect_mi_values(x$MI, pattern = "fmi")
  riv_values <- .efa_collect_mi_values(x$MI, pattern = "riv")

  if (length(fmi_values) < 1L && length(riv_values) < 1L) {
    return(invisible(NULL))
  }

  .print_efa_rule("MI Uncertainty Summary")

  if (length(fmi_values) > 0L) {
    .efa_print_key_value("Largest available FMI",
      .efa_format_plain_number(max(fmi_values, na.rm = TRUE), digits)
    )
    .efa_print_key_value("Median available FMI",
      .efa_format_plain_number(stats::median(fmi_values, na.rm = TRUE), digits)
    )
  }

  if (length(riv_values) > 0L) {
    .efa_print_key_value("Largest available RIV",
      .efa_format_plain_number(max(riv_values, na.rm = TRUE), digits)
    )
    .efa_print_key_value("Median available RIV",
      .efa_format_plain_number(stats::median(riv_values, na.rm = TRUE), digits)
    )
  }

  invisible(NULL)
}

.efa_collect_mi_values <- function(x, pattern, path = "") {
  pieces <- .efa_collect_mi_values_impl(x, pattern = pattern, path = path)
  if (length(pieces) < 1L) {
    return(numeric(0))
  }

  values <- unlist(pieces, use.names = FALSE)
  values[is.finite(values)]
}

.efa_collect_mi_values_impl <- function(x, pattern, path = "") {
  if (is.list(x)) {
    nm <- names(x)
    if (is.null(nm)) {
      nm <- rep("", length(x))
    }

    pieces <- vector("list", length(x))
    for (i in seq_along(x)) {
      child_path <- paste(c(path, nm[i]), collapse = "/")
      pieces[[i]] <- .efa_collect_mi_values_impl(x[[i]], pattern, child_path)
    }
    return(unlist(pieces, recursive = FALSE, use.names = FALSE))
  }

  if (!is.numeric(x) || !grepl(pattern, path, ignore.case = TRUE)) {
    return(list())
  }

  list(as.numeric(x))
}

.efa_loading_row_order <- function(x, sort_loadings = c("none", "primary", "clustered")) {
  sort_loadings <- .match_arg_ci(sort_loadings)

  if (identical(sort_loadings, "none") || nrow(x) < 2L || ncol(x) < 1L) {
    return(seq_len(nrow(x)))
  }

  abs_x <- abs(x)
  abs_x[is.na(abs_x)] <- -Inf
  primary_factor <- max.col(abs_x, ties.method = "first")
  primary_loading <- apply(abs_x, 1L, max, na.rm = TRUE)
  primary_loading[!is.finite(primary_loading)] <- -Inf

  if (identical(sort_loadings, "primary")) {
    return(order(primary_factor, seq_along(primary_factor)))
  }

  order(primary_factor, -primary_loading, seq_along(primary_factor))
}

.efa_format_plain_number <- function(x, digits = 3, pad = FALSE) {
  if (length(x) != 1L || is.na(x)) {
    return("NA")
  }

  .efa_num(round(x, digits = digits), digits = digits, pad = pad)
}


.efa_paste_gof_ci <- function(fitind_ci, name) {
  if (!.efa_is_ci_pair(fitind_ci)) {
    return("")
  }

  lower_names <- names(fitind_ci$lower)
  upper_names <- names(fitind_ci$upper)
  if (is.null(lower_names) || is.null(upper_names) ||
      !(name %in% lower_names) || !(name %in% upper_names)) {
    return("")
  }

  tryCatch(.paste_gof_ci(fitind_ci, name), error = function(e) "")
}

Try the EFAtools package in your browser

Any scripts or data that you put into this service are public.

EFAtools documentation built on Aug. 21, 2026, 5:16 p.m.