<?php
/**
 * NeccoSpeak — Diagnostics tab (G4): environment/key/REST/rate-limit/prompt/
 * model/widget-status checks, decrypt-failure detection + notice, and the
 * "clear the broken key" / "re-encrypt" recovery actions.
 *
 * Required unconditionally (is_admin() guard) from the bottom of
 * includes/settings.php (design rev.2 §14.3 rule R1) — this is why the
 * admin_notices / admin_post_* hooks below are registered at file scope
 * (top-level add_action() calls), not inside a lazily-loaded function: an
 * admin-post.php request needs them registered even though it never renders
 * the settings page.
 *
 * Owner: impl-admin.
 * Public contract (used by settings.php / admin-tabs.php):
 *   neccospeak_diagnostics_rows()
 *   neccospeak_display_status( $opts = null )
 *   neccospeak_key_is_broken( $opts = null )
 *   neccospeak_render_diagnostics_tab( $opts )
 *   neccospeak_render_notice_query_param()
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/* -------------------------------------------------------------------------
 * Decrypt-failure detection.
 * ---------------------------------------------------------------------- */

/**
 * Whether a non-empty stored ciphertext currently fails to decrypt (design
 * rev.2 §10.2). A never-configured key ('' stored) is NOT "broken" — that is
 * a different, non-error state (Diagnostics B1 handles it separately).
 *
 * @param array|null $opts neccospeak_get_options(), or null to fetch it.
 * @return bool
 */
function neccospeak_key_is_broken( $opts = null ) {
	$opts   = is_array( $opts ) ? $opts : neccospeak_get_options();
	$cipher = isset( $opts['openai_api_key'] ) ? trim( (string) $opts['openai_api_key'] ) : '';
	if ( '' === $cipher ) {
		return false;
	}
	return '' === neccospeak_decrypt( $cipher );
}

/**
 * Re-evaluate whether the widget would currently display, independent of
 * neccospeak_should_display() (which is front-end-only: it hard-returns
 * false under is_admin(), so it cannot be reused here — design rev.2 §10.1 G1).
 * Checks the same three conditions minus the request-context ones.
 *
 * @param array|null $opts neccospeak_get_options(), or null to fetch it.
 * @return array{would_display: bool, enabled: bool, key_ok: bool, rule: string, pattern_count: int}
 */
function neccospeak_display_status( $opts = null ) {
	$opts = is_array( $opts ) ? $opts : neccospeak_get_options();

	$enabled = ! empty( $opts['enabled'] );
	$cipher  = isset( $opts['openai_api_key'] ) ? trim( (string) $opts['openai_api_key'] ) : '';
	$key_ok  = '' !== $cipher && '' !== neccospeak_decrypt( $cipher );

	$rule = isset( $opts['display_rule'] ) ? $opts['display_rule'] : 'all';
	if ( ! in_array( $rule, array( 'all', 'include', 'exclude' ), true ) ) {
		$rule = 'all';
	}

	$pattern_count = 0;
	if ( function_exists( 'neccospeak_url_list' ) ) {
		if ( 'include' === $rule ) {
			$pattern_count = count( neccospeak_url_list( isset( $opts['included_urls'] ) ? $opts['included_urls'] : '' ) );
		} elseif ( 'exclude' === $rule ) {
			$pattern_count = count( neccospeak_url_list( isset( $opts['excluded_urls'] ) ? $opts['excluded_urls'] : '' ) );
		}
	}

	return array(
		'would_display' => $enabled && $key_ok,
		'enabled'       => $enabled,
		'key_ok'        => $key_ok,
		'rule'          => $rule,
		'pattern_count' => $pattern_count,
	);
}

/**
 * Admin notice shown site-wide (every admin screen, not just Diagnostics) so
 * an administrator who never visits the settings page still learns the key
 * is broken (design rev.2 §10.2).
 *
 * @return void
 */
function neccospeak_key_health_notice() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}
	if ( ! neccospeak_key_is_broken() ) {
		return;
	}

	$general = add_query_arg(
		array(
			'page' => NECCOSPEAK_SETTINGS_SLUG,
			'tab'  => 'general',
		),
		admin_url( 'admin.php' )
	) . '#neccospeak_api_key';
	$diag = add_query_arg(
		array(
			'page' => NECCOSPEAK_SETTINGS_SLUG,
			'tab'  => 'diagnostics',
		),
		admin_url( 'admin.php' )
	);
	?>
	<div class="notice notice-error">
		<p><strong><?php esc_html_e( 'NeccoSpeak: the stored OpenAI API key can no longer be decrypted.', 'neccospeak' ); ?></strong></p>
		<p><?php esc_html_e( 'This happens when the security salts in wp-config.php change, or when the database is moved to another site. The chat widget is not being served until a working key is saved.', 'neccospeak' ); ?></p>
		<p>
			<a class="button button-primary" href="<?php echo esc_url( $general ); ?>"><?php esc_html_e( 'Re-enter the API key', 'neccospeak' ); ?></a>
			<a class="button" href="<?php echo esc_url( $diag ); ?>"><?php esc_html_e( 'Open diagnostics', 'neccospeak' ); ?></a>
		</p>
	</div>
	<?php
}
add_action( 'admin_notices', 'neccospeak_key_health_notice' );

/**
 * Render a dismissible notice for the ?neccospeak_notice= redirect param used
 * by the admin_post_* handlers below (design rev.2 §9.7: sanitize_key() +
 * wp_unslash() on the raw $_GET read).
 *
 * @return void
 */
