<?php
/**
 * NeccoSpeak Phase 5 data layer (design phase5 §2/§3).
 *
 * Owns the plugin's three custom tables (events / usage_daily / versions),
 * their lifecycle (install/upgrade/uninstall, multisite),
 * pruning (batched + locked), the price table / cost calculator, and the
 * `neccospeak_runtime` circuit-breaker state.
 *
 * Every write API returns a safe default (0/false) when
 * neccospeak_db_ready() is false (INV-6). Every SELECT/DELETE this file
 * issues carries a LIMIT; every DELETE also carries ORDER BY id ASC
 * (INV-4/§8.1). All values are bound via $wpdb->prepare() (INV-5).
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/* -----------------------------------------------------------------------
 * §3.0 constants
 * -------------------------------------------------------------------- */

if ( ! defined( 'NECCOSPEAK_DB_VERSION' ) ) {
	// Phase 6 (design phase6 §0.4/§6.1, D3): '1' -> '2' for the new `chunks`
	// table (G26). `chunks` is deliberately NOT a required table (see
	// neccospeak_db_required_tables() below) so a host that refuses CREATE
	// TABLE for it never takes Phase 5's tables down with it.
	//
	// 2026-08 (判断事項A): '2' -> '3' for the removal of the `conversations`
	// table (会話ログ機能撤去). The version bump drives neccospeak_db_install()'s
	// best-effort DROP of the now-legacy table (neccospeak_db_legacy_tables())
	// on every upgraded site, not just on a full uninstall.
	define( 'NECCOSPEAK_DB_VERSION', '3' );
}
if ( ! defined( 'NECCOSPEAK_DB_VERSION_OPTION' ) ) {
	define( 'NECCOSPEAK_DB_VERSION_OPTION', 'neccospeak_db_version' );
}
if ( ! defined( 'NECCOSPEAK_RUNTIME_OPTION' ) ) {
	define( 'NECCOSPEAK_RUNTIME_OPTION', 'neccospeak_runtime' );
}
if ( ! defined( 'NECCOSPEAK_CRON_HOOK' ) ) {
	define( 'NECCOSPEAK_CRON_HOOK', 'neccospeak_daily_maintenance' );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_LOCK' ) ) {
	define( 'NECCOSPEAK_PRUNE_LOCK', 'neccospeak_prune_lock' );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_LAST_OPTION' ) ) {
	define( 'NECCOSPEAK_PRUNE_LAST_OPTION', 'neccospeak_prune_last' );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_BATCH' ) ) {
	define( 'NECCOSPEAK_PRUNE_BATCH', 500 );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_MAX_PASSES' ) ) {
	define( 'NECCOSPEAK_PRUNE_MAX_PASSES', 20 );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_ADMIN_BATCH' ) ) {
	define( 'NECCOSPEAK_PRUNE_ADMIN_BATCH', 200 );
}
if ( ! defined( 'NECCOSPEAK_PRUNE_ADMIN_INTERVAL' ) ) {
	define( 'NECCOSPEAK_PRUNE_ADMIN_INTERVAL', 12 * HOUR_IN_SECONDS );
}
if ( ! defined( 'NECCOSPEAK_VERSIONS_KEEP' ) ) {
	define( 'NECCOSPEAK_VERSIONS_KEEP', 30 );
}
// 2026-08 撤去記録: ここにあった NECCOSPEAK_CONV_MAX_BYTES 定数
// (neccospeak_db_add_conversation() の user_text/assistant_text クリップ用)
// は、会話ログ機能ごと削除した。
if ( ! defined( 'NECCOSPEAK_AUDIT_MAX_BYTES' ) ) {
	define( 'NECCOSPEAK_AUDIT_MAX_BYTES', 16000 );
}
if ( ! defined( 'NECCOSPEAK_EVENTS_BATCH_MAX' ) ) {
	define( 'NECCOSPEAK_EVENTS_BATCH_MAX', 20 );
}
if ( ! defined( 'NECCOSPEAK_USAGE_RETENTION_DEFAULT' ) ) {
	define( 'NECCOSPEAK_USAGE_RETENTION_DEFAULT', 400 );
}
if ( ! defined( 'NECCOSPEAK_MAX_PAGE_SIZE' ) ) {
	define( 'NECCOSPEAK_MAX_PAGE_SIZE', 100 );
}
if ( ! defined( 'NECCOSPEAK_MAX_AGG_ROWS' ) ) {
	define( 'NECCOSPEAK_MAX_AGG_ROWS', 400 );
}

// ---- Phase 5: budget MTD sync throttling (INV-1) ----
if ( ! defined( 'NECCOSPEAK_BUDGET_SYNC_INTERVAL' ) ) {
	define( 'NECCOSPEAK_BUDGET_SYNC_INTERVAL', 300 );
}
if ( ! defined( 'NECCOSPEAK_BUDGET_SYNC_HOT' ) ) {
	define( 'NECCOSPEAK_BUDGET_SYNC_HOT', 60 );
}
// ---- Phase 5: voice self-report acceptance caps (INV-12) ----
// 20,000 (not the old 200,000): a realistic average realtime-voice session is
// a few minutes of audio, not the NECCOSPEAK_VOICE_REPORT_SESSION_CAP-sized
// (400,000-token) outlier that per-request clip exists to tolerate. This
// value sizes neccospeak_voice_report_daily_cap()'s DAILY total, which
// /usage-report never verifies against actual /realtime-token issuance
// (INV-12: the report is an anonymous, unauthenticated self-report) — an
// attacker who never opens a single real voice session can still submit
// reports up to `realtime_global_daily * this` per day. At the old 200,000
// that was 40,000,000 tokens/day at the option default (200); an attacker
// could pollute the Usage tab's numbers and, with
// budget_include_voice + budget_action=pause_voice/pause_all configured,
// trigger neccospeak_runtime_pause() without ever placing a real call
// (review: "medium" — self-reported voice usage effectively unbounded).
// 20,000 keeps the default daily acceptance at 4,000,000 tokens — still
// generous headroom for genuine traffic, an order of magnitude less
// exploitable.
if ( ! defined( 'NECCOSPEAK_VOICE_TOKENS_PER_SESSION' ) ) {
	define( 'NECCOSPEAK_VOICE_TOKENS_PER_SESSION', 20000 );
}
if ( ! defined( 'NECCOSPEAK_VOICE_REPORT_SESSION_CAP' ) ) {
	define( 'NECCOSPEAK_VOICE_REPORT_SESSION_CAP', 400000 );
}
// ---- Phase 5: analytics events daily cap (§6.3) ----
if ( ! defined( 'NECCOSPEAK_EVENTS_PER_SESSION' ) ) {
	define( 'NECCOSPEAK_EVENTS_PER_SESSION', 20 );
}

// ---- Phase 6 (design phase6 §6.1/§6.4, G26): `chunks` table support ----
if ( ! defined( 'NECCOSPEAK_DB_CHUNKS_READY_TRANSIENT' ) ) {
	define( 'NECCOSPEAK_DB_CHUNKS_READY_TRANSIENT', 'neccospeak_db_chunks_ready' );
}
if ( ! defined( 'NECCOSPEAK_DB_CHUNKS_READY_TTL' ) ) {
	define( 'NECCOSPEAK_DB_CHUNKS_READY_TTL', 12 * HOUR_IN_SECONDS );
}
// Generic safety-valve LIMIT for chunk queries scoped to a single post. A
// post's chunk count is capped far below this at the rag.php layer
// (NECCOSPEAK_RAG_MAX_CHUNKS_PER_POST = 40); this just keeps every
// per-post SELECT/DELETE bounded (INV-4) even if that cap is ever changed.
if ( ! defined( 'NECCOSPEAK_DB_CHUNKS_PER_POST_LIMIT' ) ) {
	define( 'NECCOSPEAK_DB_CHUNKS_PER_POST_LIMIT', 500 );
}
if ( ! defined( 'NECCOSPEAK_DB_CHUNKS_DELETE_ALL_BATCH' ) ) {
	define( 'NECCOSPEAK_DB_CHUNKS_DELETE_ALL_BATCH', 500 );
}
if ( ! defined( 'NECCOSPEAK_DB_SEEN_VERSION_OPTION' ) ) {
	define( 'NECCOSPEAK_DB_SEEN_VERSION_OPTION', 'neccospeak_db_seen_version' );
}

/* -----------------------------------------------------------------------
 * §3.1 Schema / lifecycle
 * -------------------------------------------------------------------- */

/**
 * Map a short dataset name to its full, prefixed table name.
 *
 * @param string $name 'events'|'usage_daily'|'versions'|'chunks'.
 * @return string Full table name, or '' when unknown / $wpdb unavailable.
 */
function neccospeak_db_table( $name ) {
	global $wpdb;
	// Phase 6 (design phase6 §2.4 D1/§6.1): 'chunks' (G26) added to the
	// known-name allow-list. neccospeak_db_tables() / neccospeak_db_uninstall()
	// both iterate this list, so `chunks` is picked up by both automatically.
	// 2026-08 撤去記録: 'conversations' はこの allow-list から削除した(会話ログ
	// 機能撤去)。アップグレード済みサイトに残り得るテーブル自体の DROP は
	// neccospeak_db_legacy_tables() が別途担う。
	$known = array( 'events', 'usage_daily', 'versions', 'chunks' );
	if ( ! in_array( $name, $known, true ) ) {
		return '';
	}
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->prefix ) ) {
		return '';
	}
	return $wpdb->prefix . 'neccospeak_' . $name;
}

/**
 * @return string[] Full names of all four tables (design phase6 DB-2).
 */
function neccospeak_db_tables() {
	$out = array();
	foreach ( array( 'events', 'usage_daily', 'versions', 'chunks' ) as $name ) {
		$table = neccospeak_db_table( $name );
		if ( '' !== $table ) {
			$out[] = $table;
		}
	}
	return $out;
}

/**
 * Tables whose absence means the schema is NOT installed (design phase6
 * §6.1, DB-7).
 *
 * `chunks` is deliberately NOT in this list. It backs one default-off
 * feature (G26); a host that refuses CREATE TABLE for it must not take the
 * Phase 5 tables — and with them usage accounting and the budget circuit
 * breaker — down with it (§0.4).
 *
 * @return string[]
 */
function neccospeak_db_required_tables() {
	$all      = neccospeak_db_tables();
	$optional = array( neccospeak_db_table( 'chunks' ) );
	return array_values( array_diff( $all, $optional ) );
}

/**
 * dbDelta-ready CREATE TABLE statements (design phase5 §2.3).
 *
 * @return array<string,string> Keyed by short dataset name.
 */
function neccospeak_db_schema_sql() {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_charset_collate' ) ) {
		return array();
	}
	$cc = $wpdb->get_charset_collate();
	$p  = $wpdb->prefix;

	$sql = array();

	$sql['events'] = "CREATE TABLE {$p}neccospeak_events (
  id bigint unsigned NOT NULL auto_increment,
  event_type varchar(16) NOT NULL default '',
  event_name varchar(32) NOT NULL default '',
  created_at datetime NOT NULL,
  day_utc date NOT NULL,
  session_id varchar(32) NOT NULL default '',
  user_id bigint unsigned NOT NULL default 0,
  ref_id bigint unsigned NOT NULL default 0,
  num int NOT NULL default 0,
  subject varchar(64) NOT NULL default '',
  content longtext NULL,
  PRIMARY KEY  (id),
  KEY type_id (event_type,id),
  KEY type_day (event_type,day_utc),
  KEY sess (session_id,id)
) $cc;";

	// 2026-08 撤去記録: ここにあった $sql['conversations'] (会話ログテーブルの
	// CREATE TABLE 定義)は、会話ログ機能ごと削除した。アップグレード済みサイトの
	// 既存テーブルは neccospeak_db_legacy_tables() 経由で best-effort DROP する。
	$sql['usage_daily'] = "CREATE TABLE {$p}neccospeak_usage_daily (
  id bigint unsigned NOT NULL auto_increment,
  day_utc date NOT NULL,
  kind varchar(8) NOT NULL default 'chat',
  model varchar(64) NOT NULL default '',
  requests int unsigned NOT NULL default 0,
  prompt_tokens bigint unsigned NOT NULL default 0,
  completion_tokens bigint unsigned NOT NULL default 0,
  audio_in_tokens bigint unsigned NOT NULL default 0,
  audio_out_tokens bigint unsigned NOT NULL default 0,
  cached_in_tokens bigint unsigned NOT NULL default 0,
  cost_micro bigint unsigned NOT NULL default 0,
  estimated_requests int unsigned NOT NULL default 0,
  updated_at datetime NOT NULL,
  PRIMARY KEY  (id),
  UNIQUE KEY day_kind_model (day_utc,kind,model)
) $cc;";

	$sql['versions'] = "CREATE TABLE {$p}neccospeak_versions (
  id bigint unsigned NOT NULL auto_increment,
  created_at datetime NOT NULL,
  user_id bigint unsigned NOT NULL default 0,
  user_login varchar(60) NOT NULL default '',
  changed_count int NOT NULL default 0,
  origin varchar(16) NOT NULL default 'save',
  note varchar(190) NOT NULL default '',
  changed_keys text NULL,
  snapshot longtext NULL,
  PRIMARY KEY  (id),
  KEY created (created_at)
) $cc;";

	// Phase 6 (design phase6 §6.1, G26): chunked, indexable copies of
	// published post/page content for keyword-search RAG. FULLTEXT is
	// deliberately NOT part of this CREATE statement (rule 8): dbDelta()
	// cannot parse `WITH PARSER ngram` and would try to re-issue the ALTER on
	// every request. It is added out-of-band, once, by
	// neccospeak_db_chunks_add_fulltext_index() right after install (§6.1/§6.4).
	$sql['chunks'] = "CREATE TABLE {$p}neccospeak_chunks (
  id bigint unsigned NOT NULL auto_increment,
  post_id bigint unsigned NOT NULL default 0,
  chunk_no int unsigned NOT NULL default 0,
  updated_at datetime NOT NULL,
  content_hash char(32) NOT NULL default '',
  chunk_bytes int unsigned NOT NULL default 0,
  content mediumtext NULL,
  search_text mediumtext NULL,
  PRIMARY KEY  (id),
  UNIQUE KEY post_chunk (post_id,chunk_no),
  KEY updated (updated_at)
) $cc;";

	return $sql;
}

