<?php
/**
 * ----------------------------------------------------------------------------
 * ---                DO NOT ALTER BELOW BLOCK OF COMMENTS                  ---
 * ----------------------------------------------------------------------------
 * Plugin Name:       My Book Showroom
 * Plugin URI:        https://www.dmrstech.com/
 * Description:       A DMRS Tech Product. Deluxe Manuscripts Regally Served — the definitive book showcase plugin for WordPress authors, publishers, and retailers.
 * Requires at least: 6.6
 * Requires PHP:      8.2
 * Author:            DMRS Tech
 * Author URI:        https://www.dmrstech.com
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-book-showroom
 * Domain Path:       /languages
 * Version:           1.3.9
 *
 *
 * Main bootstrap file for My Book Showroom.
 * Defines constants, runs requirements check, registers autoloader,
 * and hands control to MBS_Plugin::get_instance().
 *
 * @author  DMRS Tech
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// ----------------------------------------------------------------------------
// PLUGIN CONSTANTS
// ----------------------------------------------------------------------------

define( 'MBS_VERSION', '1.3.9' );
define( 'MBS_DB_VERSION', '4.43' ); // 4.43: book_title + list_id columns on mbs_esp_queue (DAT-003) // 4.42: mbs_book_club_files idx_post_id (DAT-022b) // 4.41: mbs_book_awards idx_post_id (DAT-022) // 4.40: Session 162 (same sprint) // 4.39: Session 224 -- add series_number DECIMAL(5,2) to mbs_format_rows // 4.38: Session 218 -- DB index optimization: drop 4 redundant indexes; normalize mbs_book_meta.isbn13 VARCHAR(17) // 4.33: Session 212 -- correct global default section display order // 4.32: Session 187 -- UX-EDIT-002 follow-up: reset global default sort_order to canonical order // 4.30: Session 186 -- UX-EDIT-002 section-order stale-row cleanup // 4.29: Session 185 -- R2-002 reading order term_meta → mbs_reading_order table // 4.28: Session 184 -- R2-004 mbs_arc_review_urls child table // 4.27: Session 183 -- R2-003 Bowker codes post_meta → mbs_book_classification_codes // 4.26: Session 182 -- review_urls TEXT column on mbs_arc_recipients (ARC multi-URL) // 4.24: Sprint RF-5 -- mbs_brand_feed_urls table (RF-012) // 4.23: Sprint RF-5 -- mbs_author_social_links table (RF-012) // 4.22: Sprint RF-5 -- mbs_affiliate_stores table (RF-012) // 4.21: Sprint RF-5 -- mbs_esp_queue table (RF-011) // // 4.20: Sprint 171 -- migrate mbs_look_inside_text to postmeta; drop sample_text column // 4.19: Session 163 -- add series_position + title_sort_key to mbs_book_meta; eliminate postmeta JOINs for FLAG-1 and FLAG-2 (163-C) // 4.18: Session 163 -- add idx_action/idx_user_id to mbs_activity_log, idx_status to mbs_arc_recipients, idx_review_count to mbs_book_stats, idx_post_status to mbs_arc_applicants (163-B index audit) // 4.17: Session 162 -- retire mbs_collection_{term_id}_position postmeta keys (Gap 5) // 4.16: Session 161 -- retire mbs_book_meta postmeta orphans (MIGS-001) // 4.15: Session 161 -- add idx_isbn13 to mbs_book_meta (Phase E index audit) // 4.14: Session 160 -- drop legacy mbs_contributors table (Phase 10) // 4.13: Session 160 -- mbs_book_club/questions/files tables (Phase 9) // 4.12: Session 160 -- mbs_book_collection_positions table (Phase 8) // 4.7: Session 159 -- mbs_book_awards table + Phase 1 classification postmeta cleanup // 4.6: Session 159 -- mbs_book_classification_codes table + dual-write for bisac/thema/bic/illustration // 4.5: Session 159 -- pub_date consolidation + territorial_rights v1 retirement (postmeta migration) // 4.4: Session 159 -- ALTER mbs_activity_log ADD COLUMN meta TEXT // 4.3: Session 159 -- CREATE mbs_brand_feed_cache (DASH-2A) // 4.2: Session 159 -- CREATE mbs_buy_link_clicks (INSIGHTS-1) // 4.1: Session 159 -- ADD sales_rank to mbs_pa_api_cache (DISPLAY-2) // 4.0: Session 131 -- content warning term cleanup (F-019 v2): delete Abuse/Death of a Pet/Profanity; rename Substance Abuse // 3.9: Sprint S110 -- edition_number + edition_type moved to mbs_format_rows // // 3.6: Sprint F-027 -- mbs_arc_applicants table // 3.5: Sprint POS-3 — mbs_pos_locations + mbs_pos_inventory tables // 3.1: Sprint BUY-EMBED-SHOPIFY — embed_snippet added to mbs_buy_links // 3.0: Sprint BOOK-CLUB-UI — book-club-resources added to global default section order // 2.9: Section order — 4 new sections (series-order, related-books, goodreads, kindle-preview), new default order, metabox priority fix
define( 'MBS_WHATS_NEW', 'Sprint 15: Amazon PA-API sync, WooCommerce add-to-cart. Sprint GC2: timezone fix, upgrade nudge.' );
define( 'MBS_DIR', plugin_dir_path( __FILE__ ) );
define( 'MBS_URL', plugin_dir_url( __FILE__ ) );
define( 'MBS_ASSETS_URL', MBS_URL . 'assets/' );

define( 'MBS_ASSETS_DIR', MBS_DIR . 'assets/' );

define( 'MBS_TEMPLATES_DIR', MBS_DIR . 'templates/' );
define( 'MBS_INCLUDES_DIR', MBS_DIR . 'includes/' );
define( 'MBS_LAYOUTS_DIR', MBS_DIR . 'layouts/' );

// ----------------------------------------------------------------------------
// API BASE URL
// ----------------------------------------------------------------------------
// Production — live 2026-05-15.
define( 'MBS_API_BASE', 'https://www.dmrstech.com' );
define( 'MBS_UPGRADE_URL', 'https://www.dmrstech.com/' );
define( 'MBS_WEBSITE_URL',  'https://www.dmrstech.com' );
define( 'MBS_PRIVACY_URL',  'https://www.dmrstech.com/privacy.php' ); // WO-002: privacy policy page — used in all in-plugin registration prompts
define( 'MBS_POST_TYPE', 'mbs_book' );
define( 'MBS_TAX_AUTHOR',      'mbs_author'     );
define( 'MBS_TAX_SERIES',      'mbs_series'     );
define( 'MBS_TAX_GENRE',       'mbs_genre'      );
define( 'MBS_TAX_TAG',         'mbs_tag'        );
define( 'MBS_TAX_COLLECTION',      'mbs_collection'      ); // F-016
define( 'MBS_TAX_UNIVERSE',        'mbs_universe'        ); // F-017
define( 'MBS_TAX_THEME',           'mbs_theme'           ); // F-019
define( 'MBS_TAX_CONTENT_WARNING', 'mbs_content_warning' ); // F-019
define( 'MBS_TAX_PUBLISHER',    'mbs_publisher'   );   // F-020
define( 'MBS_TAX_IMPRINT',      'mbs_imprint'     );   // F-020
define( 'MBS_TAX_CONTRIBUTOR',  'mbs_contributor' );   // F-020
define( 'MBS_OPTION_PREFIX', 'mbs_setting_' );
define( 'MBS_PING_INTERVAL', 86400 );
define( 'MBS_PING_GRACE', 90000 );           // +25 hours total window
define( 'MBS_HMAC_WINDOW', 300 );            // 5 minutes — strict replay protection

define( 'MBS_LOOK_INSIDE_WORD_LIMIT',       6000     );
define( 'MBS_LOOK_INSIDE_DOCX_MAX_BYTES',   5242880  );  // 5 MB
define( 'MBS_LOOK_INSIDE_EPUB_MAX_BYTES',   10485760 );  // 10 MB

define( 'MBS_TAMPER_CLEAN',   0 );
define( 'MBS_TAMPER_WARN',    1 );
define( 'MBS_TAMPER_DISABLE', 2 );
define( 'MBS_TAMPER_NUCLEAR', 3 );

// D-3 (April 2026): MBS_MANIFEST_KEY and the per-installation random signing key
// mechanism have been removed. Manifest verification now uses the per-activation
// api_secret via an authenticated HMAC-signed POST to /upgrade/manifest.
// MBS_HMAC::sign_request() / verify_response() are used for both license and manifest.
// mbs_manifest_signing_key wp_option is no longer written on activation.
// See SERVER-STRUCTURE.md §4 and Doc 0d §2.6. Server Task S-01 tracks server-side impl.

define( 'MBS_MIN_WP_VERSION', '6.6' );
define( 'MBS_MIN_PHP_VERSION', '8.0' );

// ----------------------------------------------------------------------------
// MINIMUM REQUIREMENTS CHECK
// ----------------------------------------------------------------------------

if ( ! mbs_meets_requirements() ) {
	add_action( 'admin_notices', 'mbs_requirements_notice' );
	add_action( 'admin_init', 'mbs_deactivate_self' );
	return;
}

function mbs_meets_requirements(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	global $wp_version;
	return version_compare( PHP_VERSION, MBS_MIN_PHP_VERSION, '>=' )
	       && version_compare( $wp_version, MBS_MIN_WP_VERSION, '>=' );
}

function mbs_requirements_notice(): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	?>
	<div class="notice notice-error">
		<p><?php
			/* translators: 1: minimum WordPress version number, 2: minimum PHP version number */
			printf(
				esc_html__( 'My Book Showroom requires WordPress %1$s+ and PHP %2$s+. Please upgrade.', 'my-book-showroom' ), // phpcs:ignore WordPress.WP.I18n.MissingTranslatorsComment -- translator comment on preceding line
				esc_html( MBS_MIN_WP_VERSION ),
				esc_html( MBS_MIN_PHP_VERSION )
			); ?></p>
	</div>
	<?php
}