function neccospeak_render_notice_query_param() {
	if ( ! isset( $_GET['neccospeak_notice'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result, the state change already happened (and was nonce-checked) in the admin_post_* handler.
		return;
	}
	$notice = sanitize_key( wp_unslash( $_GET['neccospeak_notice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended

	$messages = array(
		'key_cleared'   => array( 'success', __( 'The stored API key was cleared. Enter a new key on the General tab.', 'neccospeak' ) ),
		'rewrap_ok'     => array( 'success', __( 'The API key was re-encrypted with the current key derivation.', 'neccospeak' ) ),
		'rewrap_failed' => array( 'error', __( 'Could not re-encrypt the API key (it may already be unreadable). Clear it and re-enter it instead.', 'neccospeak' ) ),
		'quirks_reset'  => array( 'success', __( 'Learned model parameters were reset for this model.', 'neccospeak' ) ),
	);

	if ( ! isset( $messages[ $notice ] ) ) {
		return;
	}

	list( $type, $text ) = $messages[ $notice ];
	echo '<div class="notice notice-' . esc_attr( $type ) . ' is-dismissible"><p>' . esc_html( $text ) . '</p></div>';
}

/* -------------------------------------------------------------------------
 * Recovery actions: clear the broken key / re-encrypt / reset learned quirks.
 * ---------------------------------------------------------------------- */

/**
 * Clear a broken/unwanted stored API key. Deliberately bypasses
 * neccospeak_sanitize_options() entirely (remove_filter before update_option)
 * because that sanitizer's "empty submitted key = keep the existing value"
 * rule (needed so a routine save never accidentally wipes a working key)
 * would otherwise make an explicit clear impossible (design rev.2 §10.2).
 *
 * @return void
 */
function neccospeak_handle_clear_key() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_clear_key' );

	$opts                    = neccospeak_get_options();
	$opts['openai_api_key']  = '';

	remove_filter( 'sanitize_option_' . NECCOSPEAK_OPTION, 'neccospeak_sanitize_options', 10 );
	update_option( NECCOSPEAK_OPTION, $opts );

	wp_safe_redirect(
		add_query_arg(
			array(
				'page'               => NECCOSPEAK_SETTINGS_SLUG,
				'tab'                => 'general',
				'neccospeak_notice'  => 'key_cleared',
			),
			admin_url( 'admin.php' )
		)
	);
	exit;
}
add_action( 'admin_post_neccospeak_clear_key', 'neccospeak_handle_clear_key' );

/**
 * Re-encrypt the currently stored key with the current (primary) key
 * derivation and the v2 envelope (design rev.2 §10.3-4). Only ever runs on
 * an explicit admin click — no implicit/automatic rewrapping. Always routes
 * through neccospeak_store_api_key_value() (§0.2.1 L3 / R4) so a failure
 * never destroys the existing working ciphertext.
 *
 * @return void
 */
function neccospeak_handle_rewrap_key() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_rewrap_key' );

	$opts   = neccospeak_get_options();
	$cipher = isset( $opts['openai_api_key'] ) ? (string) $opts['openai_api_key'] : '';
	$plain  = '' !== $cipher ? neccospeak_decrypt( $cipher ) : '';

	$notice = 'rewrap_failed';
	if ( '' !== $plain ) {
		$rewrapped = neccospeak_store_api_key_value( $plain, $cipher );
		if ( $rewrapped !== $cipher ) {
			$opts['openai_api_key'] = $rewrapped;
			remove_filter( 'sanitize_option_' . NECCOSPEAK_OPTION, 'neccospeak_sanitize_options', 10 );
			update_option( NECCOSPEAK_OPTION, $opts );
			$notice = 'rewrap_ok';
		}
	}

	wp_safe_redirect(
		add_query_arg(
			array(
				'page'               => NECCOSPEAK_SETTINGS_SLUG,
				'tab'                => 'diagnostics',
				'neccospeak_notice'  => $notice,
			),
			admin_url( 'admin.php' )
		)
	);
	exit;
}
add_action( 'admin_post_neccospeak_rewrap_key', 'neccospeak_handle_rewrap_key' );

/**
 * Reset G17's learned per-model parameter quirks for one model (Diagnostics F1).
 *
 * @return void
 */
function neccospeak_handle_reset_quirks() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_reset_quirks' );

	$model = isset( $_GET['model'] ) ? sanitize_text_field( wp_unslash( $_GET['model'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above via check_admin_referer().
	if ( '' !== $model && function_exists( 'neccospeak_model_quirks_key' ) ) {
		delete_transient( neccospeak_model_quirks_key( $model ) );
	}

	wp_safe_redirect(
		add_query_arg(
			array(
				'page'               => NECCOSPEAK_SETTINGS_SLUG,
				'tab'                => 'diagnostics',
				'neccospeak_notice'  => 'quirks_reset',
			),
			admin_url( 'admin.php' )
		)
	);
	exit;
}
add_action( 'admin_post_neccospeak_reset_quirks', 'neccospeak_handle_reset_quirks' );

/**
 * Phase 6 (design phase6 §11.3 I4): force a re-detection of the G26 content
 * search engine (FULLTEXT ngram vs. LIKE fallback vs. none), bypassing the
 * cached option. function_exists()-guarded — includes/rag.php (impl-rag) may
 * not have landed yet; the action still registers unconditionally (rule R1)
 * so it degrades to "nothing to redo" rather than a 404 admin-post.php
 * request.
 *
 * @return void
 */
function neccospeak_handle_rag_recheck() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_rag_recheck' );

	if ( function_exists( 'neccospeak_rag_detect_engine' ) ) {
		neccospeak_rag_detect_engine( true );
	}

	wp_safe_redirect(
		add_query_arg(
			array(
				'page' => NECCOSPEAK_SETTINGS_SLUG,
				'tab'  => 'diagnostics',
			),
			admin_url( 'admin.php' )
		)
	);
	exit;
}
add_action( 'admin_post_neccospeak_rag_recheck', 'neccospeak_handle_rag_recheck' );

/* -------------------------------------------------------------------------
 * Diagnostics data (design rev.2 §10.1 groups A-H) + rendering.
 * ---------------------------------------------------------------------- */

/**
 * Try one specific candidate key against the stored ciphertext, for
 * Diagnostics B4 ("was it the PRIMARY key or a fallback that worked?").
 * Read-only; never generates or persists anything.
 *
 * @param string $cipher Stored ciphertext.
 * @param string $key    32-byte raw candidate key.
 * @return bool
 */
function neccospeak_diag_key_decrypts_with( $cipher, $key ) {
	if ( '' === $cipher || ! is_string( $key ) || 32 !== strlen( $key ) ) {
		return false;
	}
	return false !== neccospeak_decrypt_try_key( $cipher, $key );
}

/**
 * Build the full diagnostics row set (design rev.2 §10.1 A-G; H is rendered
 * as static prose directly in neccospeak_render_diagnostics_tab()).
 *
 * Each row: array('group','label','state' => 'ok'|'warn'|'error'|'info',
 * 'value','note','action' => array('url','label')|null[, 'row_id']).
 * 'row_id' rows are left as "Checking…" placeholders that assets/admin.js
 * fills in client-side (C1/C2/C2b/C4 must run from the browser, not PHP, to
 * actually exercise anonymous/omit-credentials reachability).
 *
 * @return array[]
 */
function neccospeak_diagnostics_rows() {
	$opts = neccospeak_get_options();
	$rows = array();

	/* ---- A. Environment ------------------------------------------------ */
	$rows[] = array(
		'group'  => 'A',
		'label'  => __( 'Plugin version', 'neccospeak' ),
		'state'  => 'info',
		'value'  => defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '',
		'note'   => '',
		'action' => null,
	);
	$rows[] = array(
		'group'  => 'A',
		'label'  => __( 'WordPress version', 'neccospeak' ),
		'state'  => 'info',
		'value'  => get_bloginfo( 'version' ),
		'note'   => '',
		'action' => null,
	);
	$php_ok = version_compare( PHP_VERSION, '8.0', '>=' );
	$rows[] = array(
		'group'  => 'A',
		'label'  => __( 'PHP version', 'neccospeak' ),
		'state'  => $php_ok ? 'info' : 'error',
		'value'  => PHP_VERSION,
		'note'   => $php_ok ? '' : __( 'PHP 8.0 or later is required.', 'neccospeak' ),
		'action' => null,
	);
	$has_openssl = function_exists( 'openssl_encrypt' );
	$rows[]      = array(
		'group'  => 'A',
		'label'  => __( 'openssl extension', 'neccospeak' ),
		'state'  => $has_openssl ? 'info' : 'error',
		'value'  => $has_openssl ? __( 'Available', 'neccospeak' ) : __( 'Missing', 'neccospeak' ),
		'note'   => $has_openssl
			? ''
			: __( 'A new API key cannot be encrypted. Any already-saved key is kept unchanged — saving without openssl shows an error instead of silently failing.', 'neccospeak' ),
		'action' => null,
	);
	$has_mbstring = function_exists( 'mb_strcut' );
	$rows[]       = array(
		'group'  => 'A',
		'label'  => __( 'mbstring extension', 'neccospeak' ),
		'state'  => $has_mbstring ? 'info' : 'warn',
		'value'  => $has_mbstring ? __( 'Available', 'neccospeak' ) : __( 'Missing', 'neccospeak' ),
		'note'   => $has_mbstring ? '' : __( 'A pure-PHP fallback safely cuts text at byte boundaries instead; everything still works.', 'neccospeak' ),
		'action' => null,
	);
	$voice_on     = ! empty( $opts['voice_enabled'] );
	$ssl          = is_ssl();
	$https_state  = $ssl ? 'info' : ( $voice_on ? 'error' : 'warn' );
	$rows[]       = array(
		'group'  => 'A',
		'label'  => __( 'HTTPS', 'neccospeak' ),
		'state'  => $https_state,
		'value'  => $ssl ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ),
		'note'   => $ssl ? '' : ( $voice_on
			? __( 'Voice is enabled but this request is not HTTPS — voice will not work for visitors.', 'neccospeak' )
			: __( 'Recommended for all traffic; required if voice is enabled.', 'neccospeak' ) ),
		'action' => null,
	);
	$rows[] = array(
		'group'  => 'A',
		'label'  => __( 'Persistent object cache', 'neccospeak' ),
		'state'  => 'info',
		'value'  => wp_using_ext_object_cache() ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ),
		'note'   => '',
		'action' => null,
	);
	$cron_disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON;
	$rows[]        = array(
		'group'  => 'A',
		'label'  => __( 'WP-Cron', 'neccospeak' ),
		'state'  => $cron_disabled ? 'warn' : 'info',
		'value'  => $cron_disabled ? __( 'Disabled', 'neccospeak' ) : __( 'Enabled', 'neccospeak' ),
		'note'   => $cron_disabled
			? __( 'Expired rate-limit / model-cache data relies on cron for cleanup; configure a site cron alternative.', 'neccospeak' )
			: '',
		'action' => null,
	);
	$option_missing = ( NECCOSPEAK_OPTION_MISSING_SENTINEL === get_option( NECCOSPEAK_OPTION, NECCOSPEAK_OPTION_MISSING_SENTINEL ) );
	$rows[]         = array(
		'group'  => 'A',
		'label'  => __( 'Settings saved', 'neccospeak' ),
		'state'  => $option_missing ? 'warn' : 'info',
		'value'  => $option_missing ? __( 'No', 'neccospeak' ) : __( 'Yes', 'neccospeak' ),
		'note'   => $option_missing ? __( 'Settings have never been saved yet.', 'neccospeak' ) : '',
		'action' => null,
	);

	/* ---- B. API key & encryption ---------------------------------------- */
	$cipher  = isset( $opts['openai_api_key'] ) ? trim( (string) $opts['openai_api_key'] ) : '';
	$has_key = '' !== $cipher;

	$rows[] = array(
		'group'  => 'B',
		'label'  => __( 'API key stored', 'neccospeak' ),
		'state'  => $has_key ? 'info' : 'error',
		'value'  => $has_key ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ),
		'note'   => $has_key ? '' : __( 'Enter your OpenAI API key on the General tab.', 'neccospeak' ),
		'action' => $has_key ? null : array(
			'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'general' ), admin_url( 'admin.php' ) ),
			'label' => __( 'Go to General', 'neccospeak' ),
		),
	);

	$plain           = $has_key ? neccospeak_decrypt( $cipher ) : '';
	$key_decryptable = '' !== $plain;
	$rows[]          = array(
		'group'  => 'B',
		'label'  => __( 'API key decryptable', 'neccospeak' ),
		'state'  => ! $has_key ? 'info' : ( $key_decryptable ? 'info' : 'error' ),
		'value'  => ! $has_key ? __( 'N/A', 'neccospeak' ) : ( $key_decryptable ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ) ),
		'note'   => ( $has_key && ! $key_decryptable )
			? __( 'The stored value is broken (wrong key derivation, moved database, or a legacy double-encrypted value). Clear it and re-enter your key.', 'neccospeak' )
			: '',
		'action' => ( $has_key && ! $key_decryptable ) ? array(
			'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'diagnostics' ), admin_url( 'admin.php' ) ) . '#neccospeak-clear-key',
			'label' => __( 'Clear the broken key', 'neccospeak' ),
		) : null,
	);

	$version = $has_key ? neccospeak_ciphertext_version( $cipher ) : '';
	$is_v2   = ( 'v2' === $version );
	$rows[]  = array(
		'group'  => 'B',
		'label'  => __( 'Envelope version', 'neccospeak' ),
		'state'  => ! $has_key ? 'info' : ( $is_v2 ? 'info' : 'warn' ),
		'value'  => ! $has_key ? __( 'N/A', 'neccospeak' ) : ( '' !== $version ? $version : __( 'unknown', 'neccospeak' ) ),
		'note'   => ( $has_key && ! $is_v2 && $key_decryptable )
			? __( 'Legacy format (no authentication tag). Re-encrypt it to upgrade.', 'neccospeak' )
			: '',
		'action' => ( $has_key && ! $is_v2 && $key_decryptable ) ? array(
			'url'   => wp_nonce_url( admin_url( 'admin-post.php?action=neccospeak_rewrap_key' ), 'neccospeak_rewrap_key' ),
			'label' => __( 'Re-encrypt now', 'neccospeak' ),
		) : null,
	);

	$derivation       = __( 'N/A', 'neccospeak' );
	$derivation_state = 'info';
	$derivation_note  = '';
	$rewrap_action    = null;
	if ( $has_key && $key_decryptable ) {
		$candidates = neccospeak_crypto_key_candidates();
		$primary_ok = isset( $candidates[0] ) && neccospeak_diag_key_decrypts_with( $cipher, $candidates[0] );
		if ( $primary_ok ) {
			$derivation = __( 'wp-config salts (primary)', 'neccospeak' );
		} else {
			$derivation       = __( 'Fallback key used', 'neccospeak' );
			$derivation_state = 'warn';
			$derivation_note  = __( 'Decrypted using a secondary key candidate, not the primary wp-config-derived one.', 'neccospeak' );
			$rewrap_action    = array(
				'url'   => wp_nonce_url( admin_url( 'admin-post.php?action=neccospeak_rewrap_key' ), 'neccospeak_rewrap_key' ),
				'label' => __( 'Re-encrypt now', 'neccospeak' ),
			);
		}
	}
	$rows[] = array(
		'group'  => 'B',
		'label'  => __( 'Key derivation source', 'neccospeak' ),
		'state'  => $derivation_state,
		'value'  => $derivation,
		'note'   => $derivation_note,
		'action' => $rewrap_action,
	);

	$fingerprint = '';
	$fp_state    = 'info';
	$fp_note     = '';
	if ( $has_key && $key_decryptable ) {
		$fingerprint = neccospeak_mask_key( $plain );
		if ( 0 !== strpos( $plain, 'sk-' ) ) {
			$fp_state = 'warn';
			$fp_note  = __( 'Does not start with "sk-" — this can indicate a double-encrypted legacy value.', 'neccospeak' );
		}
	}
	$rows[] = array(
		'group'  => 'B',
		'label'  => __( 'Key fingerprint', 'neccospeak' ),
		'state'  => ( $has_key && $key_decryptable ) ? $fp_state : 'info',
		'value'  => ( $has_key && $key_decryptable ) ? $fingerprint : __( 'N/A', 'neccospeak' ),
		'note'   => $fp_note,
		'action' => null,
	);

	/* ---- C. REST reachability (client-side placeholders) ---------------- */
	$rows[] = array(
		'group'  => 'C',
		'label'  => __( 'REST namespace reachable (admin, GET /diag)', 'neccospeak' ),
		'state'  => 'info',
		'value'  => __( 'Checking…', 'neccospeak' ),
		'note'   => '',
		'action' => null,
		'row_id' => 'neccospeak-diag-c1',
	);
	$rows[] = array(
		'group'  => 'C',
		'label'  => __( 'REST reachable, anonymous (GET /ping)', 'neccospeak' ),
		'state'  => 'info',
		'value'  => __( 'Checking…', 'neccospeak' ),
		'note'   => __( 'Confirms REST reachability only — not rate limits or OpenAI connectivity.', 'neccospeak' ),
		'action' => null,
		'row_id' => 'neccospeak-diag-c2',
	);
	$rows[] = array(
		'group'  => 'C',
		'label'  => __( 'REST reachable, anonymous (POST /ping)', 'neccospeak' ),
		'state'  => 'info',
		'value'  => __( 'Checking…', 'neccospeak' ),
		'note'   => __( 'Confirms the same-origin check passes for POST requests too.', 'neccospeak' ),
		'action' => null,
		'row_id' => 'neccospeak-diag-c2b',
	);
	$permalinks_off = ( '' === get_option( 'permalink_structure' ) );
	$rows[]         = array(
		'group'  => 'C',
		'label'  => __( 'Permalinks', 'neccospeak' ),
		'state'  => $permalinks_off ? 'warn' : 'info',
		'value'  => $permalinks_off ? __( 'Plain (using ?rest_route=)', 'neccospeak' ) : __( 'Pretty', 'neccospeak' ),
		'note'   => '',
		'action' => null,
	);
	$rows[] = array(
		'group'  => 'C',
		'label'  => __( 'Referrer-Policy', 'neccospeak' ),
		'state'  => 'info',
		'value'  => __( 'Checking…', 'neccospeak' ),
		'note'   => '',
		'action' => null,
		'row_id' => 'neccospeak-diag-c4',
	);

	/* ---- D. Rate limiting ------------------------------------------------ */
	$backend        = function_exists( 'neccospeak_counter_backend' ) ? neccospeak_counter_backend() : 'transient';
	$backend_labels = array(
		'object-cache' => __( 'Atomic (persistent object cache)', 'neccospeak' ),
		'db-atomic'    => __( 'Atomic (database)', 'neccospeak' ),
		'transient'    => __( 'Non-atomic fallback — concurrent requests may under-count. Limits still apply but are approximate.', 'neccospeak' ),
	);
	$rows[]         = array(
		'group'  => 'D',
		'label'  => __( 'Counter backend', 'neccospeak' ),
		'state'  => ( 'transient' === $backend ) ? 'warn' : 'info',
		'value'  => isset( $backend_labels[ $backend ] ) ? $backend_labels[ $backend ] : $backend,
		'note'   => '',
		'action' => null,
	);
	$caps   = sprintf(
		/* translators: 1: per-minute chat limit, 2: hourly site-wide chat limit, 3: daily site-wide chat limit, 4: per-minute voice-token limit, 5: daily site-wide voice-token limit. */
		__( 'Chat: %1$s/min, %2$s/hr, %3$s/day (site-wide). Voice tokens: %4$s/min, %5$s/day (site-wide).', 'neccospeak' ),
		number_format_i18n( (int) $opts['rate_limit_per_min'] ),
		number_format_i18n( (int) $opts['rate_limit_global_hourly'] ),
		number_format_i18n( (int) $opts['rate_limit_global_daily'] ),
		number_format_i18n( (int) $opts['realtime_limit_per_min'] ),
		number_format_i18n( (int) $opts['realtime_global_daily'] )
	);
	$rows[] = array(
		'group'  => 'D',
		'label'  => __( 'Current caps', 'neccospeak' ),
		'state'  => 'info',
		'value'  => $caps,
		'note'   => '',
		'action' => array(
			'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'rate-limits' ), admin_url( 'admin.php' ) ),
			'label' => __( 'Edit limits', 'neccospeak' ),
		),
	);
	// Expired rate-limit counter rows (both the 'db-atomic' direct-SQL rows and
	// the plain-transient fallback) are written under the standard
	// "_transient_neccospeak_..." / "_transient_timeout_neccospeak_..." naming,
	// so WordPress core's own daily expired-transient cleanup
	// (delete_expired_transients(), a core cron job) reclaims them the same way
	// it reclaims any other plugin's transients — no GC of our own is needed
	// (see the note on neccospeak_db_atomic_increment() in includes/rest.php).
	// That is why the WP-Cron row above (group A) already covers the only
	// failure mode that matters here: cron disabled.
	$rows[] = array(
		'group'  => 'D',
		'label'  => __( 'Counter key prefix', 'neccospeak' ),
		'state'  => 'info',
		'value'  => '_transient_neccospeak_',
		'note'   => __( 'Removed automatically on uninstall.', 'neccospeak' ),
		'action' => null,
	);

	/* ---- E. Prompt budget (shared with G18) ------------------------------ */
	if ( function_exists( 'neccospeak_compose_system_prompt_ex' ) ) {
		$text_result = neccospeak_compose_system_prompt_ex( $opts, 'text' );
		$rt_result   = neccospeak_compose_system_prompt_ex( $opts, 'realtime' );

		$rows[] = array(
			'group'  => 'E',
			'label'  => __( 'Text mode prompt budget', 'neccospeak' ),
			'state'  => $text_result['truncated'] ? 'warn' : 'info',
			'value'  => sprintf( '%1$s / %2$s bytes', number_format_i18n( $text_result['bytes'] ), number_format_i18n( $text_result['budget'] ) ),
			'note'   => $text_result['truncated'] ? __( 'The composed prompt exceeds the budget and was cut.', 'neccospeak' ) : '',
			'action' => array(
				'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'prompt' ), admin_url( 'admin.php' ) ),
				'label' => __( 'Open Prompt tab', 'neccospeak' ),
			),
		);
		$rows[] = array(
			'group'  => 'E',
			'label'  => __( 'Realtime mode prompt budget', 'neccospeak' ),
			'state'  => $rt_result['truncated'] ? 'warn' : 'info',
			'value'  => sprintf( '%1$s / %2$s bytes', number_format_i18n( $rt_result['bytes'] ), number_format_i18n( $rt_result['budget'] ) ),
			'note'   => $rt_result['truncated']
				? __( 'Realtime instructions are limited to about 8,000 bytes (roughly 2,600 Japanese characters).', 'neccospeak' )
				: '',
			'action' => array(
				'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'prompt' ), admin_url( 'admin.php' ) ),
				'label' => __( 'Open Prompt tab', 'neccospeak' ),
			),
		);
	}

	/* ---- F. Model parameters (G17) --------------------------------------- */
	if ( function_exists( 'neccospeak_model_quirks' ) ) {
		$model      = isset( $opts['model'] ) ? (string) $opts['model'] : '';
		$quirks     = neccospeak_model_quirks( $model );
		$has_quirks = ! empty( $quirks['dropped'] ) || 'max_tokens' !== $quirks['max_tokens_param'];
		$value      = $has_quirks
			? sprintf(
				/* translators: 1: comma-separated list of dropped parameter names, 2: token-limit parameter name currently in use. */
				__( 'Dropped: %1$s. Token-limit parameter: %2$s.', 'neccospeak' ),
				empty( $quirks['dropped'] ) ? __( 'none', 'neccospeak' ) : implode( ', ', $quirks['dropped'] ),
				'' === $quirks['max_tokens_param'] ? __( 'none', 'neccospeak' ) : $quirks['max_tokens_param']
			)
			: __( 'No learned quirks for the current model.', 'neccospeak' );
		$rows[]     = array(
			'group'  => 'F',
			'label'  => __( 'Learned model parameters', 'neccospeak' ),
			'state'  => 'info',
			'value'  => $value,
			'note'   => '',
			'action' => ( $has_quirks && '' !== $model ) ? array(
				'url'   => wp_nonce_url( admin_url( 'admin-post.php?action=neccospeak_reset_quirks&model=' . rawurlencode( $model ) ), 'neccospeak_reset_quirks' ),
				'label' => __( 'Reset learned parameters', 'neccospeak' ),
			) : null,
		);
	}

	/* ---- G. Widget status -------------------------------------------------- */
	$status = neccospeak_display_status( $opts );
	$rows[] = array(
		'group'  => 'G',
		'label'  => __( 'Widget would display', 'neccospeak' ),
		'state'  => $status['would_display'] ? 'info' : 'warn',
		'value'  => $status['would_display'] ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ),
		'note'   => '',
		'action' => null,
	);
	$rows[] = array(
		'group'  => 'G',
		'label'  => __( 'Display rule', 'neccospeak' ),
		'state'  => 'info',
		'value'  => sprintf(
			/* translators: 1: display rule value (all/include/exclude), 2: number of URL patterns configured for that rule. */
			__( '%1$s (%2$s pattern(s))', 'neccospeak' ),
			$status['rule'],
			number_format_i18n( $status['pattern_count'] )
		),
		'note'   => '',
		'action' => null,
	);

	/* ---- I. Phase 6 features (design phase6 §11.3) ---------------------- */
	neccospeak_diagnostics_rows_phase6( $rows, $opts );

	return $rows;
}