/**
 * Tables that once belonged to a removed feature and no longer appear in
 * neccospeak_db_tables()/neccospeak_db_schema_sql(), but may still exist on
 * a site that upgraded from an older version. neccospeak_db_install() DROPs
 * these best-effort on every upgrade (判断事項A), and neccospeak_db_uninstall()
 * also folds them into its own DROP loop so a full uninstall still cleans
 * them up even though they are no longer in neccospeak_db_tables().
 *
 * 2026-08 撤去記録: 'conversations' (会話ログ機能撤去) が現状唯一のエントリ。
 *
 * @return string[] Full (prefixed) table names.
 */
function neccospeak_db_legacy_tables() {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->prefix ) ) {
		return array();
	}
	return array( $wpdb->prefix . 'neccospeak_conversations' );
}

/**
 * Run dbDelta() against all tables and verify creation of the REQUIRED
 * ones via SHOW TABLES before persisting the schema version (design phase5
 * §3.1 / INV-6, phase6 §6.1 D2/D9/D13).
 *
 * Phase 6: `chunks` (G26) is intentionally excluded from the required-table
 * check (neccospeak_db_required_tables() — DB-7) so a host that refuses
 * CREATE TABLE for it still gets a working Phase 5 (usage accounting, the
 * budget circuit breaker, ...). Everything past the
 * version write is best-effort and never flips the return value to false
 * (DB-8/DB-9) — this now also covers the 2026-08 (判断事項A) legacy-table
 * DROP below.
 *
 * Order is load-bearing (§6.1's "confirmed order", DB-10):
 *   dbDelta(all tables) -> SHOW TABLES on required tables only ->
 *   update_option(DB_VERSION) -> delete_transient(chunks_ready) ->
 *   chunks_ready() -> add_fulltext_index() -> detect_engine(true)
 * detect_engine() (rag.php) treats "no engine option yet" as needing
 * db_ready()-like signal via chunks_ready(), not neccospeak_db_ready(); if
 * the version were written AFTER this block, a first-ever install would
 * always detect with a version option that doesn't exist yet. More
 * importantly, writing the version LAST (as Phase 5 did) with `chunks`
 * missing from the required list would mean an install with a broken
 * `chunks` table never records a schema version at all — that's fine
 * because it's the earlier SHOW TABLES loop (required tables only) that
 * decides success/failure now, not this chunks best-effort tail.
 *
 * @return bool True only when every required table was confirmed to exist.
 */
function neccospeak_db_install() {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return false;
	}
	if ( ! function_exists( 'dbDelta' ) ) {
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
	}
	if ( ! function_exists( 'dbDelta' ) ) {
		return false;
	}

	$schema = neccospeak_db_schema_sql();
	if ( empty( $schema ) ) {
		return false;
	}
	foreach ( $schema as $create_sql ) {
		dbDelta( $create_sql );
	}

	foreach ( neccospeak_db_required_tables() as $table ) {
		$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', array( $table ) ) );
		if ( $found !== $table ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- intentional diagnostic, WP_DEBUG only (design §3.1).
				error_log( sprintf( 'NeccoSpeak: table %s missing after dbDelta().', $table ) );
			}
			return false;
		}
	}

	update_option( NECCOSPEAK_DB_VERSION_OPTION, NECCOSPEAK_DB_VERSION, true );

	// ---- 2026-08 (判断事項A, DB_VERSION '2'->'3'): best-effort legacy-table
	// cleanup for the removed 会話ログ機能. Never affects the return value
	// below — a host without DROP privilege must not have this mistaken for
	// Phase 5 itself failing (same reasoning as the chunks block right below).
	// This also runs on every plain upgrade (not just full uninstall), so an
	// already-upgraded site reclaims the table's space immediately instead of
	// waiting for the plugin to be deleted. ----
	foreach ( neccospeak_db_legacy_tables() as $legacy_table ) {
		// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name derived from $wpdb->prefix; DROP TABLE cannot be parameterized.
		$wpdb->query( 'DROP TABLE IF EXISTS `' . str_replace( '`', '', $legacy_table ) . '`' );
	}

	// ---- Phase 6 (design phase6 §6.1, G26): best-effort chunks setup ----
	// Never affects the return value below; `chunks`/FULLTEXT/engine
	// detection failing here must not be mistaken for Phase 5 failing.
	delete_transient( NECCOSPEAK_DB_CHUNKS_READY_TRANSIENT );
	if ( function_exists( 'neccospeak_db_chunks_ready' ) && neccospeak_db_chunks_ready() ) {
		neccospeak_db_chunks_add_fulltext_index();
		// neccospeak_rag_detect_engine() lives in includes/rag.php, which may
		// not be loaded when db.php is used standalone (design §1.3 rule 1 —
		// callers must function_exists()-guard every cross-file call).
		if ( function_exists( 'neccospeak_rag_detect_engine' ) ) {
			neccospeak_rag_detect_engine( true ); // §6.4 trigger (a)
		}
	}

	return true;
}

/**
 * Install/upgrade only when the stored schema version differs.
 *
 * @return bool
 */
function neccospeak_db_maybe_upgrade() {
	if ( NECCOSPEAK_DB_VERSION === (string) get_option( NECCOSPEAK_DB_VERSION_OPTION, '' ) ) {
		return true;
	}
	return neccospeak_db_install();
}

/**
 * Retry interval (seconds) for neccospeak_db_maybe_install_deferred()
 * (design phase6 §0.4/§6.1 D15). The very first request to see a schema
 * version change tries immediately (once); every later miss falls back to
 * the original once-a-day cadence. This shortens the "Phase 5 features are
 * silently paused because db_ready() is false" upgrade window (§0.4) for
 * sites that never open wp-admin.
 *
 * @param bool $first True on the request that first observes the new
 *                     NECCOSPEAK_DB_VERSION.
 * @return int Seconds.
 */
function neccospeak_db_install_backoff( $first ) {
	return $first ? 0 : DAY_IN_SECONDS;
}

/**
 * Front-end safety net for subsites nobody ever opens wp-admin on
 * (design phase5 §3.1 / §11, phase6 §0.4/§6.1). At most one install attempt
 * per site per day, except the very first request after a schema version
 * bump, which installs immediately.
 *
 * @return void
 */
function neccospeak_db_maybe_install_deferred() {
	if ( neccospeak_db_ready() ) {
		return;
	}
	if ( function_exists( 'is_admin' ) && is_admin() ) {
		return;
	}
	if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
		return;
	}
	if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
		return;
	}

	$seen  = (string) get_option( NECCOSPEAK_DB_SEEN_VERSION_OPTION, '' );
	$first = ( $seen !== NECCOSPEAK_DB_VERSION );
	if ( $first ) {
		update_option( NECCOSPEAK_DB_SEEN_VERSION_OPTION, NECCOSPEAK_DB_VERSION, false );
		neccospeak_db_install(); // ★ immediate, once per version bump.
		return;
	}

	if ( false !== get_transient( 'neccospeak_install_try' ) ) {
		return;
	}
	set_transient( 'neccospeak_install_try', 1, neccospeak_db_install_backoff( false ) );
	neccospeak_db_install();
}

/**
 * register_activation_hook callback. Installs on the current site only;
 * never loops the network even when $network_wide is true (design §3.1).
 *
 * @param bool $network_wide Unused; kept for hook signature compatibility.
 * @return void
 */
function neccospeak_db_activate( $network_wide = false ) {
	neccospeak_db_install();
	neccospeak_db_schedule_cron();
}

/**
 * wp_initialize_site callback: create tables for a brand-new site when the
 * plugin is network-active (design §3.1 / §11).
 *
 * @param mixed $new_site WP_Site object or a raw site id.
 * @return void
 */
function neccospeak_db_new_site( $new_site ) {
	if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
	}
	if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
		return;
	}

	$plugin_file = function_exists( 'plugin_basename' )
		? plugin_basename( NECCOSPEAK_DIR . 'neccospeak.php' )
		: 'neccospeak/neccospeak.php';
	if ( ! is_plugin_active_for_network( $plugin_file ) ) {
		return;
	}

	$site_id = ( is_object( $new_site ) && isset( $new_site->blog_id ) ) ? (int) $new_site->blog_id : (int) $new_site;
	if ( $site_id <= 0 ) {
		return;
	}
	if ( ! function_exists( 'switch_to_blog' ) || ! function_exists( 'restore_current_blog' ) ) {
		return;
	}

	switch_to_blog( $site_id );
	neccospeak_db_install();
	restore_current_blog();
}

/**
 * register_deactivation_hook callback. Tables are intentionally left in
 * place; only the cron hook is cleared.
 *
 * @return void
 */
function neccospeak_db_deactivate() {
	if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
		wp_clear_scheduled_hook( NECCOSPEAK_CRON_HOOK );
	}
}

/**
 * Cheap readiness check: a single autoloaded option read, no SHOW TABLES
 * (INV-6).
 *
 * @return bool
 */
function neccospeak_db_ready() {
	return NECCOSPEAK_DB_VERSION === (string) get_option( NECCOSPEAK_DB_VERSION_OPTION, '' );
}

/**
 * Single-site full teardown: drop tables, delete the Phase 5 options,
 * clear cron. Called by uninstall.php (design §3.1 / §11).
 *
 * @return void
 */
function neccospeak_db_uninstall() {
	global $wpdb;
	if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'query' ) ) {
		// 2026-08 撤去記録: neccospeak_db_legacy_tables() (現状 'conversations' の
		// み) をこのループに合流させた — 会話ログ機能撤去後は neccospeak_db_tables()
		// がその名を返さなくなるため、合流させないと full uninstall がテーブルを
		// 残してしまう。
		foreach ( array_merge( neccospeak_db_tables(), neccospeak_db_legacy_tables() ) as $table ) {
			// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name derived from $wpdb->prefix; DROP TABLE cannot be parameterized.
			$wpdb->query( 'DROP TABLE IF EXISTS `' . str_replace( '`', '', $table ) . '`' );
		}
	}

	delete_option( NECCOSPEAK_DB_VERSION_OPTION );
	delete_option( NECCOSPEAK_RUNTIME_OPTION );
	delete_option( NECCOSPEAK_PRUNE_LAST_OPTION );
	delete_option( 'neccospeak_options_draft' );

	// Phase 6 (design phase6 §14.2, G26): kept in sync with uninstall.php's
	// own fallback branch so the delegated and fallback paths behave the
	// same regardless of which one a given uninstall run takes.
	delete_option( 'neccospeak_rag_engine' );
	delete_option( 'neccospeak_rag_state' );
	delete_option( 'neccospeak_http_last_error' );
	delete_option( NECCOSPEAK_DB_SEEN_VERSION_OPTION );

	if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
		wp_clear_scheduled_hook( NECCOSPEAK_CRON_HOOK );
	}
}

/* -----------------------------------------------------------------------
 * Phase 6 (design phase6 §2.4/§6, G26) — `chunks` table data access.
 *
 * Every function here gates on neccospeak_db_chunks_ready() (D13), NOT
 * neccospeak_db_ready() (§0.4): `chunks` is an optional table, so its
 * readiness is tracked independently of the Phase 5 schema-version check.
 * Every SELECT/DELETE carries a LIMIT; every DELETE also carries
 * ORDER BY id ASC (INV-4). Table/index names are never bindable via
 * prepare(); they are always derived from $wpdb->prefix or a fixed literal
 * and backtick-stripped before being spliced into SQL.
 * -------------------------------------------------------------------- */

/**
 * Whether the `chunks` table exists, transient-cached for
 * NECCOSPEAK_DB_CHUNKS_READY_TTL (design phase6 §2.4 D13).
 *
 * Deliberately independent of neccospeak_db_ready() (§0.4): `chunks` can be
 * missing (no CREATE TABLE permission, or dbDelta hasn't run yet) while the
 * three Phase 5 tables are perfectly healthy, and vice versa during the
 * install window.
 *
 * @return bool
 */
function neccospeak_db_chunks_ready() {
	// '1'/'0' string sentinels (not a bare bool) so a cached-false result is
	// distinguishable from "never cached" — get_transient() returns false
	// for both, and WordPress core option/transient APIs offer no other way
	// to tell them apart.
	$cached = get_transient( NECCOSPEAK_DB_CHUNKS_READY_TRANSIENT );
	if ( '1' === $cached || '0' === $cached ) {
		return ( '1' === $cached );
	}

	global $wpdb;
	$table = neccospeak_db_table( 'chunks' );
	if ( '' === $table || ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'get_var' ) ) {
		return false; // Not cached: a real $wpdb showing up later gets a fresh check.
	}

	$found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', array( $table ) ) );
	$ready = ( $found === $table );
	set_transient( NECCOSPEAK_DB_CHUNKS_READY_TRANSIENT, $ready ? '1' : '0', NECCOSPEAK_DB_CHUNKS_READY_TTL );
	return $ready;
}

/**
 * Normalize one chunk's raw row data into the shape neccospeak_rag_search()
 * (rag.php) and the two search_* functions below share (design §6.5).
 *
 * @param mixed $rows Result of a $wpdb->get_results() call.
 * @return array<int,array{post_id:int,chunk_no:int,content:string,score:float}>
 */