function mbs_deactivate_self(): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	deactivate_plugins( plugin_basename( __FILE__ ) );
}

// ----------------------------------------------------------------------------
// GLOBAL HELPER FUNCTIONS
// ----------------------------------------------------------------------------

/**
 * Outermost gate for ALL front-end output.
 *
 * Reads the canonical option keys written by MBS_License_Credentials::store()
 * and MBS_License_Activation::activate(). These are the authoritative keys
 * for the entire plugin — every PRO gate, template, and layout reads these.
 *
* A-001: Threshold is MBS_TAMPER_DISABLE (2), not TAMPER_NUCLEAR (3).
 * Level 2 must suppress all FREE front-end output per Doc 0c LS-3.
 */
function mbs_is_registered(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
    // A-001: Tamper gate fires first at all tiers (including keyless FREE).
    // Level MBS_TAMPER_DISABLE suppresses ALL plugin output per Doc 0c LS-3.
    $flag = (int) get_option( 'mbs_tamper_flag', MBS_TAMPER_CLEAN );
    if ( $flag >= MBS_TAMPER_DISABLE ) {
        return (bool) apply_filters( 'mbs_is_registered', false ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
    }

    // WO-002: FREE tier does not require a license key.
    // A fresh install with no encrypted key stored operates in keyless FREE mode
    // so all FREE features render without activation.
    // mbs_is_pro() and mbs_is_pub() still require a validated, activated key.
    if ( '' === (string) get_option( 'mbs_license_key_encrypted', '' ) ) {
        return (bool) apply_filters( 'mbs_is_registered', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
    }

    // A key is stored — return whether activation completed successfully.
    return (bool) apply_filters( 'mbs_is_registered', (bool) get_option( 'mbs_license_registered', false ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
}

/**
 * Whether the tamper flag currently prevents license validation.
 *
 * Encapsulates the shared tamper-gate block used by mbs_is_pro() and
 * mbs_is_pub().
 *
 * @return bool True when the tamper flag is at or above MBS_TAMPER_DISABLE.
 * @since 1.2.0
 */
function mbs_tamper_blocks_license(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$flag = (int) get_option( 'mbs_tamper_flag', MBS_TAMPER_CLEAN );
	return $flag >= MBS_TAMPER_DISABLE;
}

/**
 * Whether the license ping has gone stale (no successful ping within the
 * allowed time window).
 *
 * Encapsulates the shared ping-age block used by mbs_is_pro() and
 * mbs_is_pub().
 *
 * @return bool True when the last successful ping exceeded the allowed window.
 * @since 1.2.0
 */
function mbs_ping_is_stale(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$last_ping = (int) get_option( 'mbs_license_last_ping', 0 );
	return ( time() - $last_ping ) > ( MBS_PING_INTERVAL + MBS_PING_GRACE );
}

/**
 * Gates PRO features only.
 */
function mbs_is_pro(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	// SEC-02 (resolved Sprint 12 story 12-7):
	// The mbs_is_pro filter is now gated behind MBS_TESTING_MODE.
	// In production (MBS_TESTING_MODE not defined), this block is unreachable
	// and no external code can override mbs_is_pro() via add_filter().
	// In local/staging test environments, define MBS_TESTING_MODE = true in
	// wp-config.php to re-enable the filter for unit and integration tests.
	// See SECURITY.md Rule 3, DEVELOPER-REFERENCE.md §4.
	if ( defined( 'MBS_TESTING_MODE' ) && MBS_TESTING_MODE ) {
		$override = apply_filters( 'mbs_is_pro', null ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
		if ( null !== $override ) {
			return (bool) $override;
		}
	}

	if ( mbs_tamper_blocks_license() ) {
		return false;
	}

	// R3-authorized: HMAC verification machinery — IS the mbs_is_pro() accessor.
	$tier = get_option( 'mbs_license_tier', 'free' );
	if ( ! in_array( $tier, array( 'pro', 'pub' ), true ) ) {
		return false;
	}
	// SEC-1 + WO-004: Verify tier HMAC and hook integrity.
	if ( 'free' !== $tier ) {
		// SEC-1: Verify tier HMAC against API secret to detect DB-edit escalation.
		$_api_secret  = MBS_Encryption::decrypt( get_option( 'mbs_api_secret_encrypted', '' ) ); // R1-authorized: license credential machinery -- value not logged.
		$_stored_hmac = get_option( 'mbs_license_tier_hmac', '' );
		if ( '' !== $_api_secret && ! \DMRS\MBS\Api\License\MBS_License_Credentials::verify_tier( $tier, $_stored_hmac, $_api_secret ) ) {
			// HMAC mismatch on keyed install — possible tier escalation via DB edit.
			trigger_error( 'MBS: License tier integrity check failed. Possible DB tampering.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
			$tier = 'free';
		}
		// WO-003: Keyless FREE installs cannot hold non-free tier.
		if ( '' === $_api_secret && 'free' !== $tier ) {
			trigger_error( 'MBS: Non-free tier on keyless install — demoting to free.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
			$tier = 'free';
		}
	}
	if ( 'free' === $tier ) {
		return false;
	}
	// WO-004: Detect unexpected __return_true hooks on mbs_is_pro filter.
	global $wp_filter;
	if ( isset( $wp_filter['mbs_is_pro'] ) ) {
		foreach ( $wp_filter['mbs_is_pro']->callbacks as $_p => $_cbs ) {
			foreach ( $_cbs as $_cb ) {
				$_fn = $_cb['function'];
				$_is_bypass = ( '__return_true' === $_fn )
					|| ( is_array( $_fn ) && isset( $_fn[1] ) && in_array( $_fn[1], array( '__return_true', 'return_true', 'always_true' ), true ) );
				if ( $_is_bypass ) {
					trigger_error( 'MBS: Unexpected bypass hook detected on mbs_is_pro filter.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
					return false; // Downgrade to free.
				}
			}
		}
	}
	if ( mbs_ping_is_stale() ) {
		return false;
	}
	return true;
}

/**
 * F-10 (Fourth audit): warn loudly if MBS_TESTING_MODE is left defined on a
 * non-local site. When set, mbs_is_pro() honours the `mbs_is_pro` filter, which
 * is a tier-gate bypass surface meant only for local/CI test runs. It must never
 * be defined in production wp-config.php. See SECURITY.md deployment checklist.
 */
function mbs_testing_mode_admin_notice(): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	if ( ! defined( 'MBS_TESTING_MODE' ) || ! MBS_TESTING_MODE ) {
		return;
	}
	// Only nag on environments that are not explicitly local/development.
	$env = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production';
	if ( in_array( $env, array( 'local', 'development' ), true ) ) {
		return;
	}
	if ( ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) {
		return;
	}
	echo '<div class="notice notice-error"><p>';
	echo esc_html__( 'My Book Showroom: MBS_TESTING_MODE is defined on a non-local site. This enables the tier-gate test filter and must be removed from wp-config.php in production.', 'my-book-showroom' );
	echo '</p></div>';
}
add_action( 'admin_notices', 'mbs_testing_mode_admin_notice' );

/**
 * Gates PUB-only features.
 *
 * Returns true only when the active license tier is 'pub'.
 * Applies the same tamper and ping-age checks as mbs_is_pro().
 * PUB-gated DOM elements must be absent entirely (not commented) for
 * non-PUB tiers -- see SPRINT-ONIX-EXPORT-IMPORT-Spec.md §3.
 */
function mbs_is_pub(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	// Testing mode override (same pattern as mbs_is_pro -- SEC-02).
	if ( defined( 'MBS_TESTING_MODE' ) && MBS_TESTING_MODE ) {
		$override = apply_filters( 'mbs_is_pub', null ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
		if ( null !== $override ) {
			return (bool) $override;
		}
	}

	if ( mbs_tamper_blocks_license() ) {
		return false;
	}

	// R3-authorized: HMAC verification machinery — IS the mbs_is_pub() accessor.
	$tier = get_option( 'mbs_license_tier', 'free' );
	if ( 'pub' !== $tier ) {
		return false;
	}
	// SEC-1 + WO-004: Verify tier HMAC and hook integrity.
	if ( 'free' !== $tier ) {
		// SEC-1: Verify tier HMAC against API secret to detect DB-edit escalation.
		$_api_secret  = MBS_Encryption::decrypt( get_option( 'mbs_api_secret_encrypted', '' ) ); // R1-authorized: license credential machinery -- value not logged.
		$_stored_hmac = get_option( 'mbs_license_tier_hmac', '' );
		if ( '' !== $_api_secret && ! \DMRS\MBS\Api\License\MBS_License_Credentials::verify_tier( $tier, $_stored_hmac, $_api_secret ) ) {
			// HMAC mismatch on keyed install — possible tier escalation via DB edit.
			trigger_error( 'MBS: License tier integrity check failed. Possible DB tampering.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
			$tier = 'free';
		}
		// WO-003: Keyless FREE installs cannot hold non-free tier.
		if ( '' === $_api_secret && 'free' !== $tier ) {
			trigger_error( 'MBS: Non-free tier on keyless install — demoting to free.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
			$tier = 'free';
		}
	}
	if ( 'pub' !== $tier ) {
		return false;
	}
	// WO-004: Detect unexpected bypass hooks on mbs_is_pub filter.
	global $wp_filter;
	if ( isset( $wp_filter['mbs_is_pub'] ) ) {
		foreach ( $wp_filter['mbs_is_pub']->callbacks as $_p => $_cbs ) {
			foreach ( $_cbs as $_cb ) {
				$_fn = $_cb['function'];
				$_is_bypass = ( '__return_true' === $_fn )
					|| ( is_array( $_fn ) && isset( $_fn[1] ) && in_array( $_fn[1], array( '__return_true', 'return_true', 'always_true' ), true ) );
				if ( $_is_bypass ) {
					trigger_error( 'MBS: Unexpected bypass hook detected on mbs_is_pub filter.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error -- trigger_error() used for recoverable errors in plugin lifecycle; acceptable in non-production contexts and gated by MBS_DEV_MODE
					return false; // Downgrade to free.
				}
			}
		}
	}
	if ( mbs_ping_is_stale() ) {
		return false;
	}
	return true;
}

/**
 * Whether the current install has no license key on file (keyless FREE state).
 *
 * Returns true when no encrypted key has been stored in wp_options. This is the
 * positive-identity check for the "no key on file" state introduced by WO-002.
 *
 * Use this — not ! mbs_is_registered() — when branching on keyless state, because
 * mbs_is_registered() intentionally returns true for keyless installs so that all
 * FREE features render without a key.
 *
 * @return bool True when mbs_license_key_encrypted is empty or not set.
 * @since  2.0.0
 */
function mbs_is_keyless(): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	return '' === (string) get_option( 'mbs_license_key_encrypted', '' );
}


/**
 * Return the stored license tier label as a plain string.
 *
 * This is the ONE place outside the accessor functions that reads
 * mbs_license_tier directly; every other caller should use this helper.
 * R3-authorized: single canonical reader — not a gate check.
 *
 * @return string 'free' | 'pro' | 'pub'
 * @since  2.1.0
 */
function mbs_get_license_tier_label(): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	return (string) get_option( 'mbs_license_tier', 'free' );
}

function mbs_get_setting( string $tab, string $key, mixed $default = '' ): mixed { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$option_name = MBS_OPTION_PREFIX . sanitize_key( $tab ) . '_' . sanitize_key( $key );
	return get_option( $option_name, $default );
}

function mbs_update_setting( string $tab, string $key, mixed $value ): bool { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$option_name = MBS_OPTION_PREFIX . sanitize_key( $tab ) . '_' . sanitize_key( $key );
	return update_option( $option_name, $value, false ); // V6-D-009: admin-only settings need not autoload.
}

/**
 * Retrieve and decrypt an AES-256-CBC-encrypted WordPress option.
 *
 * Convenience wrapper: get_option() -> MBS_Encryption::decrypt() -> string.
 * Returns an empty string when the option is absent, empty, or decryption fails.
 *
 * @param string $option_name The wp_options name holding the encrypted value.
 * @return string Decrypted plaintext, or empty string on any failure.
 * @since 1.3.4
 */
function mbs_decrypt_option( string $option_name ): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$stored = (string) get_option( $option_name, '' );
	if ( '' === $stored ) {
		return '';
	}
	$decrypted = MBS_Encryption::decrypt( $stored );
	return false !== $decrypted ? $decrypted : '';
}

/**
 * Template Part Loader
 *
* A-003: Uses a static closure instead of extract() to satisfy PHPCS.
 * $args keys are available as local variables in the included template.
 * Closure params are prefixed _mbs_ to prevent collision with $args keys.
 */
function mbs_get_template_part( string $slug, array $args = array() ): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	$file = MBS_TEMPLATES_DIR . $slug . '.php';
	if ( file_exists( $file ) ) {
		( static function ( string $_mbs_file, array $_mbs_args ): void {
			foreach ( $_mbs_args as $_mbs_key => $_mbs_value ) {
				$$_mbs_key = $_mbs_value;
			}
			$args = $_mbs_args;
			unset( $_mbs_args, $_mbs_key, $_mbs_value );
			include $_mbs_file;
		} )( $file, $args );
	}
}

// ----------------------------------------------------------------------------
// HELP TIP HELPER (UI-HELP-001)
// ----------------------------------------------------------------------------

/**
 * Output a contextual help-tip icon (question mark) with a CSS tooltip.
 *
 * Usage: mbs_help_tip( 'Your explanation here.' );
 *
 * The tooltip appears above the icon on hover/focus. Requires the
 * mbs-admin.css stylesheet (loaded on all plugin admin pages). No JS.
 * Safe to call inside any admin render function.
 *
 * @param string $text        Tooltip text. Plain text only — HTML is escaped.
 * @param string $screen      Optional screen-reader label (defaults to 'Help').
 * @param string $extra_class Optional extra CSS class(es) on the outer span (e.g. 'mbs-help-tip--wide').
 * @return void
 * @since 1.5.0
 */
function mbs_help_tip( string $text, string $screen = 'Help', string $extra_class = '' ): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	static $mbs_help_tip_script = false;
	if ( ! $mbs_help_tip_script ) {
		$mbs_help_tip_script = true;
		echo "<script>
(function(){
	'use strict';
	function mbsContent(btn){
		return btn.closest('.mbs-help-tip').querySelector('.mbs-help-tip__content');
	}
	function mbsCloseTips(){
		document.querySelectorAll('.mbs-help-tip__btn[aria-expanded=\"true\"]').forEach(function(b){
			b.setAttribute('aria-expanded','false');
			var c=mbsContent(b);
			if(c) c.style.display='none';
		});
	}
	document.addEventListener('click',function(e){
		var btn=e.target.closest('.mbs-help-tip__btn');
		if(btn){
			var open=btn.getAttribute('aria-expanded')==='true';
			mbsCloseTips();
			if(!open){
				btn.setAttribute('aria-expanded','true');
				var c=mbsContent(btn);
				if(c) c.style.display='block';
			}
			e.stopPropagation();
		} else if(!e.target.closest('.mbs-help-tip')){
			mbsCloseTips();
		}
	});
	document.addEventListener('keydown',function(e){
		if(e.key==='Escape'){mbsCloseTips();}
	});
}());
</script>";
	}
	echo '<span class="mbs-help-tip' . ( $extra_class ? ' ' . esc_attr( $extra_class ) : '' ) . '">'
		. '<button type="button" class="mbs-help-tip__btn" tabindex="0" aria-expanded="false" aria-label="' . esc_attr( $screen ) . '">'
		. '<span class="dashicons dashicons-editor-help" aria-hidden="true"></span>'
		. '</button>'
		. '<span class="mbs-help-tip__content" role="tooltip" style="display:none">'
		. esc_html( $text )
		. '</span>'
		. '</span>';
}

/**
 * Render a complete <th scope="row"> cell for a settings form-table row.
 *
 * Combines a <label> with an optional contextual help tip in a single call,
 * keeping all settings rows consistent and eliminating repeated boilerplate.
 *
 * Usage — in any settings form-table <tr>:
 *
 *   <tr>
 *     <th scope="row">
 *       <?php mbs_settings_th( 'my_field_id', __( 'Label text', 'my-book-showroom' ), __( 'Tip text.', 'my-book-showroom' ) ); ?>
 *     </th>
 *     <td> ... field HTML ... </td>
 *   </tr>
 *
 * Pass an empty string (or omit) $tip to render a plain label with no icon.
 *
 * @param string $for   The `for` attribute value matching the field's id.
 * @param string $label The visible label text. Already-translated string expected.
 * @param string $tip   Tooltip explanation. Empty string = no icon rendered.
 * @return void
 * @since 1.5.0
 */
function mbs_settings_th( string $for, string $label, string $tip = '' ): void { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
	echo '<label for="' . esc_attr( $for ) . '">' . esc_html( $label ) . '</label>';
	if ( '' !== $tip ) {
		mbs_help_tip( $tip );
	}
}

// ----------------------------------------------------------------------------
// COLOR VARIABLES OUTPUT
// ----------------------------------------------------------------------------
// REF-02: mbs_output_color_vars() extracted to MBS_Color_Vars class
// (includes/helpers/class-mbs-color-vars.php). Hook registration moved
// to MBS_Plugin::init() via MBS_Color_Vars::register(). A page-context
// gate was added so the style block only fires on MBS content pages.
// ----------------------------------------------------------------------------

// ----------------------------------------------------------------------------
// AUTOLOADER + PLUGIN INITIALIZATION
// ----------------------------------------------------------------------------

require_once MBS_INCLUDES_DIR . 'class-mbs-autoloader.php';
MBS_Autoloader::register();

add_action( 'plugins_loaded', function (): void {
	// Load textdomain at init p0 so it is ready before layout registry __() calls
	// fire at init p1 (WP 6.7+ defers textdomain loading to init).
	add_action( 'init', function (): void {
		load_plugin_textdomain( 'my-book-showroom', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- load_plugin_textdomain() is the correct WP API for loading plugin translations; this check flags it incorrectly
	}, 0 );

	// Single source of truth for plugin initialization
	MBS_Plugin::get_instance();

	// Register PUB-tier postmeta keys (mbs_language for ONIX export).
	// Visibility is enforced at render/save time via mbs_is_pub() -- the key
	// is registered for all installs so REST / tooling see a clean schema.
	add_action( 'init', function (): void {
		register_post_meta( MBS_POST_TYPE, 'mbs_language', array(
			'type'              => 'string',
			'description'       => 'ISO 639-2/B language code for ONIX export (PUB tier only)',
			'single'            => true,
			'sanitize_callback' => 'sanitize_text_field',
			'show_in_rest'      => true,
			'auth_callback'     => function(): bool {
				return current_user_can( 'edit_posts' );
			},
		) );

		// DAT-031: Register all remaining accepted mbs_* postmeta keys so they
		// have sanitize_callback safeguards and are visible to Block Editor /
		// REST tooling.  show_in_rest is false for all non-public keys.

		// ---- Textarea keys ----
		$textarea_keys = array(
			'mbs_synopsis',
			'mbs_teaser',
			'mbs_toc',
			'mbs_look_inside_text',
			'mbs_copyright_disclaimer',
			'mbs_contributor_statement',
			'mbs_share_text',
		);
		foreach ( $textarea_keys as $key ) {
			register_post_meta( MBS_POST_TYPE, $key, array(
				'type'              => 'string',
				'single'            => true,
				'show_in_rest'      => false,
				'sanitize_callback' => 'sanitize_textarea_field',
			) );
		}

		// ---- Boolean keys ----
		$bool_keys = array(
			'mbs_coming_soon',
			'mbs_featured',
			'mbs_share_section_enabled',
			'mbs_show_bisac_public',
			'mbs_show_thema_public',
			'mbs_show_genre_public',
			'mbs_enable_reader_reviews',
			'mbs_wc_enabled',
		);
		foreach ( $bool_keys as $key ) {
			register_post_meta( MBS_POST_TYPE, $key, array(
				'type'              => 'boolean',
				'single'            => true,
				'show_in_rest'      => false,
				'sanitize_callback' => function( $v ) { return (bool) $v; },
			) );
		}

		// ---- Integer keys ----
		$int_keys = array(
			'mbs_word_count',
			'mbs_copyright_year',
			'mbs_illustration_count',
			'mbs_wc_product_id',
		);
		foreach ( $int_keys as $key ) {
			register_post_meta( MBS_POST_TYPE, $key, array(
				'type'              => 'integer',
				'single'            => true,
				'show_in_rest'      => false,
				'sanitize_callback' => 'absint',
			) );
		}

		// ---- URL keys ----
		$url_keys = array(
			'mbs_goodreads_url',
			'mbs_storygraph_url',
			'mbs_publisher_url',
			'mbs_preorder_link',
			'mbs_trailer_embed_url',
		);
		foreach ( $url_keys as $key ) {
			register_post_meta( MBS_POST_TYPE, $key, array(
				'type'              => 'string',
				'single'            => true,
				'show_in_rest'      => false,
				'sanitize_callback' => 'esc_url_raw',
			) );
		}

		// ---- Default string keys ----
		$string_keys = array(
			'mbs_subtitle',
			'mbs_publisher',
			'mbs_lccn',
			'mbs_trailer_video_id',
			'mbs_spinning_video_id',
			'mbs_theme_override',
			'mbs_landing_page_mode',
			'mbs_product_contact_name',
			'mbs_product_contact_email',
			'mbs_product_contact_role',
			'mbs_audience',
			'mbs_audience_code',
			'mbs_arc_availability_date',
		);
		foreach ( $string_keys as $key ) {
			register_post_meta( MBS_POST_TYPE, $key, array(
				'type'              => 'string',
				'single'            => true,
				'show_in_rest'      => false,
				'sanitize_callback' => 'sanitize_text_field',
			) );
		}
	} );

	// Register Widgets
	add_action( 'widgets_init', function() {
		register_widget( 'MBS_Widget_Featured_Book' );
		register_widget( 'MBS_Widget_Genre_List' );
		register_widget( 'MBS_Widget_Series_List' );
		register_widget( 'MBS_Widget_Genre_Cloud' );
		register_widget( 'MBS_Widget_Related_Books' );
	} );
} );

// ----------------------------------------------------------------------------
// ACTIVATION / DEACTIVATION
// ----------------------------------------------------------------------------

register_activation_hook( __FILE__, function (): void {
	require_once MBS_INCLUDES_DIR . 'class-mbs-db-installer.php';

	if ( ! is_multisite() || is_main_site() ) {
		MBS_DB_Installer::install();
		MBS_DB_Installer::seed_default_section_order();

		// Sprint CLASSIFICATION: seed BISAC + Thema classification tables.
		require_once MBS_INCLUDES_DIR . 'data/class-mbs-classification-seeder.php';
		MBS_Classification_Seeder::maybe_seed();

		// D-3: mbs_manifest_signing_key no longer written on activation.
		// Manifest verification uses per-activation api_secret. See SERVER-STRUCTURE.md §4.

		// S15-1: Schedule daily PA-API sync cron event.
		require_once MBS_INCLUDES_DIR . 'admin/class-mbs-pa-api-sync-scheduler.php';
		MBS_PA_API_Sync_Scheduler::schedule();

		// SW-1: Queue the setup wizard redirect for first-run installations.
		// maybe_trigger_on_activation() sets a 30-second transient that admin_init
		// picks up to redirect the user to the setup wizard -- but only if no books
		// exist and the wizard has not been dismissed.
		// NOTE: must run BEFORE MBS_Dummy_Book_Installer::install() so the
		// book-count check sees zero on a genuine fresh install or post-reset
		// reactivation. See RESET-PROCEDURE.md (Bug 2 / Session 255).
		require_once MBS_INCLUDES_DIR . 'admin/class-mbs-setup-wizard.php';
		MBS_Setup_Wizard::maybe_trigger_on_activation();

		require_once MBS_INCLUDES_DIR . 'class-mbs-dummy-book-installer.php';
		MBS_Dummy_Book_Installer::install();
		// GC2-1: Record first-activation timestamp for upgrade nudge milestones.
		add_option( 'mbs_install_timestamp', time() );
		add_option( 'mbs_telemetry_consent', 'pending' ); // V7-E-003: Fresh install state — pending until wizard step is acted on.

		// Grant the mbs_book capabilities to administrator so the auto-generated
		// "All Books" and "Add New Book" submenus render. Idempotent.
		require_once MBS_INCLUDES_DIR . 'class-mbs-capabilities.php';
		MBS_Capabilities::grant_admin_caps();
	} // end is_main_site() guard

	// Single canonical cron hook name: mbs_daily_license_ping
	if ( ! is_multisite() || is_main_site() ) {
		if ( ! wp_next_scheduled( 'mbs_daily_license_ping' ) ) {
			wp_schedule_event( time(), 'daily', 'mbs_daily_license_ping' );
		}
	}

	// mbs_monthly_purge removed Session 163 — no callbacks existed (V3-C-045)

	// Register the CPT and all taxonomies before flushing rewrite rules.
	// flush_rewrite_rules() only writes rules for post types that are already
	// registered at the time of the call. Without this, MBS_Book_Post_Type
	// would not be registered yet (it fires on init, which runs after
	// the activation hook) and books/* URLs would 404.
	require_once MBS_INCLUDES_DIR . 'post-types/class-mbs-book-post-type.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-author.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-series.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-genre.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-tag.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-collection.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-universe.php';
	require_once MBS_INCLUDES_DIR . 'taxonomies/class-mbs-taxonomy-contributor.php';
	MBS_Book_Post_Type::register();
	MBS_Taxonomy_Author::register();
	MBS_Taxonomy_Series::register();
	MBS_Taxonomy_Genre::register();
	MBS_Taxonomy_Tag::register();
	MBS_Taxonomy_Collection::register();
	MBS_Taxonomy_Universe::register();
	MBS_Taxonomy_Contributor::register();

	flush_rewrite_rules( false ); // V6-C-001: Soft flush sufficient; CPTs now registered above.
} );

register_deactivation_hook( __FILE__, function (): void {
	wp_clear_scheduled_hook( 'mbs_daily_license_ping' );
	wp_clear_scheduled_hook( 'mbs_telemetry_weekly_ping' ); // V6-C-006: Clear telemetry cron on deactivate.
	wp_clear_scheduled_hook( 'mbs_cleanup_import_temp_dirs' ); // V8-D-005: Clear temp-dir cleanup cron.
	// S15-1: Clear PA-API sync cron event.
	require_once MBS_INCLUDES_DIR . 'admin/class-mbs-pa-api-sync-scheduler.php';
	MBS_PA_API_Sync_Scheduler::unschedule();
	flush_rewrite_rules( false ); // V6-C-002: Soft flush on deactivation.
} );

// ----------------------------------------------------------------------------
// EXPLICIT REQUIRES FOR HELPERS
// ----------------------------------------------------------------------------
require_once MBS_INCLUDES_DIR . 'helpers/mbs-sort-helpers.php'; // F-SORT-1
require_once MBS_INCLUDES_DIR . 'helpers/class-mbs-input-helpers.php';
require_once MBS_INCLUDES_DIR . 'helpers/class-mbs-icon.php';     // Task 3.0.5 — Phase 3 UX-GL

// ----------------------------------------------------------------------------
// END OF BOOTSTRAP FILE
// ----------------------------------------------------------------------------