/**
 * Phase 6 (design phase6 §11.3): group I, "Phase 6 features" — I1-I12.
 * Split out of neccospeak_diagnostics_rows() purely to keep that function's
 * length manageable; behaves exactly as if the rows were appended inline
 * there. Every accessor owned by another Phase 6 agent's file
 * (includes/http.php / rag.php / tools.php) is called behind
 * function_exists() — this whole block degrades to "fewer info rows", never
 * a fatal, when those files have not landed yet (design phase6 §1.3).
 *
 * @param array[] $rows By-reference row accumulator (neccospeak_diagnostics_rows()'s own $rows).
 * @param array   $opts neccospeak_get_options().
 * @return void
 */
function neccospeak_diagnostics_rows_phase6( array &$rows, array $opts ) {
	$content_url    = add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'content' ), admin_url( 'admin.php' ) );
	// Review fix: the I1 SSE self-test row's action must point at the
	// Appearance tab — that is where stream_mode (Off/Typewriter/Live
	// streaming) actually lives, not Content.
	$appearance_url = add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'appearance' ), admin_url( 'admin.php' ) );

	// I1: SSE self-test — client-side placeholder (same C1/C2/C4 pattern),
	// filled in by assets/admin.js's initStreamSelfTest(). The current
	// stream_mode is carried on the row itself (a data attribute, added by
	// neccospeak_render_diagnostics_tab() below) so the client can apply the
	// "unsupported -> error when stream_mode==='sse'" upgrade (§7.4.4)
	// without any extra localized config.
	$rows[] = array(
		'group'  => 'I',
		'label'  => __( 'SSE self-test', 'neccospeak' ),
		'state'  => 'info',
		'value'  => __( 'Checking…', 'neccospeak' ),
		'note'   => __( 'Tests whether replies can be streamed progressively through your server and any reverse proxy in front of it.', 'neccospeak' ),
		'action' => array( 'url' => $appearance_url, 'label' => __( 'Open Appearance tab', 'neccospeak' ) ),
		'row_id' => 'neccospeak-diag-i1',
	);

	// I2: SSE prerequisites.
	$has_curl        = function_exists( 'curl_init' );
	$zlib_compression = (string) ini_get( 'zlib.output_compression' );
	$output_buffering = (string) ini_get( 'output_buffering' );
	$sse_disabled     = defined( 'NECCOSPEAK_DISABLE_SSE' ) && NECCOSPEAK_DISABLE_SSE;
	// Review fix: a single blocked word whose longest full-width/kana variant
	// (+1) exceeds NECCOSPEAK_SSE_HOLDBACK_MAX makes
	// neccospeak_rest_chat_stream_emit() refuse to stream ANY message on this
	// site — same silent-disable shape as the curl/NECCOSPEAK_DISABLE_SSE
	// cases above, so it is surfaced the same way here.
	$long_banned_word = function_exists( 'neccospeak_banned_words_list' )
		&& function_exists( 'neccospeak_sse_required_holdback_bytes' )
		&& defined( 'NECCOSPEAK_SSE_HOLDBACK_MAX' )
		&& neccospeak_sse_required_holdback_bytes( neccospeak_banned_words_list( $opts ) ) > NECCOSPEAK_SSE_HOLDBACK_MAX;
	$i2_notes         = array();
	if ( $sse_disabled ) {
		$i2_notes[] = __( 'Live streaming is force-disabled by the NECCOSPEAK_DISABLE_SSE constant.', 'neccospeak' );
	}
	if ( ! $has_curl ) {
		$i2_notes[] = __( 'Live streaming requires the curl PHP extension; Typewriter mode still works without it.', 'neccospeak' );
	}
	if ( $long_banned_word ) {
		$i2_notes[] = __( 'Live streaming disabled by a long blocked word: one entry under Safety → Blocked words is long enough that SSE cannot mask it safely, so live streaming falls back automatically; Typewriter mode and non-streaming replies still mask it normally.', 'neccospeak' );
	}
	$rows[] = array(
		'group'  => 'I',
		'label'  => __( 'SSE prerequisites', 'neccospeak' ),
		'state'  => ( $has_curl && ! $sse_disabled && ! $long_banned_word ) ? 'info' : 'warn',
		'value'  => sprintf(
			/* translators: 1: whether the curl extension is available (Yes/No), 2: zlib.output_compression php.ini value, 3: output_buffering php.ini value. */
			__( 'curl: %1$s. zlib.output_compression: %2$s. output_buffering: %3$s.', 'neccospeak' ),
			$has_curl ? __( 'Yes', 'neccospeak' ) : __( 'No', 'neccospeak' ),
			'' !== $zlib_compression ? $zlib_compression : __( 'off', 'neccospeak' ),
			'' !== $output_buffering ? $output_buffering : __( 'off', 'neccospeak' )
		),
		'note'   => implode( ' ', $i2_notes ),
		'action' => null,
	);

	// I3: Chat timeout headroom. Only (a) max_execution_time and (c)
	// chat_timeout_seconds can actually be read at runtime — a PHP-FPM pool's
	// request_terminate_timeout is not exposed to the script itself (§11.3).
	$budget         = defined( 'NECCOSPEAK_CHAT_UPSTREAM_BUDGET' ) ? (int) apply_filters( 'neccospeak_chat_upstream_budget', NECCOSPEAK_CHAT_UPSTREAM_BUDGET ) : 45;
	// +10s general slack, plus NECCOSPEAK_TOOL_EXEC_BUDGET_MS: a tool round's
	// own execution time is spent ON TOP OF $budget's upstream-call
	// accounting (it is never subtracted from $deadline), so it is real wall
	// time this headroom check must not ignore (review fix, §5.4).
	$tool_exec_s    = defined( 'NECCOSPEAK_TOOL_EXEC_BUDGET_MS' ) ? (int) ceil( NECCOSPEAK_TOOL_EXEC_BUDGET_MS / 1000 ) : 0;
	$headroom       = $budget + 10 + $tool_exec_s;
	$max_exec_raw   = (string) ini_get( 'max_execution_time' );
	$max_exec       = ( '' !== $max_exec_raw ) ? (int) $max_exec_raw : 0;
	$chat_timeout   = isset( $opts['chat_timeout_seconds'] ) ? (int) $opts['chat_timeout_seconds'] : 130;
	$i3_warn        = ( $max_exec > 0 && $max_exec < $headroom ) || ( $chat_timeout < $headroom );
	$rows[]         = array(
		'group'  => 'I',
		'label'  => __( 'Chat timeout headroom', 'neccospeak' ),
		'state'  => $i3_warn ? 'warn' : 'info',
		'value'  => sprintf(
			/* translators: 1: upstream request budget in seconds, 2: PHP max_execution_time in seconds, 3: browser-side chat timeout in seconds. */
			__( 'Upstream budget: %1$ss. PHP max_execution_time: %2$s. Browser timeout: %3$ss.', 'neccospeak' ),
			number_format_i18n( $budget ),
			$max_exec > 0 ? number_format_i18n( $max_exec ) . 's' : __( 'unlimited', 'neccospeak' ),
			number_format_i18n( $chat_timeout )
		),
		'note'   => ( $i3_warn ? __( 'One of these is close to or shorter than the upstream budget, which can cut a request off before it finishes.', 'neccospeak' ) . ' ' : '' )
			. __( 'A server-side request-terminate timeout (e.g. a PHP-FPM pool setting) cannot be detected here — the common default is 60 seconds.', 'neccospeak' ),
		'action' => array(
			'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'general' ), admin_url( 'admin.php' ) ),
			'label' => __( 'Open General tab', 'neccospeak' ),
		),
	);

	// I4: Content search engine.
	if ( function_exists( 'neccospeak_rag_engine' ) ) {
		$engine       = neccospeak_rag_engine();
		$rag_state    = function_exists( 'neccospeak_rag_state' ) ? neccospeak_rag_state() : array();
		$stale        = ! empty( $rag_state['stale'] );
		$engine_state = 'fulltext' === $engine ? 'info' : ( 'like' === $engine ? 'warn' : 'info' );
		$engine_label = array(
			'fulltext' => __( 'Full-text (ngram)', 'neccospeak' ),
			'like'     => __( 'Keyword fallback (LIKE)', 'neccospeak' ),
			'none'     => __( 'Not enabled', 'neccospeak' ),
		);
		$rows[]       = array(
			'group'  => 'I',
			'label'  => __( 'Content search engine', 'neccospeak' ),
			'state'  => $stale ? 'warn' : $engine_state,
			'value'  => isset( $engine_label[ $engine ] ) ? $engine_label[ $engine ] : $engine,
			'note'   => $stale ? __( 'The index needs to be rebuilt.', 'neccospeak' ) : '',
			'action' => array(
				'url'   => wp_nonce_url( admin_url( 'admin-post.php?action=neccospeak_rag_recheck' ), 'neccospeak_rag_recheck' ),
				'label' => __( 'Re-check', 'neccospeak' ),
			),
		);
	}

	// I5: Content index size.
	if ( function_exists( 'neccospeak_db_chunks_counts' ) ) {
		$counts     = neccospeak_db_chunks_counts();
		$chunk_n    = is_array( $counts ) && isset( $counts['chunks'] ) ? (int) $counts['chunks'] : 0;
		$engine_now = function_exists( 'neccospeak_rag_engine' ) ? neccospeak_rag_engine() : 'none';
		$warn_rows  = defined( 'NECCOSPEAK_RAG_LIKE_WARN_ROWS' ) ? (int) NECCOSPEAK_RAG_LIKE_WARN_ROWS : 5000;
		$i5_warn    = ( 'like' === $engine_now && $chunk_n > $warn_rows );
		$rows[]     = array(
			'group'  => 'I',
			'label'  => __( 'Content index size', 'neccospeak' ),
			'state'  => $i5_warn ? 'warn' : 'info',
			'value'  => number_format_i18n( $chunk_n ) . ' ' . __( 'chunks', 'neccospeak' ),
			'note'   => $i5_warn ? __( 'A large index on the keyword fallback engine can become slow to search.', 'neccospeak' ) : '',
			'action' => array( 'url' => $content_url, 'label' => __( 'Open Content tab', 'neccospeak' ) ),
		);
	}

	// I6: Tools enabled.
	if ( function_exists( 'neccospeak_tools_names' ) ) {
		$tool_names   = neccospeak_tools_names( $opts );
		$tools_active = ! empty( $opts['tools_enabled'] ) && ! empty( $tool_names );
		$per_ip       = isset( $opts['rate_limit_per_min'] ) ? (int) $opts['rate_limit_per_min'] : 10;
		$note         = '';
		if ( $tools_active ) {
			$note = sprintf(
				/* translators: %d: the site's current per-minute per-IP rate limit. */
				__( 'When a tool is used, one message is counted as up to 3 requests against your per-IP limit (currently %d/min) — effectively about a third the usual number of conversations.', 'neccospeak' ),
				$per_ip
			);
		}
		$i6_warn = $tools_active && 'sse' === ( isset( $opts['stream_mode'] ) ? $opts['stream_mode'] : 'off' );
		$rows[]  = array(
			'group'  => 'I',
			'label'  => __( 'Tools enabled', 'neccospeak' ),
			'state'  => $i6_warn ? 'warn' : 'info',
			'value'  => $tools_active ? implode( ', ', $tool_names ) : __( 'None', 'neccospeak' ),
			'note'   => $i6_warn ? __( 'Live streaming is not used while tools are enabled — replies fall back to Typewriter mode automatically.', 'neccospeak' ) : $note,
			'action' => array( 'url' => $content_url, 'label' => __( 'Open Content tab', 'neccospeak' ) ),
		);
	}

	// I7: Outbound host allow-list.
	if ( function_exists( 'neccospeak_http_allowed_hosts' ) ) {
		$hosts        = neccospeak_http_allowed_hosts();
		$default_host = array( 'api.openai.com', is_string( wp_parse_url( home_url(), PHP_URL_HOST ) ) ? strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) : '' );
		$extra        = array_diff( $hosts, $default_host );
		$diag         = function_exists( 'neccospeak_http_diag' ) ? neccospeak_http_diag() : array();
		$note         = '';
		if ( ! empty( $diag['same_host_exempt'] ) ) {
			$note = __( "This site's own host resolves to a private address (Docker / reverse proxy / intranet); requests to it are allowed by the same-host exception.", 'neccospeak' );
		}
		$rows[] = array(
			'group'  => 'I',
			'label'  => __( 'Outbound host allow-list', 'neccospeak' ),
			'state'  => empty( $extra ) ? 'info' : 'warn',
			'value'  => implode( ', ', $hosts ),
			'note'   => $note,
			'action' => null,
		);
	}

	// I8: Last outbound request error.
	if ( function_exists( 'neccospeak_http_diag' ) ) {
		$diag       = neccospeak_http_diag();
		$last_error = is_array( $diag ) && isset( $diag['last_error'] ) ? (string) $diag['last_error'] : '';
		$rows[]     = array(
			'group'  => 'I',
			'label'  => __( 'Last outbound request error', 'neccospeak' ),
			'state'  => 'info',
			'value'  => '' !== $last_error ? $last_error : __( 'None', 'neccospeak' ),
			'note'   => '',
			'action' => null,
		);
	}

	// I9: Contact Form 7.
	if ( function_exists( 'neccospeak_cf7_active' ) ) {
		$cf7_active = neccospeak_cf7_active();
		$cf7_value  = $cf7_active
			? ( defined( 'WPCF7_VERSION' ) ? sprintf( /* translators: %s: Contact Form 7 plugin version. */ __( 'Active (%s)', 'neccospeak' ), WPCF7_VERSION ) : __( 'Active', 'neccospeak' ) )
			: __( 'Not installed', 'neccospeak' );
		$rows[]     = array(
			'group'  => 'I',
			'label'  => __( 'Contact Form 7', 'neccospeak' ),
			'state'  => ( ! $cf7_active && ! empty( $opts['cf7_prefill_enabled'] ) ) ? 'warn' : 'info',
			'value'  => $cf7_value,
			'note'   => ( ! $cf7_active && ! empty( $opts['cf7_prefill_enabled'] ) ) ? __( 'Prefill is enabled but Contact Form 7 is not active — that action is hidden until it is.', 'neccospeak' ) : '',
			'action' => array(
				'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'actions' ), admin_url( 'admin.php' ) ),
				'label' => __( 'Open Actions tab', 'neccospeak' ),
			),
		);
	}

	// I10: mbstring for indexing.
	$has_mb_substr = function_exists( 'mb_substr' );
	$rows[]        = array(
		'group'  => 'I',
		'label'  => __( 'mbstring for indexing', 'neccospeak' ),
		'state'  => $has_mb_substr ? 'info' : 'info',
		'value'  => $has_mb_substr ? __( 'Available', 'neccospeak' ) : __( 'Missing', 'neccospeak' ),
		'note'   => $has_mb_substr ? '' : __( 'Content is split into search chunks at byte boundaries instead of character boundaries; everything still works.', 'neccospeak' ),
		'action' => null,
	);

	// I11: Database schema.
	if ( defined( 'NECCOSPEAK_DB_VERSION' ) ) {
		$db_version_option = 'neccospeak_db_version';
		$current_version   = get_option( $db_version_option, '' );
		$db_up_to_date     = ( (string) $current_version === (string) NECCOSPEAK_DB_VERSION );
		$chunks_ready      = function_exists( 'neccospeak_db_chunks_ready' ) ? neccospeak_db_chunks_ready() : true;
		$state             = ! $db_up_to_date ? 'error' : ( ! $chunks_ready ? 'warn' : 'info' );
		$note              = '';
		if ( ! $db_up_to_date ) {
			$note = __( 'Phase 5 features (usage, budget, logs) are paused until the schema updates. Reload this page to retry.', 'neccospeak' );
		} elseif ( ! $chunks_ready ) {
			$note = __( 'The content index table could not be created — site content search is unavailable, everything else works.', 'neccospeak' );
		}
		$rows[] = array(
			'group'  => 'I',
			'label'  => __( 'Database schema', 'neccospeak' ),
			'state'  => $state,
			'value'  => $db_up_to_date ? __( 'Up to date', 'neccospeak' ) : __( 'Needs an update', 'neccospeak' ),
			'note'   => $note,
			'action' => null,
		);
	}

	// I12: Usage records (estimated token counts from live streaming without
	// a usage chunk, §7.3.7). Best-effort: reads via the allow-listed table
	// name accessor (INV-4) and suppresses SQL errors so a schema that
	// predates this column (or is mid-upgrade) never fatals this tab.
	if ( function_exists( 'neccospeak_db_table' ) && function_exists( 'neccospeak_db_ready' ) && neccospeak_db_ready() ) {
		global $wpdb;
		$usage_table = neccospeak_db_table( 'usage_daily' );
		if ( $usage_table && is_object( $wpdb ) && method_exists( $wpdb, 'get_var' ) ) {
			$wpdb->suppress_errors( true );
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $usage_table comes only from neccospeak_db_table()'s fixed allow-list, never user input (INV-4).
			$estimated = (int) $wpdb->get_var( "SELECT SUM(estimated_requests) FROM {$usage_table}" );
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- same allow-listed table name.
			$total = (int) $wpdb->get_var( "SELECT SUM(requests) FROM {$usage_table}" );
			$wpdb->suppress_errors( false );
			if ( $estimated > 0 ) {
				$rows[] = array(
					'group'  => 'I',
					'label'  => __( 'Usage records', 'neccospeak' ),
					'state'  => 'info',
					'value'  => sprintf(
						/* translators: 1: number of usage records with an estimated (not exact) token count, 2: total number of usage records. */
						__( '%1$s of %2$s requests were recorded with estimated token counts.', 'neccospeak' ),
						number_format_i18n( $estimated ),
						number_format_i18n( max( $total, $estimated ) )
					),
					'note'   => __( 'This happens with live streaming when the AI service does not report a token count directly.', 'neccospeak' ),
					'action' => array(
						'url'   => add_query_arg( array( 'page' => NECCOSPEAK_SETTINGS_SLUG, 'tab' => 'usage' ), admin_url( 'admin.php' ) ),
						'label' => __( 'Open Usage tab', 'neccospeak' ),
					),
				);
			}
		}
	}
}