function neccospeak_db_chunks_normalize_rows( $rows ) {
	$out = array();
	foreach ( (array) $rows as $row ) {
		$row  = (array) $row;
		$out[] = array(
			'post_id'  => isset( $row['post_id'] ) ? (int) $row['post_id'] : 0,
			'chunk_no' => isset( $row['chunk_no'] ) ? (int) $row['chunk_no'] : 0,
			'content'  => isset( $row['content'] ) ? (string) $row['content'] : '',
			'score'    => isset( $row['score'] ) ? (float) $row['score'] : 0.0,
		);
	}
	return $out;
}

/**
 * Differential UPSERT of one post's chunks (design phase6 §2.4 D4). Chunks
 * whose content_hash is unchanged are left untouched; changed/new chunk_no
 * values are UPDATEd/INSERTed; chunk_no values beyond the new chunk count
 * are DELETEd.
 *
 * @param int   $post_id
 * @param array $chunks List of {content:string, search_text:string}, in
 *                       final chunk_no order (index 0 = chunk_no 0, ...).
 * @return int Number of rows written (INSERT + UPDATE only — unchanged
 *             chunks and the trailing DELETE are not counted).
 */
function neccospeak_db_chunks_replace( $post_id, array $chunks ) {
	global $wpdb;
	$post_id = (int) $post_id;
	if ( $post_id <= 0 || ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}

	$table   = neccospeak_db_table( 'chunks' );
	$chunks  = array_values( $chunks );
	$now     = neccospeak_db_now();

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$existing_rows = $wpdb->get_results( $wpdb->prepare(
		"SELECT chunk_no, content_hash FROM {$table} WHERE post_id = %d ORDER BY chunk_no ASC LIMIT %d",
		array( $post_id, NECCOSPEAK_DB_CHUNKS_PER_POST_LIMIT )
	) );
	$existing_hash = array();
	foreach ( (array) $existing_rows as $row ) {
		$row                                    = (array) $row;
		$existing_hash[ (int) $row['chunk_no'] ] = isset( $row['content_hash'] ) ? (string) $row['content_hash'] : '';
	}

	$written = 0;
	foreach ( $chunks as $chunk_no => $chunk ) {
		$content     = ( is_array( $chunk ) && isset( $chunk['content'] ) ) ? (string) $chunk['content'] : '';
		$search_text = ( is_array( $chunk ) && isset( $chunk['search_text'] ) ) ? (string) $chunk['search_text'] : '';
		$hash        = md5( $content );
		$bytes       = strlen( $content );

		if ( isset( $existing_hash[ $chunk_no ] ) ) {
			if ( $existing_hash[ $chunk_no ] === $hash ) {
				continue; // Unchanged: never written, never read again after this pass.
			}
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
			$wpdb->query( $wpdb->prepare(
				"UPDATE {$table} SET updated_at = %s, content_hash = %s, chunk_bytes = %d, content = %s, search_text = %s WHERE post_id = %d AND chunk_no = %d",
				array( $now, $hash, $bytes, $content, $search_text, $post_id, $chunk_no )
			) );
		} else {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
			$wpdb->query( $wpdb->prepare(
				"INSERT INTO {$table} (post_id, chunk_no, updated_at, content_hash, chunk_bytes, content, search_text) VALUES (%d,%d,%s,%s,%d,%s,%s)",
				array( $post_id, $chunk_no, $now, $hash, $bytes, $content, $search_text )
			) );
		}
		++$written;
	}

	// Drop any chunk_no this post no longer has (post got shorter).
	$keep_below = count( $chunks );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare(
		"DELETE FROM {$table} WHERE post_id = %d AND chunk_no >= %d ORDER BY id ASC LIMIT %d",
		array( $post_id, $keep_below, NECCOSPEAK_DB_CHUNKS_PER_POST_LIMIT )
	) );

	return $written;
}

/**
 * @param int $post_id
 * @return int Rows deleted.
 */
function neccospeak_db_chunks_delete_post( $post_id ) {
	global $wpdb;
	$post_id = (int) $post_id;
	if ( $post_id <= 0 || ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$table = neccospeak_db_table( 'chunks' );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare(
		"DELETE FROM {$table} WHERE post_id = %d ORDER BY id ASC LIMIT %d",
		array( $post_id, NECCOSPEAK_DB_CHUNKS_PER_POST_LIMIT )
	) );
	return (int) $wpdb->rows_affected;
}

/**
 * @return array{posts:int,chunks:int,bytes:int}
 */
function neccospeak_db_chunks_counts() {
	global $wpdb;
	$empty = array(
		'posts'  => 0,
		'chunks' => 0,
		'bytes'  => 0,
	);
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}
	$table = neccospeak_db_table( 'chunks' );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$row = $wpdb->get_row( $wpdb->prepare(
		"SELECT COUNT(DISTINCT post_id) AS posts, COUNT(*) AS chunks, COALESCE(SUM(chunk_bytes),0) AS bytes FROM {$table} LIMIT %d",
		array( 1 )
	) );
	$row = $row ? (array) $row : array();
	return array(
		'posts'  => isset( $row['posts'] ) ? (int) $row['posts'] : 0,
		'chunks' => isset( $row['chunks'] ) ? (int) $row['chunks'] : 0,
		'bytes'  => isset( $row['bytes'] ) ? (int) $row['bytes'] : 0,
	);
}

/**
 * FULLTEXT (ngram, BOOLEAN MODE) search (design §6.5(A)).
 *
 * @param string $boolean_query Pre-built via neccospeak_rag_boolean_query().
 * @param int    $limit
 * @return array Same shape as neccospeak_db_chunks_normalize_rows().
 */
function neccospeak_db_chunks_search_fulltext( $boolean_query, $limit ) {
	global $wpdb;
	$boolean_query = is_string( $boolean_query ) ? $boolean_query : '';
	$limit         = max( 1, min( 50, (int) $limit ) );
	if ( '' === $boolean_query || ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return array();
	}
	$table = neccospeak_db_table( 'chunks' );
	// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from neccospeak_db_table() ($wpdb->prefix + a fixed allow-listed suffix, not user input); the two %s/%d values are passed through prepare()'s own placeholders. Static analysis cannot trace $sql through the intermediate variable into get_results(), but the query is fully prepared.
	$sql = $wpdb->prepare(
		"SELECT post_id, chunk_no, content, MATCH(search_text) AGAINST (%s IN BOOLEAN MODE) AS score
		   FROM {$table}
		  WHERE MATCH(search_text) AGAINST (%s IN BOOLEAN MODE)
		  ORDER BY score DESC, id ASC
		  LIMIT %d",
		array( $boolean_query, $boolean_query, $limit )
	);
	return neccospeak_db_chunks_normalize_rows( $wpdb->get_results( $sql ) );
	// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
}

/**
 * LIKE bi-gram fallback search (design §6.5(B)).
 *
 * @param array $grams Pre-built via neccospeak_rag_like_grams().
 * @param int   $limit
 * @return array Same shape as neccospeak_db_chunks_normalize_rows().
 */
function neccospeak_db_chunks_search_like( array $grams, $limit ) {
	global $wpdb;
	$grams = array_values( array_filter( $grams, function ( $g ) {
		return is_string( $g ) && '' !== $g;
	} ) );
	$limit = max( 1, min( 50, (int) $limit ) );
	if ( empty( $grams ) || ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return array();
	}
	$table = neccospeak_db_table( 'chunks' );

	$where  = array();
	$score  = array();
	$params = array();
	foreach ( $grams as $g ) {
		$like     = '%' . $wpdb->esc_like( $g ) . '%';
		$where[]  = 'search_text LIKE %s';
		$score[]  = '(search_text LIKE %s)';
		$params[] = $like;
	}
	// prepare() consumes placeholders in order: SELECT-clause copies first,
	// then the WHERE-clause copies, then LIMIT (design §6.5(B)).
	// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from neccospeak_db_table() ($wpdb->prefix + a fixed allow-listed suffix, not user input); $where/$score hold only the fixed strings 'search_text LIKE %s' / '(search_text LIKE %s)' repeated per $grams entry, never user data, and every actual value flows through prepare()'s %s/%d placeholders. Static analysis cannot trace $sql through the intermediate variable into get_results(), but the query is fully prepared.
	$sql = $wpdb->prepare(
		"SELECT post_id, chunk_no, content, ( " . implode( ' + ', $score ) . " ) AS score
		   FROM {$table}
		  WHERE " . implode( ' OR ', $where ) . "
		  ORDER BY score DESC, id ASC
		  LIMIT %d",
		array_merge( $params, $params, array( $limit ) )
	);
	return neccospeak_db_chunks_normalize_rows( $wpdb->get_results( $sql ) );
	// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
}

/**
 * Try to add the ngram FULLTEXT index (design §6.4's step table). Never
 * fatal; returns false at the first unmet precondition.
 *
 * @return bool
 */
function neccospeak_db_chunks_add_fulltext_index() {
	global $wpdb;
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return false;
	}

	$server_info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : '';
	$version_var = method_exists( $wpdb, 'get_var' ) ? (string) $wpdb->get_var( 'SELECT VERSION()' ) : '';
	if ( false !== stripos( $server_info, 'MariaDB' ) || false !== stripos( $version_var, 'MariaDB' ) ) {
		return false; // MariaDB has no ngram parser (§6.4 step 2).
	}

	$db_version = method_exists( $wpdb, 'db_version' ) ? (string) $wpdb->db_version() : '';
	if ( '' === $db_version || version_compare( $db_version, '5.7', '<' ) ) {
		return false;
	}

	$table       = neccospeak_db_table( 'chunks' );
	$safe_table  = str_replace( '`', '', $table );
	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is a literal derived from $wpdb->prefix, not user input; SHOW TABLE STATUS cannot bind a table name.
	$status = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', array( $table ) ) );
	$engine = ( $status && isset( $status->Engine ) ) ? (string) $status->Engine : '';
	if ( 'InnoDB' !== $engine ) {
		return false;
	}

	$suppressed = method_exists( $wpdb, 'suppress_errors' ) ? $wpdb->suppress_errors( true ) : null;
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/index names cannot be parameterized; $safe_table is backtick-stripped and prefix-derived (design §6.1 rule 8 / §6.4 step 5).
	$wpdb->query( "ALTER TABLE `{$safe_table}` ADD FULLTEXT KEY neccospeak_ft (search_text) WITH PARSER ngram" );
	if ( null !== $suppressed && method_exists( $wpdb, 'suppress_errors' ) ) {
		$wpdb->suppress_errors( $suppressed );
	}

	if ( ! neccospeak_db_chunks_has_fulltext_index() ) {
		return false;
	}

	$ngram_size = 2;
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- fixed literal, nothing to bind.
	$var_row = $wpdb->get_row( "SHOW VARIABLES LIKE 'ngram_token_size'" );
	if ( $var_row && isset( $var_row->Value ) && (int) $var_row->Value > 0 ) {
		$ngram_size = (int) $var_row->Value;
	}
	if ( function_exists( 'neccospeak_rag_state' ) ) {
		neccospeak_rag_state( array( 'ngram_size' => $ngram_size ) );
	}
	return true;
}

/**
 * @return bool Whether the `neccospeak_ft` FULLTEXT index actually exists.
 */
function neccospeak_db_chunks_has_fulltext_index() {
	global $wpdb;
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return false;
	}
	$table      = neccospeak_db_table( 'chunks' );
	$safe_table = str_replace( '`', '', $table );
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- table/key names cannot be parameterized; $safe_table is backtick-stripped and prefix-derived, 'neccospeak_ft' is a fixed literal (design §6.4 step 6).
	$row = $wpdb->get_row( "SHOW INDEX FROM `{$safe_table}` WHERE Key_name = 'neccospeak_ft'" );
	return ( null !== $row && false !== $row );
}

/**
 * @return int Highest post_id present in `chunks` (0 when empty/not ready).
 *             Sync-progress helper (design §2.4 D11).
 */
function neccospeak_db_chunks_max_post_id() {
	global $wpdb;
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$table = neccospeak_db_table( 'chunks' );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$max = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(post_id) FROM {$table} LIMIT %d", array( 1 ) ) );
	return $max ? (int) $max : 0;
}

/**
 * Delete rows whose post_id no longer resolves to a published, unexcluded
 * post (design §2.4 D12). Best-effort orphan sweep; called from
 * neccospeak_db_prune_once(). Silently no-ops when WP's get_post() isn't
 * available (a standalone/test load of this file).
 *
 * @param int $batch How many distinct post_ids to examine this call.
 * @return int Rows deleted.
 */
function neccospeak_db_chunks_prune_orphans( $batch ) {
	global $wpdb;
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	if ( ! function_exists( 'get_post' ) ) {
		return 0;
	}
	$batch = max( 1, (int) $batch );
	$table = neccospeak_db_table( 'chunks' );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$post_ids = $wpdb->get_col( $wpdb->prepare(
		"SELECT DISTINCT post_id FROM {$table} ORDER BY post_id ASC LIMIT %d",
		array( $batch )
	) );

	$deleted = 0;
	foreach ( (array) $post_ids as $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			continue;
		}
		$post   = get_post( $post_id );
		$orphan = ( ! is_object( $post ) || ! isset( $post->post_status ) )
			|| ( 'publish' !== $post->post_status )
			|| ( isset( $post->post_password ) && '' !== (string) $post->post_password );
		if ( $orphan ) {
			$deleted += neccospeak_db_chunks_delete_post( $post_id );
		}
	}
	return $deleted;
}

/**
 * Drop every row in `chunks` (design §2.4 D14). Used by "Rebuild index
 * (reset=1)" and by the rag_enabled ON->OFF transition (§6.3).
 *
 * @return int Rows deleted.
 */
function neccospeak_db_chunks_delete_all() {
	global $wpdb;
	if ( ! neccospeak_db_chunks_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$table = neccospeak_db_table( 'chunks' );
	$total = 0;
	// Bounded loop (not "until 0 rows"): each pass is itself LIMIT-bounded
	// (INV-4); the pass count cap is just a hard stop against a pathological
	// wpdb double or an obscenely large table wedging a single request.
	for ( $i = 0; $i < 1000; $i++ ) {
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare(
			"DELETE FROM {$table} ORDER BY id ASC LIMIT %d",
			array( NECCOSPEAK_DB_CHUNKS_DELETE_ALL_BATCH )
		) );
		$affected = (int) $wpdb->rows_affected;
		$total   += $affected;
		if ( $affected < NECCOSPEAK_DB_CHUNKS_DELETE_ALL_BATCH ) {
			break;
		}
	}
	return $total;
}

/* -----------------------------------------------------------------------
 * §3.2 Common helpers
 * -------------------------------------------------------------------- */

/**
 * @return string Current UTC datetime, 'Y-m-d H:i:s'.
 */
function neccospeak_db_now() {
	if ( function_exists( 'current_time' ) ) {
		return current_time( 'mysql', true );
	}
	return gmdate( 'Y-m-d H:i:s' );
}

/**
 * @return string Current UTC date, 'Y-m-d'.
 */
function neccospeak_db_today() {
	return gmdate( 'Y-m-d' );
}

/**
 * @param string $mysql_datetime A 'Y-m-d H:i:s' (UTC) datetime.
 * @return string 'Y-m-d' derived from it (falls back to today on parse failure).
 */
function neccospeak_db_day_of( $mysql_datetime ) {
	if ( ! is_string( $mysql_datetime ) || '' === $mysql_datetime ) {
		return neccospeak_db_today();
	}
	$ts = strtotime( $mysql_datetime . ' UTC' );
	if ( false === $ts ) {
		return neccospeak_db_today();
	}
	return gmdate( 'Y-m-d', $ts );
}

/**
 * Validate/normalize a client-supplied session id.
 *
 * @param mixed $raw
 * @return string The id when it matches /^[A-Za-z0-9_-]{8,32}$/, else ''.
 */
function neccospeak_db_session_id( $raw ) {
	if ( ! is_string( $raw ) ) {
		return '';
	}
	return preg_match( '/^[A-Za-z0-9_-]{8,32}$/', $raw ) ? $raw : '';
}

/**
 * UTF-8-safe byte clip, delegating to includes/prompt.php's helper when
 * available (design §3.2).
 *
 * @param mixed $text
 * @param int   $max_bytes
 * @return string
 */
function neccospeak_db_clip( $text, $max_bytes ) {
	$text      = is_string( $text ) ? $text : '';
	$max_bytes = (int) $max_bytes;
	if ( $max_bytes <= 0 ) {
		return '';
	}
	if ( function_exists( 'neccospeak_strcut' ) ) {
		return neccospeak_strcut( $text, $max_bytes );
	}
	if ( strlen( $text ) <= $max_bytes ) {
		return $text;
	}
	if ( function_exists( 'mb_strcut' ) ) {
		return mb_strcut( $text, 0, $max_bytes, 'UTF-8' );
	}
	return substr( $text, 0, $max_bytes );
}

/**
 * JSON-encode then byte-clip; empty string on encode failure.
 *
 * @param mixed $value
 * @param int   $max_bytes
 * @return string
 */
function neccospeak_db_json( $value, $max_bytes ) {
	$json = wp_json_encode( $value );
	if ( ! is_string( $json ) ) {
		return '';
	}
	return neccospeak_db_clip( $json, $max_bytes );
}

/**
 * Clamp paging args (INV-4).
 *
 * @param array $args May contain 'page', 'per_page'.
 * @return array{limit:int,offset:int,page:int,per_page:int}
 */
function neccospeak_db_paging( array $args ) {
	$per_page = isset( $args['per_page'] ) ? (int) $args['per_page'] : 20;
	$per_page = max( 1, min( NECCOSPEAK_MAX_PAGE_SIZE, $per_page ) );
	$page     = isset( $args['page'] ) ? (int) $args['page'] : 1;
	$page     = max( 1, min( 10000, $page ) );
	$offset   = ( $page - 1 ) * $per_page;
	return array(
		'limit'    => $per_page,
		'offset'   => $offset,
		'page'     => $page,
		'per_page' => $per_page,
	);
}

/* -----------------------------------------------------------------------
 * §3.3 Write APIs
 * -------------------------------------------------------------------- */

/**
 * Record a G5 audit-log row (event_type='audit').
 *
 * @param array $args name, subject, user_id, changes, ref_id, note. See design §3.3.
 * @return int Inserted id, or 0 (not ready / nothing to insert).
 */
function neccospeak_db_log_audit( array $args ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$table = neccospeak_db_table( 'events' );

	$name    = isset( $args['name'] ) ? neccospeak_db_clip( (string) $args['name'], 32 ) : '';
	$subject = isset( $args['subject'] ) ? neccospeak_db_clip( (string) $args['subject'], 64 ) : '';
	$user_id = isset( $args['user_id'] ) ? max( 0, (int) $args['user_id'] ) : 0;
	$ref_id  = isset( $args['ref_id'] ) ? max( 0, (int) $args['ref_id'] ) : 0;
	$changes = ( isset( $args['changes'] ) && is_array( $args['changes'] ) ) ? $args['changes'] : array();
	$note    = isset( $args['note'] ) ? neccospeak_db_clip( (string) $args['note'], 190 ) : '';

	$payload = array( 'c' => $changes );
	if ( '' !== $note ) {
		$payload['n'] = $note;
	}
	$content = neccospeak_db_json( $payload, NECCOSPEAK_AUDIT_MAX_BYTES );

	$now = neccospeak_db_now();
	$day = neccospeak_db_day_of( $now );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare(
		"INSERT INTO {$table} (event_type, event_name, created_at, day_utc, session_id, user_id, ref_id, num, subject, content) VALUES (%s,%s,%s,%s,%s,%d,%d,%d,%s,%s)",
		array( 'audit', $name, $now, $day, '', $user_id, $ref_id, count( $changes ), $subject, $content )
	) );
	return (int) $wpdb->insert_id;
}

/**
 * Record G31 analytics events (event_type='event') as one multi-row INSERT.
 *
 * @param string   $session_id
 * @param string[] $names Allow-listed event names; capped to NECCOSPEAK_EVENTS_BATCH_MAX.
 * @return int Number of rows actually inserted.
 */
function neccospeak_db_add_events( $session_id, array $names ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$sid   = neccospeak_db_session_id( $session_id );
	$names = array_slice( array_values( array_filter( $names, 'is_string' ) ), 0, NECCOSPEAK_EVENTS_BATCH_MAX );
	if ( empty( $names ) ) {
		return 0;
	}

	$table = neccospeak_db_table( 'events' );
	$now   = neccospeak_db_now();
	$day   = neccospeak_db_day_of( $now );

	$placeholders = array();
	$params       = array();
	foreach ( $names as $name ) {
		$placeholders[] = '(%s,%s,%s,%s,%s,%d,%d,%d,%s,%s)';
		array_push( $params, 'event', neccospeak_db_clip( $name, 32 ), $now, $day, $sid, 0, 0, 0, '', '' );
	}
	$sql = "INSERT INTO {$table} (event_type, event_name, created_at, day_utc, session_id, user_id, ref_id, num, subject, content) VALUES " . implode( ',', $placeholders );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix, not user input; $placeholders holds only the fixed '(%s,%s,...)' pattern string repeated per row, and all real values are passed through prepare()'s own array of placeholders.
	$wpdb->query( $wpdb->prepare( $sql, $params ) );
	return count( $names );
}

// 2026-08 撤去記録: ここにあった neccospeak_db_add_conversation() /
// neccospeak_db_next_turn() (G41 会話ターンの永続化・次ターン番号算出) は、
// 会話ログ機能ごと削除した。唯一の呼び出し元だった includes/rest.php の
// neccospeak_maybe_log_conversation() も同時に削除済み。

/**
 * UPSERT one usage_daily row (design §3.3). Always computes cost via
 * neccospeak_usage_cost_micro() at write time (price changes never move
 * historical rows).
 *
 * @param array $args kind, model, requests, prompt_tokens, completion_tokens,
 *                     audio_in_tokens, audio_out_tokens, cached_in_tokens, day.
 * @return bool
 */
function neccospeak_db_usage_add( array $args ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return false;
	}

	$kind  = ( isset( $args['kind'] ) && 'voice' === $args['kind'] ) ? 'voice' : 'chat';
	$model = isset( $args['model'] ) ? neccospeak_db_clip( (string) $args['model'], 64 ) : '';
	$day   = ( isset( $args['day'] ) && is_string( $args['day'] ) && '' !== $args['day'] ) ? $args['day'] : neccospeak_db_today();

	$requests          = isset( $args['requests'] ) ? max( 0, (int) $args['requests'] ) : 0;
	$prompt_tokens     = isset( $args['prompt_tokens'] ) ? max( 0, (int) $args['prompt_tokens'] ) : 0;
	$completion_tokens = isset( $args['completion_tokens'] ) ? max( 0, (int) $args['completion_tokens'] ) : 0;
	$audio_in_tokens   = isset( $args['audio_in_tokens'] ) ? max( 0, (int) $args['audio_in_tokens'] ) : 0;
	$audio_out_tokens  = isset( $args['audio_out_tokens'] ) ? max( 0, (int) $args['audio_out_tokens'] ) : 0;
	$cached_in_tokens  = isset( $args['cached_in_tokens'] ) ? max( 0, (int) $args['cached_in_tokens'] ) : 0;
	// Phase 6 (design phase6 §2.4 D16): rows whose usage came from
	// neccospeak_chat_usage_estimate()'s lower-bound estimate (SSE without a
	// real usage chunk) rather than an OpenAI-reported usage object.
	$estimated_requests = empty( $args['estimated'] ) ? 0 : 1;

	$cost_micro = neccospeak_usage_cost_micro( $model, array(
		'prompt'     => $prompt_tokens,
		'completion' => $completion_tokens,
		'audio_in'   => $audio_in_tokens,
		'audio_out'  => $audio_out_tokens,
		'cached_in'  => $cached_in_tokens,
	) );

	$table = neccospeak_db_table( 'usage_daily' );
	$now   = neccospeak_db_now();

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare(
		"INSERT INTO {$table} (day_utc, kind, model, requests, prompt_tokens, completion_tokens, audio_in_tokens, audio_out_tokens, cached_in_tokens, cost_micro, estimated_requests, updated_at) VALUES (%s,%s,%s,%d,%d,%d,%d,%d,%d,%d,%d,%s) ON DUPLICATE KEY UPDATE requests = requests + VALUES(requests), prompt_tokens = prompt_tokens + VALUES(prompt_tokens), completion_tokens = completion_tokens + VALUES(completion_tokens), audio_in_tokens = audio_in_tokens + VALUES(audio_in_tokens), audio_out_tokens = audio_out_tokens + VALUES(audio_out_tokens), cached_in_tokens = cached_in_tokens + VALUES(cached_in_tokens), cost_micro = cost_micro + VALUES(cost_micro), estimated_requests = estimated_requests + VALUES(estimated_requests), updated_at = VALUES(updated_at)",
		array( $day, $kind, $model, $requests, $prompt_tokens, $completion_tokens, $audio_in_tokens, $audio_out_tokens, $cached_in_tokens, $cost_micro, $estimated_requests, $now )
	) );
	return true;
}

/**
 * Insert a G2 version snapshot and prune old ones down to
 * NECCOSPEAK_VERSIONS_KEEP. The API key is defensively stripped again here
 * even though callers must already have removed it (design §2.4/§3.3).
 *
 * @param array $args user_id, user_login, changed_keys, snapshot, origin, note.
 * @return int Inserted id, or 0.
 */
function neccospeak_db_add_version( array $args ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}

	$user_id    = isset( $args['user_id'] ) ? max( 0, (int) $args['user_id'] ) : 0;
	$user_login = isset( $args['user_login'] ) ? neccospeak_db_clip( (string) $args['user_login'], 60 ) : '';
	$changed    = ( isset( $args['changed_keys'] ) && is_array( $args['changed_keys'] ) ) ? array_values( $args['changed_keys'] ) : array();
	$origin     = ( isset( $args['origin'] ) && in_array( $args['origin'], array( 'save', 'publish', 'rollback', 'discard' ), true ) ) ? $args['origin'] : 'save';
	$note       = isset( $args['note'] ) ? neccospeak_db_clip( (string) $args['note'], 190 ) : '';
	$snapshot   = ( isset( $args['snapshot'] ) && is_array( $args['snapshot'] ) ) ? $args['snapshot'] : array();

	// Defense in depth (design §2.4): the API key must never reach the snapshot column.
	unset( $snapshot['openai_api_key'] );

	$table = neccospeak_db_table( 'versions' );
	$now   = neccospeak_db_now();

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare(
		"INSERT INTO {$table} (created_at, user_id, user_login, changed_count, origin, note, changed_keys, snapshot) VALUES (%s,%d,%s,%d,%s,%s,%s,%s)",
		array(
			$now,
			$user_id,
			$user_login,
			count( $changed ),
			$origin,
			$note,
			neccospeak_db_json( $changed, 4000 ),
			neccospeak_db_json( $snapshot, 500000 ),
		)
	) );
	$id = (int) $wpdb->insert_id;
	if ( $id > 0 ) {
		neccospeak_db_prune_versions( NECCOSPEAK_VERSIONS_KEEP );
	}
	return $id;
}

/* -----------------------------------------------------------------------
 * §3.4 Read APIs (all LIMIT-bounded)
 * -------------------------------------------------------------------- */

/**
 * Shared paginated reader for the events table (used by audit/events).
 *
 * 2026-08 撤去記録: 'feedback' も本 reader を共用していたが、AI応答評価機能の
 * 撤去に伴い、その専用フィルタだった $args['rating'] 分岐と
 * neccospeak_db_get_feedback() ラッパーを削除した。関数本体は audit/events が
 * 引き続き使うためそのまま残している。
 *
 * @param string $event_type 'audit'|'event'.
 * @param array  $args page, per_page, name, user_id, session_id, search, since, until.
 * @return array{rows:array,total:int}
 */
function neccospeak_db_query_events( $event_type, array $args ) {
	global $wpdb;
	$empty = array(
		'rows'  => array(),
		'total' => 0,
	);
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}

	$table  = neccospeak_db_table( 'events' );
	$paging = neccospeak_db_paging( $args );

	$where  = array( 'event_type = %s' );
	$params = array( $event_type );

	if ( ! empty( $args['name'] ) && is_string( $args['name'] ) ) {
		$where[]  = 'event_name = %s';
		$params[] = neccospeak_db_clip( $args['name'], 32 );
	}
	if ( isset( $args['user_id'] ) && (int) $args['user_id'] > 0 ) {
		$where[]  = 'user_id = %d';
		$params[] = (int) $args['user_id'];
	}
	if ( ! empty( $args['session_id'] ) ) {
		$sid = neccospeak_db_session_id( $args['session_id'] );
		if ( '' !== $sid ) {
			$where[]  = 'session_id = %s';
			$params[] = $sid;
		}
	}
	if ( ! empty( $args['since'] ) && is_string( $args['since'] ) ) {
		$where[]  = 'created_at >= %s';
		$params[] = $args['since'];
	}
	if ( ! empty( $args['until'] ) && is_string( $args['until'] ) ) {
		$where[]  = 'created_at <= %s';
		$params[] = $args['until'];
	}
	if ( ! empty( $args['search'] ) && is_string( $args['search'] ) ) {
		$like     = '%' . $wpdb->esc_like( $args['search'] ) . '%';
		$where[]  = '(subject LIKE %s OR content LIKE %s)';
		$params[] = $like;
		$params[] = $like;
	}

	$where_sql = 'WHERE ' . implode( ' AND ', $where );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql} LIMIT 1", $params ) );

	$row_params   = $params;
	$row_params[] = $paging['limit'];
	$row_params[] = $paging['offset'];
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d", $row_params ) );

	return array(
		'rows'  => is_array( $rows ) ? $rows : array(),
		'total' => $total,
	);
}

/**
 * @param array $args page, per_page, name, user_id, search, since, until.
 * @return array{rows:array,total:int}
 */
function neccospeak_db_get_audit( array $args = array() ) {
	return neccospeak_db_query_events( 'audit', $args );
}

/**
 * @param array $args page, per_page, name, session_id, since, until.
 * @return array{rows:array,total:int}
 */
function neccospeak_db_get_events( array $args = array() ) {
	return neccospeak_db_query_events( 'event', $args );
}

// 2026-08 撤去記録: ここにあった neccospeak_db_get_conversations() /
// neccospeak_db_get_conversation_sessions() (会話ログの一覧/セッション別
// 集計リーダー) は、会話ログ機能ごと削除した。呼び出し元は無かった
// (includes/admin-logs.php のデータ閲覧UI自体が dev.42 で "unreachable
// screens" として既に削除済みだったため — includes/admin-tabs.php 参照)。

/**
 * @param array $args page, per_page, with_snapshot.
 * @return array{rows:array,total:int}
 */
function neccospeak_db_get_versions( array $args = array() ) {
	global $wpdb;
	$empty = array(
		'rows'  => array(),
		'total' => 0,
	);
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}
	$table  = neccospeak_db_table( 'versions' );
	$paging = neccospeak_db_paging( $args );
	$cols   = ! empty( $args['with_snapshot'] ) ? '*' : 'id, created_at, user_id, user_login, changed_count, origin, note, changed_keys';

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} LIMIT 1", array() ) );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$cols} FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", array( $paging['limit'], $paging['offset'] ) ) );

	return array(
		'rows'  => is_array( $rows ) ? $rows : array(),
		'total' => $total,
	);
}

/**
 * @param int $id
 * @return array|null Row as an assoc array with 'snapshot' json_decode()'d, or null.
 */
function neccospeak_db_get_version( $id ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return null;
	}
	$id = (int) $id;
	if ( $id <= 0 ) {
		return null;
	}
	$table = neccospeak_db_table( 'versions' );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d LIMIT 1", array( $id ) ) );
	if ( ! $row ) {
		return null;
	}
	$row             = (array) $row;
	$decoded         = isset( $row['snapshot'] ) ? json_decode( (string) $row['snapshot'], true ) : null;
	$row['snapshot'] = is_array( $decoded ) ? $decoded : array();
	return $row;
}

/**
 * Month bounds helper ('Y-m' -> ['Y-m-d' first, 'Y-m-d' last]).
 *
 * @param string $ym
 * @return array{0:string,1:string}
 */
function neccospeak_db_month_bounds( $ym ) {
	$ts = strtotime( $ym . '-01' );
	if ( false === $ts ) {
		$ts = time();
	}
	return array( gmdate( 'Y-m-d', $ts ), gmdate( 'Y-m-t', $ts ) );
}

/**
 * @param array $args from, to, kind, model, limit (<= NECCOSPEAK_MAX_AGG_ROWS).
 * @return array<object>
 */
function neccospeak_db_get_usage_daily( array $args = array() ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return array();
	}
	$table = neccospeak_db_table( 'usage_daily' );

	$where  = array();
	$params = array();
	if ( ! empty( $args['from'] ) && is_string( $args['from'] ) ) {
		$where[]  = 'day_utc >= %s';
		$params[] = $args['from'];
	}
	if ( ! empty( $args['to'] ) && is_string( $args['to'] ) ) {
		$where[]  = 'day_utc <= %s';
		$params[] = $args['to'];
	}
	if ( ! empty( $args['kind'] ) && is_string( $args['kind'] ) ) {
		$where[]  = 'kind = %s';
		$params[] = $args['kind'];
	}
	if ( ! empty( $args['model'] ) && is_string( $args['model'] ) ) {
		$where[]  = 'model = %s';
		$params[] = $args['model'];
	}
	$where_sql = $where ? ( 'WHERE ' . implode( ' AND ', $where ) ) : '';

	$limit    = isset( $args['limit'] ) ? (int) $args['limit'] : NECCOSPEAK_MAX_AGG_ROWS;
	$limit    = max( 1, min( NECCOSPEAK_MAX_AGG_ROWS, $limit ) );
	$params[] = $limit;

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} {$where_sql} ORDER BY day_utc ASC LIMIT %d", $params ) );
	return is_array( $rows ) ? $rows : array();
}

/**
 * Internal: SUM(tokens)/SUM(cost_micro) for one month + kind. Used by
 * neccospeak_runtime_reconcile(); does not require neccospeak_db_ready()
 * itself (caller already checked).
 *
 * @param string $ym
 * @param string $kind
 * @return array{tokens:int,cost_micro:int}
 */
function neccospeak_db_usage_sum_kind( $ym, $kind ) {
	global $wpdb;
	$empty = array(
		'tokens'     => 0,
		'cost_micro' => 0,
	);
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}
	list( $from, $to ) = neccospeak_db_month_bounds( $ym );
	$table              = neccospeak_db_table( 'usage_daily' );

	// audio_in_tokens/audio_out_tokens (and cached_in_tokens) are subsets of
	// prompt_tokens/completion_tokens, not additional tokens on top of them
	// (design §6.4's "in >= audio_in + cached_in" invariant; see also
	// neccospeak_usage_cost_micro()'s $text_in derivation below, which treats
	// them the same way). Summing them again here double-counted every voice
	// row's tokens (~1.9-2x), inflating budget_evaluate()'s pct and firing
	// warn/over/pause_voice roughly twice as fast as the configured budget
	// (review: "high" — budget totals double-counted audio tokens).
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$row = $wpdb->get_row( $wpdb->prepare(
		"SELECT SUM(prompt_tokens + completion_tokens) AS tokens, SUM(cost_micro) AS cost_micro FROM {$table} WHERE day_utc BETWEEN %s AND %s AND kind = %s LIMIT 1",
		array( $from, $to, $kind )
	) );
	$row = $row ? (array) $row : array();
	return array(
		'tokens'     => isset( $row['tokens'] ) ? (int) $row['tokens'] : 0,
		'cost_micro' => isset( $row['cost_micro'] ) ? (int) $row['cost_micro'] : 0,
	);
}

/**
 * @param string $ym 'YYYY-MM'.
 * @return array requests, prompt_tokens, completion_tokens, audio_in_tokens,
 *                audio_out_tokens, cached_in_tokens, total_tokens, cost_micro.
 */
function neccospeak_db_usage_month_totals( $ym ) {
	global $wpdb;
	$empty = array(
		'requests'          => 0,
		'prompt_tokens'     => 0,
		'completion_tokens' => 0,
		'audio_in_tokens'   => 0,
		'audio_out_tokens'  => 0,
		'cached_in_tokens'  => 0,
		'total_tokens'      => 0,
		'cost_micro'        => 0,
	);
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}
	if ( ! preg_match( '/^\d{4}-\d{2}$/', (string) $ym ) ) {
		return $empty;
	}
	list( $from, $to ) = neccospeak_db_month_bounds( $ym );
	$table              = neccospeak_db_table( 'usage_daily' );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$row = $wpdb->get_row( $wpdb->prepare(
		"SELECT SUM(requests) AS requests, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(audio_in_tokens) AS audio_in_tokens, SUM(audio_out_tokens) AS audio_out_tokens, SUM(cached_in_tokens) AS cached_in_tokens, SUM(cost_micro) AS cost_micro FROM {$table} WHERE day_utc BETWEEN %s AND %s LIMIT 1",
		array( $from, $to )
	) );
	$row = $row ? (array) $row : array();

	$out = array(
		'requests'          => isset( $row['requests'] ) ? (int) $row['requests'] : 0,
		'prompt_tokens'     => isset( $row['prompt_tokens'] ) ? (int) $row['prompt_tokens'] : 0,
		'completion_tokens' => isset( $row['completion_tokens'] ) ? (int) $row['completion_tokens'] : 0,
		'audio_in_tokens'   => isset( $row['audio_in_tokens'] ) ? (int) $row['audio_in_tokens'] : 0,
		'audio_out_tokens'  => isset( $row['audio_out_tokens'] ) ? (int) $row['audio_out_tokens'] : 0,
		'cached_in_tokens'  => isset( $row['cached_in_tokens'] ) ? (int) $row['cached_in_tokens'] : 0,
		'cost_micro'        => isset( $row['cost_micro'] ) ? (int) $row['cost_micro'] : 0,
	);
	// audio_in_tokens/audio_out_tokens are subsets of prompt_tokens/
	// completion_tokens, not additional tokens (see neccospeak_db_usage_sum_kind()
	// above for the same fix / rationale).
	$out['total_tokens'] = $out['prompt_tokens'] + $out['completion_tokens'];
	return $out;
}

/**
 * @param string $ym
 * @param int    $limit
 * @return array<object>
 */
function neccospeak_db_usage_by_model( $ym, $limit = 50 ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return array();
	}
	if ( ! preg_match( '/^\d{4}-\d{2}$/', (string) $ym ) ) {
		return array();
	}
	list( $from, $to ) = neccospeak_db_month_bounds( $ym );
	$table              = neccospeak_db_table( 'usage_daily' );
	$limit              = max( 1, min( NECCOSPEAK_MAX_AGG_ROWS, (int) $limit ) );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$rows = $wpdb->get_results( $wpdb->prepare(
		"SELECT model, kind, SUM(requests) AS requests, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(audio_in_tokens) AS audio_in_tokens, SUM(audio_out_tokens) AS audio_out_tokens, SUM(cached_in_tokens) AS cached_in_tokens, SUM(cost_micro) AS cost_micro FROM {$table} WHERE day_utc BETWEEN %s AND %s GROUP BY model, kind ORDER BY cost_micro DESC LIMIT %d",
		array( $from, $to, $limit )
	) );
	return is_array( $rows ) ? $rows : array();
}

/**
 * @param array $args from, to (default: trailing 30 days), limit.
 * @return array{counts:array,sessions:array}
 */
function neccospeak_db_events_daily( array $args = array() ) {
	global $wpdb;
	$empty = array(
		'counts'   => array(),
		'sessions' => array(),
	);
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}
	$table = neccospeak_db_table( 'events' );
	$from  = ( ! empty( $args['from'] ) && is_string( $args['from'] ) ) ? $args['from'] : gmdate( 'Y-m-d', time() - 30 * DAY_IN_SECONDS );
	$to    = ( ! empty( $args['to'] ) && is_string( $args['to'] ) ) ? $args['to'] : neccospeak_db_today();
	$limit = isset( $args['limit'] ) ? max( 1, min( NECCOSPEAK_MAX_AGG_ROWS, (int) $args['limit'] ) ) : NECCOSPEAK_MAX_AGG_ROWS;

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$counts = $wpdb->get_results( $wpdb->prepare(
		"SELECT day_utc, event_name, COUNT(*) AS c FROM {$table} WHERE event_type = %s AND day_utc BETWEEN %s AND %s GROUP BY day_utc, event_name ORDER BY day_utc ASC LIMIT %d",
		array( 'event', $from, $to, $limit )
	) );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$sessions = $wpdb->get_results( $wpdb->prepare(
		"SELECT day_utc, COUNT(DISTINCT session_id) AS s FROM {$table} WHERE event_type = %s AND day_utc BETWEEN %s AND %s GROUP BY day_utc ORDER BY day_utc ASC LIMIT %d",
		array( 'event', $from, $to, $limit )
	) );

	return array(
		'counts'   => is_array( $counts ) ? $counts : array(),
		'sessions' => is_array( $sessions ) ? $sessions : array(),
	);
}

/**
 * @return array{events:int,audit:int,usage_daily:int,versions:int}
 */
function neccospeak_db_counts() {
	global $wpdb;
	$empty = array(
		'events'      => 0,
		'audit'       => 0,
		'usage_daily' => 0,
		'versions'    => 0,
	);
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return $empty;
	}

	$events_table = neccospeak_db_table( 'events' );
	$usage_table  = neccospeak_db_table( 'usage_daily' );
	$ver_table    = neccospeak_db_table( 'versions' );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$events_total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$events_table} LIMIT 1", array() ) );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$audit = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$events_table} WHERE event_type = %s LIMIT 1", array( 'audit' ) ) );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$usage = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$usage_table} LIMIT 1", array() ) );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$versions = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$ver_table} LIMIT 1", array() ) );

	return array(
		'events'      => $events_total,
		'audit'       => $audit,
		'usage_daily' => $usage,
		'versions'    => $versions,
	);
}

/* -----------------------------------------------------------------------
 * §3.5 Delete APIs
 * -------------------------------------------------------------------- */

/**
 * Internal: resolve a dataset name to (table, event_type|null).
 *
 * @param string $dataset 'audit'|'event'|'version'.
 * @return array{0:string,1:?string}
 */
function neccospeak_db_dataset_table( $dataset ) {
	switch ( $dataset ) {
		case 'audit':
		case 'event':
			return array( neccospeak_db_table( 'events' ), $dataset );
		case 'version':
			return array( neccospeak_db_table( 'versions' ), null );
		default:
			// 2026-08 撤去記録: ここにあった 'conversation' ケース(会話ログ機能撤去)
			// はデフォルト分岐へ合流し、array('', null) を返す(空テーブル名 =
			// 呼び出し元の各関数が既存の '' === $table ガードで安全に no-op する)。
			return array( '', null );
	}
}

/**
 * Delete a single row by primary key (design §3.5). events-typed datasets
 * also constrain on event_type to prevent cross-type id collisions.
 *
 * @param string $dataset 'audit'|'event'|'version'.
 * @param int    $id
 * @return bool
 */
function neccospeak_db_delete_row( $dataset, $id ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return false;
	}
	$id = (int) $id;
	if ( $id <= 0 ) {
		return false;
	}
	list( $table, $event_type ) = neccospeak_db_dataset_table( $dataset );
	if ( '' === $table ) {
		return false;
	}

	if ( null !== $event_type ) {
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id = %d AND event_type = %s ORDER BY id ASC LIMIT 1", array( $id, $event_type ) ) );
	} else {
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id = %d ORDER BY id ASC LIMIT 1", array( $id ) ) );
	}
	return (int) $wpdb->rows_affected > 0;
}

/**
 * Batched conditional/full delete (design §3.5). Runs up to
 * NECCOSPEAK_PRUNE_MAX_PASSES batches of NECCOSPEAK_PRUNE_BATCH rows
 * (INV-4: no unbounded DELETE). If the return value equals
 * batch*passes, more rows may remain — callers should offer "delete again".
 *
 * @param string $dataset 'audit'|'event'|'version'.
 * @param array  $args session_id, since, until.
 * @return int Rows deleted.
 */
function neccospeak_db_delete_dataset( $dataset, array $args = array() ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	list( $table, $event_type ) = neccospeak_db_dataset_table( $dataset );
	if ( '' === $table ) {
		return 0;
	}

	$session_id = isset( $args['session_id'] ) ? neccospeak_db_session_id( $args['session_id'] ) : '';
	$since      = ( isset( $args['since'] ) && is_string( $args['since'] ) ) ? $args['since'] : '';
	$until      = ( isset( $args['until'] ) && is_string( $args['until'] ) ) ? $args['until'] : '';

	$batch = NECCOSPEAK_PRUNE_BATCH;
	$total = 0;

	for ( $pass = 0; $pass < NECCOSPEAK_PRUNE_MAX_PASSES; $pass++ ) {
		$where  = array();
		$params = array();
		if ( null !== $event_type ) {
			$where[]  = 'event_type = %s';
			$params[] = $event_type;
		}
		if ( '' !== $session_id ) {
			$where[]  = 'session_id = %s';
			$params[] = $session_id;
		}
		if ( '' !== $since ) {
			$where[]  = 'created_at >= %s';
			$params[] = $since;
		}
		if ( '' !== $until ) {
			$where[]  = 'created_at <= %s';
			$params[] = $until;
		}
		$where_sql = $where ? ( 'WHERE ' . implode( ' AND ', $where ) ) : '';
		$params[]  = $batch;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} {$where_sql} ORDER BY id ASC LIMIT %d", $params ) );
		$n      = (int) $wpdb->rows_affected;
		$total += $n;
		if ( $n < $batch ) {
			break;
		}
	}
	return $total;
}

// 2026-08 撤去記録: ここにあった neccospeak_db_delete_session() (1セッション分の
// 会話ログ+イベント行を一括削除) は、唯一の呼び出し元だった
// includes/admin-logs.php の 'conversation' データセット分岐ごと削除した。

/**
 * Batched delete of usage_daily rows, resetting the runtime MTD cache
 * afterwards (design §3.5).
 *
 * @param array $args from, to (both 'Y-m-d'; omit for all-time).
 * @return int Rows deleted.
 */
function neccospeak_db_reset_usage( array $args = array() ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$table = neccospeak_db_table( 'usage_daily' );
	$from  = ( ! empty( $args['from'] ) && is_string( $args['from'] ) ) ? $args['from'] : '';
	$to    = ( ! empty( $args['to'] ) && is_string( $args['to'] ) ) ? $args['to'] : '';

	$batch = NECCOSPEAK_PRUNE_BATCH;
	$total = 0;

	for ( $pass = 0; $pass < NECCOSPEAK_PRUNE_MAX_PASSES; $pass++ ) {
		$where  = array();
		$params = array();
		if ( '' !== $from ) {
			$where[]  = 'day_utc >= %s';
			$params[] = $from;
		}
		if ( '' !== $to ) {
			$where[]  = 'day_utc <= %s';
			$params[] = $to;
		}
		$where_sql = $where ? ( 'WHERE ' . implode( ' AND ', $where ) ) : '';
		$params[]  = $batch;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} {$where_sql} ORDER BY id ASC LIMIT %d", $params ) );
		$n      = (int) $wpdb->rows_affected;
		$total += $n;
		if ( $n < $batch ) {
			break;
		}
	}

	neccospeak_runtime_set( array(
		'mtd_tokens_chat'      => 0,
		'mtd_cost_micro_chat'  => 0,
		'mtd_tokens_voice'     => 0,
		'mtd_cost_micro_voice' => 0,
		'mtd_synced_at'        => time(),
	) );

	return $total;
}

// 2026-08 撤去記録: ここにあった §3.6 Streaming export
// (neccospeak_db_export_rows() — keyset-paginated ストリーミングエクスポート)
// は、唯一の呼び出し元だった includes/admin-logs.php の CSV エクスポート
// ハンドラ(neccospeak_handle_logs_export())を会話ログ機能ごと削除した時点で
// 消費者ゼロの死にコードになったため、dev.42 の死にコード摘出の前例に
// 従って削除した(design §1.3 の「呼び出し元が消えたら被呼び出し側も消す」
// 方針)。

/* -----------------------------------------------------------------------
 * §3.7 Pruning
 * -------------------------------------------------------------------- */

/**
 * @param string $dataset 'audit'|'event'|'usage'.
 * @return int Retention days (falls back to the built-in default on bad input).
 */
function neccospeak_db_retention_days( $dataset ) {
	$map = array(
		'audit' => array( 'audit_retention_days', 90 ),
		'event' => array( 'analytics_retention_days', 90 ),
		// 2026-08 撤去記録: 'conversation' => array('conversation_retention_days', 30)
		// は会話ログ機能撤去に伴い削除した。
		'usage' => array( 'usage_retention_days', NECCOSPEAK_USAGE_RETENTION_DEFAULT ),
	);
	if ( ! isset( $map[ $dataset ] ) ) {
		return 0;
	}
	list( $option_key, $default ) = $map[ $dataset ];
	$value = function_exists( 'neccospeak_option' ) ? neccospeak_option( $option_key, $default ) : $default;
	$value = (int) $value;
	return $value > 0 ? $value : (int) $default;
}

/**
 * @param string $dataset
 * @return string 'Y-m-d' cutoff: rows with day_utc before this are prunable.
 */
function neccospeak_db_retention_cutoff( $dataset ) {
	$days = neccospeak_db_retention_days( $dataset );
	if ( $days <= 0 ) {
		$days = 1;
	}
	return gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
}

/**
 * Atomic multi-process lock via INSERT IGNORE on wp_options (design §8.2).
 * A plain get/set-transient pair is not atomic; this is.
 *
 * @param int $ttl Seconds.
 * @return bool True when this call acquired the lock.
 */
function neccospeak_db_prune_lock_acquire( $ttl = 300 ) {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) {
		// Non-atomic fallback: worst case is an occasional double-run, never corruption.
		if ( false !== get_transient( NECCOSPEAK_PRUNE_LOCK ) ) {
			return false;
		}
		set_transient( NECCOSPEAK_PRUNE_LOCK, time() + $ttl, $ttl );
		return true;
	}
	$name = '_transient_' . NECCOSPEAK_PRUNE_LOCK;

	// Self-heal: clear a stale lock left behind by a fatal-interrupted run.
	$cur = get_option( $name, '' );
	if ( '' !== $cur && (int) $cur < time() ) {
		delete_option( $name );
	}

	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomic lock; WP has no API for it (design §8.2).
	$wpdb->query( $wpdb->prepare(
		"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$wpdb->options} is the core table name, not user input.
		array( $name, (string) ( time() + $ttl ) )
	) );
	if ( function_exists( 'wp_cache_delete' ) ) {
		wp_cache_delete( $name, 'options' );
		wp_cache_delete( 'notoptions', 'options' );
	}
	return 1 === (int) $wpdb->rows_affected;
}

/**
 * @return void
 */
function neccospeak_db_prune_lock_release() {
	delete_option( '_transient_' . NECCOSPEAK_PRUNE_LOCK );
}

/**
 * Internal: one batch of day_utc-cutoff deletes for the events table's
 * sub-datasets.
 *
 * @param string $dataset 'audit'|'event'.
 * @param int    $batch
 * @return int Rows deleted.
 */
function neccospeak_db_prune_dataset_batch( $dataset, $batch ) {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$cutoff = neccospeak_db_retention_cutoff( $dataset );

	if ( in_array( $dataset, array( 'audit', 'event' ), true ) ) {
		$table = neccospeak_db_table( 'events' );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE event_type = %s AND day_utc < %s ORDER BY id ASC LIMIT %d", array( $dataset, $cutoff, $batch ) ) );
		return (int) $wpdb->rows_affected;
	}
	// 2026-08 撤去記録: ここにあった 'conversation' 分岐(conversations テーブルの
	// cutoff 削除)は、会話ログ機能撤去に伴い削除した。
	return 0;
}

/**
 * Internal: one batch of day_utc-cutoff delete for usage_daily.
 *
 * @param int $batch
 * @return int Rows deleted.
 */
function neccospeak_db_prune_usage_batch( $batch ) {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$cutoff = neccospeak_db_retention_cutoff( 'usage' );
	$table  = neccospeak_db_table( 'usage_daily' );
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE day_utc < %s ORDER BY id ASC LIMIT %d", array( $cutoff, $batch ) ) );
	return (int) $wpdb->rows_affected;
}

/**
 * One batch per dataset (design §3.7). Does not take the lock itself;
 * callers (prune_run / cron / admin_init) are responsible for locking.
 *
 * @param int $batch
 * @return array{audit:int,event:int,usage:int,version:int}
 */
function neccospeak_db_prune_once( $batch = NECCOSPEAK_PRUNE_BATCH ) {
	$result = array(
		'audit'   => 0,
		'event'   => 0,
		'usage'   => 0,
		'version' => 0,
		'chunks'  => 0, // Phase 6 (design phase6 §2.4 D12, G26).
	);
	if ( ! neccospeak_db_ready() ) {
		return $result;
	}
	$batch = max( 1, (int) $batch );

	foreach ( array( 'audit', 'event' ) as $ds ) {
		$result[ $ds ] = neccospeak_db_prune_dataset_batch( $ds, $batch );
	}
	$result['usage']   = neccospeak_db_prune_usage_batch( $batch );
	$result['version'] = neccospeak_db_prune_versions( NECCOSPEAK_VERSIONS_KEEP );
	// `chunks` is an optional table (§0.4); neccospeak_db_chunks_prune_orphans()
	// gates on its own readiness so a host without it just gets 0 here.
	$result['chunks'] = neccospeak_db_chunks_prune_orphans( $batch );

	return $result;
}

/**
 * Lock, then run prune_once() repeatedly until either $max_passes is
 * reached or a pass deletes nothing at all (design §3.7 / §8.2).
 *
 * @param int $max_passes
 * @param int $batch
 * @return array Same shape as neccospeak_db_prune_once(), summed across passes.
 */
function neccospeak_db_prune_run( $max_passes = NECCOSPEAK_PRUNE_MAX_PASSES, $batch = NECCOSPEAK_PRUNE_BATCH ) {
	$totals = array(
		'audit'   => 0,
		'event'   => 0,
		'usage'   => 0,
		'version' => 0,
		'chunks'  => 0, // Phase 6 (design phase6 §2.4 D12, G26).
	);
	if ( ! neccospeak_db_ready() ) {
		return $totals;
	}
	if ( ! neccospeak_db_prune_lock_acquire() ) {
		return $totals;
	}

	for ( $i = 0; $i < (int) $max_passes; $i++ ) {
		$once     = neccospeak_db_prune_once( $batch );
		$all_zero = true;
		foreach ( $once as $k => $v ) {
			$totals[ $k ] += $v;
			if ( $v > 0 ) {
				$all_zero = false;
			}
		}
		if ( $all_zero ) {
			break;
		}
	}

	neccospeak_db_prune_lock_release();
	return $totals;
}

/**
 * Count-based pruning for versions: keep the newest $keep rows, delete the
 * rest in batches of 100 (design §3.7/§8.1).
 *
 * @param int $keep
 * @return int Rows deleted.
 */
function neccospeak_db_prune_versions( $keep = NECCOSPEAK_VERSIONS_KEEP ) {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return 0;
	}
	$keep   = max( 0, (int) $keep );
	$table  = neccospeak_db_table( 'versions' );
	$offset = max( 0, $keep - 1 );

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$boundary_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} ORDER BY id DESC LIMIT 1 OFFSET %d", array( $offset ) ) );
	if ( ! $boundary_id ) {
		return 0;
	}
	$boundary_id = (int) $boundary_id;

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, not user input
	$wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE id < %d ORDER BY id ASC LIMIT %d", array( $boundary_id, 100 ) ) );
	return (int) $wpdb->rows_affected;
}

/**
 * Number of prune passes for the daily wp-cron run, scaled to stay ahead of
 * the worst-case /events insert rate (design §8.3, review finding 1(b)):
 * neccospeak_events_daily_cap() (includes/rest.php) can rise as high as
 * 500,000/day when `rate_limit_global_daily` is raised, so a fixed
 * NECCOSPEAK_PRUNE_MAX_PASSES x NECCOSPEAK_PRUNE_BATCH (20 x 500 = 10,000)
 * prune ceiling falls behind and wp_neccospeak_events grows unbounded. This
 * derives enough passes to cover the current cap, never fewer than the
 * static default.
 *
 * @return int
 */
function neccospeak_db_cron_pass_count() {
	$passes = NECCOSPEAK_PRUNE_MAX_PASSES;
	if ( function_exists( 'neccospeak_events_daily_cap' ) ) {
		$cap    = (int) neccospeak_events_daily_cap();
		$needed = (int) ceil( $cap / NECCOSPEAK_PRUNE_BATCH );
		$passes = max( $passes, $needed );
	}
	return $passes;
}

/**
 * wp-cron daily handler (design §8.3).
 *
 * @return void
 */
function neccospeak_db_cron() {
	if ( ! neccospeak_db_ready() ) {
		return;
	}
	neccospeak_db_prune_run( neccospeak_db_cron_pass_count(), NECCOSPEAK_PRUNE_BATCH );
	neccospeak_runtime_reconcile( true );
}

/**
 * admin_init deferred-prune handler for low-traffic sites where cron may
 * not fire: at most one small batch every NECCOSPEAK_PRUNE_ADMIN_INTERVAL
 * (design §8.3).
 *
 * @return void
 */
function neccospeak_db_maybe_prune_admin() {
	if ( ! neccospeak_db_ready() ) {
		return;
	}
	if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
		return;
	}
	if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
		return;
	}
	$last = (int) get_option( NECCOSPEAK_PRUNE_LAST_OPTION, 0 );
	if ( ( time() - $last ) < NECCOSPEAK_PRUNE_ADMIN_INTERVAL ) {
		return;
	}
	if ( ! neccospeak_db_prune_lock_acquire() ) {
		return;
	}
	neccospeak_db_prune_once( NECCOSPEAK_PRUNE_ADMIN_BATCH );
	update_option( NECCOSPEAK_PRUNE_LAST_OPTION, time(), false );
	neccospeak_db_prune_lock_release();
}

/**
 * Register the daily cron event once (guarded), from activation and
 * admin_init (design §8.3).
 *
 * @return void
 */
function neccospeak_db_schedule_cron() {
	if ( function_exists( 'wp_next_scheduled' ) && wp_next_scheduled( NECCOSPEAK_CRON_HOOK ) ) {
		return;
	}
	if ( function_exists( 'wp_schedule_event' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', NECCOSPEAK_CRON_HOOK );
	}
}

/* -----------------------------------------------------------------------
 * §3.8 / §7.1 Price table, cost, runtime, budget & circuit breaker
 * -------------------------------------------------------------------- */

/**
 * OpenAI list prices, USD per 1,000,000 tokens (design §7.1, as_of 2026-05-01).
 * Filterable via 'neccospeak_price_table'. Unknown models cost 0.
 *
 * @return array{as_of:string,models:array<string,array>}
 */
function neccospeak_price_table() {
	$table = array(
		'as_of'  => '2026-05-01',
		'models' => array(
			// ---- text / chat completions ----
			'gpt-5'                        => array(
				'in'        => 1.25,
				'cached_in' => 0.125,
				'out'       => 10.00,
			),
			'gpt-5-mini'                   => array(
				'in'        => 0.25,
				'cached_in' => 0.025,
				'out'       => 2.00,
			),
			'gpt-5-nano'                   => array(
				'in'        => 0.05,
				'cached_in' => 0.005,
				'out'       => 0.40,
			),
			'gpt-4.1'                      => array(
				'in'        => 2.00,
				'cached_in' => 0.50,
				'out'       => 8.00,
			),
			'gpt-4.1-mini'                 => array(
				'in'        => 0.40,
				'cached_in' => 0.10,
				'out'       => 1.60,
			),
			'gpt-4.1-nano'                 => array(
				'in'        => 0.10,
				'cached_in' => 0.025,
				'out'       => 0.40,
			),
			'gpt-4o'                       => array(
				'in'        => 2.50,
				'cached_in' => 1.25,
				'out'       => 10.00,
			),
			'gpt-4o-mini'                  => array(
				'in'        => 0.15,
				'cached_in' => 0.075,
				'out'       => 0.60,
			),
			'o3'                           => array(
				'in'        => 2.00,
				'cached_in' => 0.50,
				'out'       => 8.00,
			),
			'o3-mini'                      => array(
				'in'        => 1.10,
				'cached_in' => 0.55,
				'out'       => 4.40,
			),
			'o4-mini'                      => array(
				'in'        => 1.10,
				'cached_in' => 0.275,
				'out'       => 4.40,
			),
			// ---- realtime (text and audio priced separately; cached_audio_in = cached audio input) ----
			'gpt-realtime'                 => array(
				'in'              => 4.00,
				'cached_in'       => 0.40,
				'out'             => 16.00,
				'audio_in'        => 32.00,
				'audio_out'       => 64.00,
				'cached_audio_in' => 0.40,
			),
			'gpt-realtime-mini'            => array(
				'in'              => 0.60,
				'cached_in'       => 0.06,
				'out'             => 2.40,
				'audio_in'        => 10.00,
				'audio_out'       => 20.00,
				'cached_audio_in' => 0.30,
			),
			'gpt-4o-realtime-preview'      => array(
				'in'              => 5.00,
				'cached_in'       => 2.50,
				'out'             => 20.00,
				'audio_in'        => 40.00,
				'audio_out'       => 80.00,
				'cached_audio_in' => 2.50,
			),
			'gpt-4o-mini-realtime-preview' => array(
				'in'              => 0.60,
				'cached_in'       => 0.30,
				'out'             => 2.40,
				'audio_in'        => 10.00,
				'audio_out'       => 20.00,
				'cached_audio_in' => 0.30,
			),
		),
	);
	return apply_filters( 'neccospeak_price_table', $table );
}

/**
 * Longest-prefix-match model lookup (design §7.1): 'gpt-realtime-2' matches
 * 'gpt-realtime'; 'gpt-4o-mini-2024-07-18' matches 'gpt-4o-mini' (not the
 * shorter 'gpt-4o').
 *
 * @param string $model
 * @return array|null Price row, or null when no prefix matches.
 */
function neccospeak_price_for_model( $model ) {
	if ( ! is_string( $model ) || '' === $model ) {
		return null;
	}
	$table  = neccospeak_price_table();
	$models = ( isset( $table['models'] ) && is_array( $table['models'] ) ) ? $table['models'] : array();

	$best     = null;
	$best_len = -1;
	foreach ( $models as $key => $prices ) {
		if ( 0 === strpos( $model, $key ) && strlen( $key ) > $best_len ) {
			$best     = $prices;
			$best_len = strlen( $key );
		}
	}
	return $best;
}

/**
 * Cost in micro-USD (design §7.1). Since prices are USD per 1e6 tokens,
 * tokens * price already equals microUSD directly (no extra scaling).
 * Cached-audio tokens are subtracted from both audio_in and cached_in to
 * avoid double-deduction, and priced at their own (cheaper) rate.
 *
 * @param string $model
 * @param array  $tokens prompt, completion, audio_in, audio_out, cached_in.
 * @return int microUSD, rounded; 0 when the model's price is unknown.
 */
function neccospeak_usage_cost_micro( $model, array $tokens ) {
	$price = neccospeak_price_for_model( $model );
	if ( null === $price ) {
		return 0;
	}

	$prompt     = isset( $tokens['prompt'] ) ? max( 0, (int) $tokens['prompt'] ) : 0;
	$completion = isset( $tokens['completion'] ) ? max( 0, (int) $tokens['completion'] ) : 0;
	$audio_in   = isset( $tokens['audio_in'] ) ? max( 0, (int) $tokens['audio_in'] ) : 0;
	$audio_out  = isset( $tokens['audio_out'] ) ? max( 0, (int) $tokens['audio_out'] ) : 0;
	$cached_in  = isset( $tokens['cached_in'] ) ? max( 0, (int) $tokens['cached_in'] ) : 0;

	$in_price           = isset( $price['in'] ) ? (float) $price['in'] : 0.0;
	$cached_in_price    = isset( $price['cached_in'] ) ? (float) $price['cached_in'] : 0.0;
	$out_price          = isset( $price['out'] ) ? (float) $price['out'] : 0.0;
	$audio_in_price     = isset( $price['audio_in'] ) ? (float) $price['audio_in'] : 0.0;
	$audio_out_price    = isset( $price['audio_out'] ) ? (float) $price['audio_out'] : 0.0;
	$cached_audio_price = isset( $price['cached_audio_in'] ) ? (float) $price['cached_audio_in'] : $audio_in_price;

	$cached_audio_in   = min( $cached_in, $audio_in );
	$cached_text_in    = max( 0, $cached_in - $cached_audio_in );
	$uncached_audio_in = max( 0, $audio_in - $cached_audio_in );
	$text_in           = max( 0, $prompt - $audio_in - $cached_text_in );
	$text_out          = max( 0, $completion - $audio_out );

	$cost = ( $text_in * $in_price )
		+ ( $cached_text_in * $cached_in_price )
		+ ( $uncached_audio_in * $audio_in_price )
		+ ( $cached_audio_in * $cached_audio_price )
		+ ( $text_out * $out_price )
		+ ( $audio_out * $audio_out_price );

	return (int) round( $cost );
}

/**
 * In-request static cache for the runtime option (get/set/clear).
 *
 * @param array|null $set   Pass an array to overwrite the cache.
 * @param bool       $clear Pass true to reset to uncached.
 * @return array|null
 */
function neccospeak_runtime_cache( $set = null, $clear = false ) {
	static $cache = null;
	if ( $clear ) {
		$cache = null;
		return null;
	}
	if ( null !== $set ) {
		$cache = $set;
	}
	return $cache;
}

/**
 * Read (and memoize for the remainder of the request) the
 * `neccospeak_runtime` option (design §3.8/§4.6: autoload=no).
 *
 * @return array
 */
function neccospeak_runtime_get() {
	$cached = neccospeak_runtime_cache();
	if ( null !== $cached ) {
		return $cached;
	}

	$defaults = array(
		'pause_chat'           => false,
		'pause_voice'          => false,
		'paused_at'            => 0,
		'paused_reason'        => '',
		'mtd_month'            => '',
		'mtd_tokens_chat'      => 0,
		'mtd_cost_micro_chat'  => 0,
		'mtd_tokens_voice'     => 0,
		'mtd_cost_micro_voice' => 0,
		'mtd_synced_at'        => 0,
		'voice_day'            => '',
		'voice_day_tokens'     => 0,
		'events_day'           => '',
		'events_capped_at'     => 0,
		'notified'             => array(
			'warn' => '',
			'over' => '',
		),
	);

	$stored = get_option( NECCOSPEAK_RUNTIME_OPTION, array() );
	if ( ! is_array( $stored ) ) {
		$stored = array();
	}
	$merged = array_merge( $defaults, $stored );
	if ( ! isset( $merged['notified'] ) || ! is_array( $merged['notified'] ) ) {
		$merged['notified'] = $defaults['notified'];
	} else {
		$merged['notified'] = array_merge( $defaults['notified'], $merged['notified'] );
	}

	neccospeak_runtime_cache( $merged );
	return $merged;
}

/**
 * Merge $patch into the current runtime state and persist it
 * (autoload=no, per §4.6). This is the plugin's ONLY write path for
 * `neccospeak_runtime` (design §3.8).
 *
 * @param array $patch
 * @return bool
 */
function neccospeak_runtime_set( array $patch ) {
	$current = neccospeak_runtime_get();
	$merged  = array_merge( $current, $patch );
	update_option( NECCOSPEAK_RUNTIME_OPTION, $merged, false );
	neccospeak_runtime_cache( $merged );
	return true;
}

/**
 * @param string $bucket 'chat'|'voice'.
 * @return bool
 */
function neccospeak_runtime_is_paused( $bucket ) {
	$rt = neccospeak_runtime_get();
	if ( 'chat' === $bucket ) {
		return ! empty( $rt['pause_chat'] );
	}
	if ( 'voice' === $bucket ) {
		return ! empty( $rt['pause_voice'] );
	}
	return false;
}

/**
 * @param string $scope 'chat'|'voice'|'all'.
 * @param string $reason
 * @return void
 */
function neccospeak_runtime_pause( $scope, $reason ) {
	$patch = array(
		'paused_at'     => time(),
		'paused_reason' => (string) $reason,
	);
	if ( 'all' === $scope ) {
		$patch['pause_chat']  = true;
		$patch['pause_voice'] = true;
	} elseif ( 'chat' === $scope ) {
		$patch['pause_chat'] = true;
	} elseif ( 'voice' === $scope ) {
		$patch['pause_voice'] = true;
	} else {
		return;
	}
	neccospeak_runtime_set( $patch );
}

/**
 * @param string $scope 'chat'|'voice'|'all'.
 * @return void
 */
function neccospeak_runtime_resume( $scope = 'all' ) {
	$patch = array( 'paused_reason' => '' );
	if ( 'all' === $scope ) {
		$patch['pause_chat']  = false;
		$patch['pause_voice'] = false;
	} elseif ( 'chat' === $scope ) {
		$patch['pause_chat'] = false;
	} elseif ( 'voice' === $scope ) {
		$patch['pause_voice'] = false;
	} else {
		return;
	}
	neccospeak_runtime_set( $patch );
}

/**
 * Recompute MTD chat/voice tokens+cost from the authoritative usage_daily
 * table and refresh the runtime cache (design §3.8/§7.2). On a month
 * rollover this also clears notified flags and resumes both buckets.
 *
 * @param bool $force Unused by callers that already checked the interval;
 *                     kept for API-contract compatibility with design §3.8.
 * @return void
 */
function neccospeak_runtime_reconcile( $force = false ) {
	global $wpdb;
	if ( ! neccospeak_db_ready() || ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
		return;
	}
	$rt            = neccospeak_runtime_get();
	$ym            = gmdate( 'Y-m' );
	$month_changed = ( $rt['mtd_month'] !== $ym );

	$chat  = neccospeak_db_usage_sum_kind( $ym, 'chat' );
	$voice = neccospeak_db_usage_sum_kind( $ym, 'voice' );

	$patch = array(
		'mtd_month'            => $ym,
		'mtd_tokens_chat'      => $chat['tokens'],
		'mtd_cost_micro_chat'  => $chat['cost_micro'],
		'mtd_tokens_voice'     => $voice['tokens'],
		'mtd_cost_micro_voice' => $voice['cost_micro'],
		'mtd_synced_at'        => time(),
	);

	if ( $month_changed ) {
		$patch['notified']      = array(
			'warn' => '',
			'over' => '',
		);
		$patch['pause_chat']    = false;
		$patch['pause_voice']   = false;
		$patch['paused_reason'] = '';
	}

	neccospeak_runtime_set( $patch );
}

/**
 * The sole entry point impl-rest uses to record usage (design §3.8).
 * Always UPSERTs usage_daily; only touches `neccospeak_runtime` when
 * budget monitoring is enabled AND the sync interval has elapsed (INV-1).
 *
 * @param array $args Same shape as neccospeak_db_usage_add().
 * @return void
 */
function neccospeak_usage_note( array $args ) {
	neccospeak_db_usage_add( $args );

	$budget_enabled = function_exists( 'neccospeak_option' ) ? (bool) neccospeak_option( 'budget_enabled', false ) : false;
	if ( ! $budget_enabled ) {
		return; // INV-1: default configuration never writes neccospeak_runtime.
	}

	$rt  = neccospeak_runtime_get();
	$hot = ( ! empty( $rt['pause_chat'] ) || ! empty( $rt['pause_voice'] )
		|| '' !== $rt['notified']['warn'] || '' !== $rt['notified']['over'] );
	$interval = $hot ? NECCOSPEAK_BUDGET_SYNC_HOT : NECCOSPEAK_BUDGET_SYNC_INTERVAL;

	$month_changed = ( $rt['mtd_month'] !== gmdate( 'Y-m' ) );
	if ( $month_changed || ( time() - (int) $rt['mtd_synced_at'] ) >= $interval ) {
		neccospeak_runtime_reconcile( true );
	}

	neccospeak_budget_evaluate();
}

/**
 * Daily acceptance cap for self-reported voice tokens (design §3.8/INV-12(a)).
 *
 * @return int
 */
function neccospeak_voice_report_daily_cap() {
	$global_daily = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'realtime_global_daily', 200 ) : 200;
	$cap          = $global_daily * NECCOSPEAK_VOICE_TOKENS_PER_SESSION;
	$cap          = max( 1000000, min( 20000000000, $cap ) );
	return (int) apply_filters( 'neccospeak_voice_report_daily_cap', $cap );
}

/**
 * Current budget snapshot for display (design §3.8). Does not evaluate or
 * mutate state; see neccospeak_budget_evaluate() for that.
 *
 * @return array
 */
function neccospeak_budget_state() {
	$enabled       = function_exists( 'neccospeak_option' ) ? (bool) neccospeak_option( 'budget_enabled', false ) : false;
	$include_voice = function_exists( 'neccospeak_option' ) ? (bool) neccospeak_option( 'budget_include_voice', false ) : false;
	$warn_percent  = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_warn_percent', 80 ) : 80;
	$limit_tokens  = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_monthly_tokens', 0 ) : 0;
	$limit_usd     = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_monthly_usd', 0 ) : 0;
	$limit_micro   = $limit_usd * 1000000;

	$rt = neccospeak_runtime_get();

	$tokens_chat  = (int) $rt['mtd_tokens_chat'];
	$cost_chat    = (int) $rt['mtd_cost_micro_chat'];
	$tokens_voice = (int) $rt['mtd_tokens_voice'];
	$cost_voice   = (int) $rt['mtd_cost_micro_voice'];

	$tokens = $include_voice ? ( $tokens_chat + $tokens_voice ) : $tokens_chat;
	$cost   = $include_voice ? ( $cost_chat + $cost_voice ) : $cost_chat;

	list( $pct, $pct_chat ) = neccospeak_budget_pct( $tokens, $cost, $tokens_chat, $cost_chat, $limit_tokens, $limit_micro );

	if ( $pct >= 100 ) {
		$level = 'over';
	} elseif ( $pct >= $warn_percent ) {
		$level = 'warn';
	} else {
		$level = 'ok';
	}

	return array(
		'enabled'          => $enabled,
		'include_voice'    => $include_voice,
		'month'            => (string) $rt['mtd_month'],
		'tokens'           => $tokens,
		'cost_micro'       => $cost,
		'tokens_chat'      => $tokens_chat,
		'cost_micro_chat'  => $cost_chat,
		'tokens_voice'     => $tokens_voice,
		'cost_micro_voice' => $cost_voice,
		'limit_tokens'     => $limit_tokens,
		'limit_micro'      => $limit_micro,
		'pct'              => $pct,
		'pct_chat'         => $pct_chat,
		'level'            => $level,
		'paused_chat'      => ! empty( $rt['pause_chat'] ),
		'paused_voice'     => ! empty( $rt['pause_voice'] ),
	);
}

/**
 * Internal: shared percent-of-limit math for budget_state()/budget_evaluate().
 *
 * @return array{0:int,1:int} [pct, pct_chat], each clamped to >= 0.
 */
function neccospeak_budget_pct( $tokens, $cost, $tokens_chat, $cost_chat, $limit_tokens, $limit_micro ) {
	$pct_token = ( $limit_tokens > 0 ) ? (int) floor( ( $tokens / $limit_tokens ) * 100 ) : -1;
	$pct_cost  = ( $limit_micro > 0 ) ? (int) floor( ( $cost / $limit_micro ) * 100 ) : -1;
	$pct       = max( 0, $pct_token, $pct_cost );

	$pct_token_chat = ( $limit_tokens > 0 ) ? (int) floor( ( $tokens_chat / $limit_tokens ) * 100 ) : -1;
	$pct_cost_chat  = ( $limit_micro > 0 ) ? (int) floor( ( $cost_chat / $limit_micro ) * 100 ) : -1;
	$pct_chat       = max( 0, $pct_token_chat, $pct_cost_chat );

	return array( $pct, $pct_chat );
}

/**
 * Evaluate the monthly budget and, on first crossing into 'over' this
 * month, fire the configured circuit breaker (design §3.8/§7.2/INV-12).
 * budget_include_voice=false (default) means only kind='chat' feeds the
 * decision; even when voice is included, pause_all only fires when the
 * chat-only percentage alone has reached 100.
 *
 * @return string 'ok'|'warn'|'over'.
 */
function neccospeak_budget_evaluate() {
	$enabled = function_exists( 'neccospeak_option' ) ? (bool) neccospeak_option( 'budget_enabled', false ) : false;
	if ( ! $enabled ) {
		// Safety net (review: "high" — a pause left over from before
		// budget_enabled was switched off had no automatic recovery path this
		// function could provide: it no-ops on !$enabled, so it never got a
		// chance to look at the pause flags at all. Since this function's only
		// production caller (neccospeak_usage_note()) itself already skips
		// calling it while disabled, the primary fix for the admin-facing side
		// of this is the Usage tab's "Resume now" button/banner now rendering
		// unconditionally on a pause (see includes/admin-logs.php) — this is
		// the defense-in-depth half: turning monitoring off is itself a
		// reasonable admin signal of "stop enforcing the budget", so any
		// callers that DO still reach this function while disabled get a
		// stale pause cleared rather than left stuck.
		$rt = neccospeak_runtime_get();
		if ( ! empty( $rt['pause_chat'] ) || ! empty( $rt['pause_voice'] ) ) {
			neccospeak_runtime_resume( 'all' );
		}
		return 'ok'; // No OTHER side effects (INV-1 / design §7.2 step 1).
	}

	$rt = neccospeak_runtime_get();
	$ym = gmdate( 'Y-m' );
	if ( $rt['mtd_month'] !== $ym ) {
		neccospeak_runtime_reconcile( true );
		$rt = neccospeak_runtime_get();
	}

	$include_voice = function_exists( 'neccospeak_option' ) ? (bool) neccospeak_option( 'budget_include_voice', false ) : false;
	$action        = function_exists( 'neccospeak_option' ) ? (string) neccospeak_option( 'budget_action', 'notify' ) : 'notify';
	$warn_percent  = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_warn_percent', 80 ) : 80;
	$limit_tokens  = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_monthly_tokens', 0 ) : 0;
	$limit_usd     = function_exists( 'neccospeak_option' ) ? (int) neccospeak_option( 'budget_monthly_usd', 0 ) : 0;
	$limit_micro   = $limit_usd * 1000000;

	$tokens_chat  = (int) $rt['mtd_tokens_chat'];
	$cost_chat    = (int) $rt['mtd_cost_micro_chat'];
	$tokens_voice = (int) $rt['mtd_tokens_voice'];
	$cost_voice   = (int) $rt['mtd_cost_micro_voice'];

	$tokens = $include_voice ? ( $tokens_chat + $tokens_voice ) : $tokens_chat;
	$cost   = $include_voice ? ( $cost_chat + $cost_voice ) : $cost_chat;

	list( $pct, $pct_chat ) = neccospeak_budget_pct( $tokens, $cost, $tokens_chat, $cost_chat, $limit_tokens, $limit_micro );

	if ( $pct >= 100 ) {
		$level = 'over';
	} elseif ( $pct >= $warn_percent ) {
		$level = 'warn';
	} else {
		$level = 'ok';
	}

	$notified = ( isset( $rt['notified'] ) && is_array( $rt['notified'] ) ) ? $rt['notified'] : array(
		'warn' => '',
		'over' => '',
	);

	if ( 'warn' === $level && $notified['warn'] !== $ym ) {
		$notified['warn'] = $ym;
		neccospeak_runtime_set( array( 'notified' => $notified ) );
	}

	if ( 'over' === $level && $notified['over'] !== $ym ) {
		$notified['over'] = $ym;
		neccospeak_runtime_set( array( 'notified' => $notified ) );

		if ( 'pause_voice' === $action ) {
			neccospeak_runtime_pause( 'voice', 'budget' );
			neccospeak_db_log_breaker_audit( 'pause_voice' );
		} elseif ( 'pause_all' === $action ) {
			if ( $pct_chat >= 100 ) {
				// INV-12: only a server-measured chat overage may stop chat.
				neccospeak_runtime_pause( 'all', 'budget' );
				neccospeak_db_log_breaker_audit( 'pause_all' );
			} else {
				neccospeak_runtime_pause( 'voice', 'budget' );
				neccospeak_db_log_breaker_audit( 'voice-attributed' );
			}
		}
		// 'notify': no pause, notification flag above is sufficient.
	}

	return $level;
}

/**
 * Internal: log a 'breaker' audit row when db.php's own audit API is present.
 *
 * @param string $note
 * @return void
 */
function neccospeak_db_log_breaker_audit( $note ) {
	if ( ! function_exists( 'neccospeak_db_log_audit' ) ) {
		return;
	}
	neccospeak_db_log_audit( array(
		'name'    => 'breaker',
		'subject' => NECCOSPEAK_RUNTIME_OPTION,
		'note'    => $note,
	) );
}

/* -----------------------------------------------------------------------
 * Hook registration (design §3.1/§8.3/§11). Loading this file only
 * registers callbacks; it never issues a query by itself (INV-6 note in §11).
 * -------------------------------------------------------------------- */

add_action( NECCOSPEAK_CRON_HOOK, 'neccospeak_db_cron' );
add_action( 'admin_init', 'neccospeak_db_maybe_upgrade', 5 );
add_action( 'admin_init', 'neccospeak_db_schedule_cron', 5 );
add_action( 'admin_init', 'neccospeak_db_maybe_prune_admin', 20 );
add_action( 'wp_initialize_site', 'neccospeak_db_new_site', 20 );
add_action( 'init', 'neccospeak_db_maybe_install_deferred', 20 );
