<?php
/**
 * File Path: includes/metaboxes/class-mbs-metabox-classification.php
 *
 * Classification Metabox
 *
 * Standalone "Classification" metabox on the mbs_book edit screen.
 * Renders the Genre Picker (Layer 1 curated + Layer 2 typeahead), BISAC
 * codes section, Thema codes section, front-end display checkboxes,
 * Target Audience, Age Range, Grade Range, Bowker Subject Codes,
 * Content Warnings, and the LCCN field.
 *
 * Save handler implements the soft cap model (D-8, D-9) for BISAC/Thema:
 *   - Accepts new active selections up to the tier limit.
 *   - Appends overflow codes that were previously stored beyond the limit.
 *   - Never discards overflow codes at save time.
 *
 * Meta keys owned by this class (CLASS-6):
 *   mbs_audience_code      ONIX List 28 audience code
 *   mbs_age_range_from     Interest age from (integer string, ONIX List 30 q.17)
 *   mbs_age_range_to       Interest age to   (integer string or \'\' for open-ended)
 *   mbs_grade_range_from   US school grade from (List 77: P,K,1-12)
 *   mbs_grade_range_to     US school grade to   (List 77: P,K,1-12 or \'\')
 *   mbs_bowker_codes       JSON array max 2 Bowker subject code strings
 *   mbs_content_warning    taxonomy (wp_set_object_terms)
 *
 * @package MyBookShowroom
 * @since   1.1.0
 */

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

// phpcs:disable WordPress.Security.NonceVerification -- nonce verified in parent class verify_save() / check_admin_referer() before $_POST access; PHPCS cannot trace cross-method nonce verification
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- input sanitized at point of use (sanitize_text_field, absint, wp_kses_post, etc.); false positives from PHPCS not tracing sanitization helpers called before this method
class MBS_Metabox_Classification extends MBS_Metabox_Base { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	// -------------------------------------------------------------------------
	// Tier limits
	// -------------------------------------------------------------------------

	const TIER_MAX_FREE = 1;
	const TIER_MAX_PRO  = 10;

	// -------------------------------------------------------------------------
	// Bootstrap
	// -------------------------------------------------------------------------

	/**
	 * Constructor — registers hooks.
	 */
	public function __construct() {
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
	}



	/**
	 * Enqueue JS and CSS for the Classification metabox.
	 *
	 * @param string $hook Current admin page hook.
	 * @return void
	 */
	public function enqueue_assets( string $hook ): void {
		if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
			return;
		}
		$screen = get_current_screen();
		if ( ! $screen || MBS_POST_TYPE !== $screen->post_type ) {
			return;
		}

		wp_enqueue_style(
			'mbs-classification',
			MBS_ASSETS_URL . 'css/mbs-admin-classification.css',
			array(),
			MBS_VERSION
		);
		foreach ( array( 'layout', 'genre-picker', 'chips', 'typeahead' ) as $component ) {
			wp_enqueue_style(
				'mbs-classification-' . $component,
				plugins_url( 'assets/css/admin/classification/classification-' . $component . '.css', MBS_DIR . 'bookshowroom.php' ),
				array( 'mbs-classification' ),
				MBS_VERSION
			);
		}

		wp_enqueue_script(
			'mbs-classification',
			MBS_ASSETS_URL . 'js/adminjs/mbs-classification.js',
			array( 'jquery', 'wp-i18n' ),
			MBS_VERSION,
			true
		);
		wp_set_script_translations( 'mbs-classification', 'my-book-showroom', MBS_DIR . 'languages' );

		$tier_max = $this->get_tier_max();

		wp_localize_script(
			'mbs-classification',
			'mbsClassification',
			array(
				'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
				'nonce'      => wp_create_nonce( 'mbs_classification_nonce' ),
				'tierMax'    => $tier_max,
				'groupMap'       => MBS_Genre_Engine::get_group_map(),
				'l2map'          => MBS_Genre_Engine::build_l2map(),
				'isPub'      => mbs_is_pub(),
			)
		);
	}

	// -------------------------------------------------------------------------
	// Render
	// -------------------------------------------------------------------------

	/**
	 * Render the Classification metabox.
	 *
	 * @param WP_Post $post Current post.
	 * @return void
	 */
	public function render( WP_Post $post ): void {
		$tier_max = $this->get_tier_max();
		$is_free  = ! mbs_is_pro() && ! mbs_is_pub();
		$is_pub   = mbs_is_pub();

		// --- ONIX industry genre classification: read saved slugs from the
		// classification table (scheme 'onix_genre'). These are ONIX/industry
		// genre slugs from genre-map.json — entirely separate from the
		// mbs_genre reader-facing taxonomy managed by the Categorization metabox.
		$onix_genre_saved = MBS_Classification_DAO::get_book_codes( $post->ID, 'onix_genre' );

		// --- BISAC codes data -------------------------------------------------
		$bisac_all      = MBS_Classification_DAO::get_book_codes( $post->ID, 'bisac' ); // Phase 1: read from custom table
		$bisac_active   = array_slice( $bisac_all, 0, $tier_max );
		$bisac_overflow = array_slice( $bisac_all, $tier_max );

		// --- Thema codes data -------------------------------------------------
		$thema_all      = MBS_Classification_DAO::get_book_codes( $post->ID, 'thema' ); // Phase 1: read from custom table
		$thema_active   = array_slice( $thema_all, 0, $tier_max );
		$thema_overflow = array_slice( $thema_all, $tier_max );

		// --- Enriched descriptions for active codes ---------------------------
		$bisac_descs = $this->get_code_descriptions( $bisac_active, 'bisac' );
		$thema_descs = $this->get_code_descriptions( $thema_active, 'thema' );

		// --- Display flags ----------------------------------------------------
		$show_genre = get_post_meta( $post->ID, 'mbs_show_genre_public',  true );
		$show_bisac = get_post_meta( $post->ID, 'mbs_show_bisac_public',  true );
		$show_thema = get_post_meta( $post->ID, 'mbs_show_thema_public',  true );

		// --- Audience (ONIX List 28) ------------------------------------------
		$audience_code    = (string) get_post_meta( $post->ID, 'mbs_audience_code',    true );
		$audience_options = MBS_ONIX_Lists::audience_codes();

		// --- Age Range (ONIX List 30 qualifier 17) ----------------------------
		$age_from = (string) get_post_meta( $post->ID, 'mbs_age_range_from', true );
		$age_to   = (string) get_post_meta( $post->ID, 'mbs_age_range_to',   true );

		// --- Grade Range (ONIX List 30 qualifier 11 / List 77) ----------------
		$grade_from = (string) get_post_meta( $post->ID, 'mbs_grade_range_from', true );
		$grade_to   = (string) get_post_meta( $post->ID, 'mbs_grade_range_to',   true );

		// --- Bowker Subject Codes (max 2) — read from mbs_book_classification_codes (R2-003) ---
		$bowker_saved     = MBS_Classification_DAO::get_book_codes( $post->ID, 'bowker' );
		$bowker_primary   = $bowker_saved[0] ?? '';
		$bowker_secondary = $bowker_saved[1] ?? '';
		$bowker_all_codes = array();
		$_bj = MBS_DIR . 'data/bowker-codes.json';
		if ( file_exists( $_bj ) ) {
			$_br = json_decode( (string) file_get_contents( $_bj ), true );
			if ( is_array( $_br ) ) {
				$bowker_all_codes = $_br;
			}
		}

		// --- Content Warnings (taxonomy) --------------------------------------
		$warning_terms     = get_terms( array( 'taxonomy' => MBS_TAX_CONTENT_WARNING, 'hide_empty' => false ) );
		$warning_terms     = is_wp_error( $warning_terms ) ? array() : $warning_terms;
		$assigned_warnings = wp_get_post_terms( $post->ID, MBS_TAX_CONTENT_WARNING, array( 'fields' => 'ids' ) );
		$assigned_warnings = is_wp_error( $assigned_warnings ) ? array() : $assigned_warnings;

		// --- LCCN -------------------------------------------------------------
		$lccn = get_post_meta( $post->ID, 'mbs_lccn', true );

		mbs_get_template_part( 'admin/metabox-classification', compact(
			'tier_max', 'is_free', 'is_pub',
			'onix_genre_saved',
			'bisac_active', 'bisac_overflow', 'bisac_descs',
			'thema_active', 'thema_overflow', 'thema_descs',
			'show_genre', 'show_bisac', 'show_thema',
			'audience_code', 'audience_options',
			'age_from', 'age_to',
			'grade_from', 'grade_to',
			'bowker_primary', 'bowker_secondary', 'bowker_all_codes',
			'warning_terms', 'assigned_warnings',
			'lccn'
		) );
		$this->render_onix_trade_fields( $post );
	}

	// -------------------------------------------------------------------------
	// Save
	// -------------------------------------------------------------------------

	/**
	 * Save all classification fields.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 * @return void
	 */
	public function save( int $post_id, WP_Post $post ): void {
		if ( ! $this->verify_save( $post_id, 'mbs_classification_nonce', 'mbs_classification_save' ) ) {
			return;
		}

		$tier_max = $this->get_tier_max();

		// ---- ONIX industry genre classification slugs -------------------------
		// Slugs from the Classification tab genre picker (genre-map.json).
		// Stored in mbs_book_classification_codes under scheme 'onix_genre'.
		// No relation to the mbs_genre reader-facing taxonomy.
		$onix_genre_slugs = array();
		if ( isset( $_POST['mbs_onix_genres'] ) && is_array( $_POST['mbs_onix_genres'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_onix_genres'] ) as $slug ) {
				$onix_genre_slugs[] = sanitize_key( $slug );
			}
			$onix_genre_slugs = array_values( array_unique( array_filter( $onix_genre_slugs ) ) );
		}

		// ---- BISAC codes — soft cap model -------------------------------------
		$bisac_new = array();
		if ( isset( $_POST['mbs_bisac_codes'] ) && is_array( $_POST['mbs_bisac_codes'] ) ) {
			foreach ( array_slice( array_values( array_unique( wp_unslash( $_POST['mbs_bisac_codes'] ) ) ), 0, $tier_max ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate BISAC code format before storing.
					if ( ! preg_match( '/^[A-Z]{3}[0-9]{6}$/', $clean ) ) {
						continue; // Skip invalid BISAC codes silently.
					}
					$bisac_new[] = $clean;
				}
			}
		}
		$bisac_overflow = array();
		if ( isset( $_POST['mbs_bisac_overflow'] ) && is_array( $_POST['mbs_bisac_overflow'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_bisac_overflow'] ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate BISAC code format before storing.
					if ( ! preg_match( '/^[A-Z]{3}[0-9]{6}$/', $clean ) ) {
						continue; // Skip invalid BISAC codes silently.
					}
					$bisac_overflow[] = $clean;
				}
			}
		}
		$bisac_final = array_values( array_unique( array_merge( $bisac_new, $bisac_overflow ) ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'bisac', $bisac_final );

		// ---- Thema codes — soft cap model -------------------------------------
		$thema_new = array();
		if ( isset( $_POST['mbs_thema_codes'] ) && is_array( $_POST['mbs_thema_codes'] ) ) {
			foreach ( array_slice( array_values( array_unique( wp_unslash( $_POST['mbs_thema_codes'] ) ) ), 0, $tier_max ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate Thema code format before storing.
					if ( ! preg_match( '/^[A-Z][A-Z0-9]{0,4}$/', $clean ) ) {
						continue;
					}
					$thema_new[] = $clean;
				}
			}
		}
		$thema_overflow = array();
		if ( isset( $_POST['mbs_thema_overflow'] ) && is_array( $_POST['mbs_thema_overflow'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_thema_overflow'] ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate Thema code format before storing.
					if ( ! preg_match( '/^[A-Z][A-Z0-9]{0,4}$/', $clean ) ) {
						continue;
					}
					$thema_overflow[] = $clean;
				}
			}
		}
		$thema_final = array_values( array_unique( array_merge( $thema_new, $thema_overflow ) ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'thema', $thema_final );

		// ---- BIC codes — derived from Thema at save time ----------------------------
		$bic_limit = ( self::TIER_MAX_FREE === $tier_max ) ? 1 : 999;
		$bic_codes  = array();
		if ( ! empty( $thema_final ) ) {
			$dao      = new MBS_Classification_DAO();
			$bic_rows = $dao->get_bic_for_thema_batch( $thema_final, $bic_limit );
			foreach ( $bic_rows as $bic_row ) {
				$bic_code = strtoupper( sanitize_text_field( (string) ( $bic_row['bic_code'] ?? '' ) ) );
				if ( '' !== $bic_code ) {
					$bic_codes[] = $bic_code;
				}
			}
			$bic_codes = array_values( array_unique( $bic_codes ) );
		}
		MBS_Classification_DAO::save_book_codes( $post_id, 'bic', $bic_codes );

		// ---- Display flags ----------------------------------------------------
		update_post_meta( $post_id, 'mbs_show_genre_public', isset( $_POST['mbs_show_genre_public'] ) ? '1' : '0' );
		update_post_meta( $post_id, 'mbs_show_bisac_public', isset( $_POST['mbs_show_bisac_public'] ) ? '1' : '0' );
		update_post_meta( $post_id, 'mbs_show_thema_public', isset( $_POST['mbs_show_thema_public'] ) ? '1' : '0' );

		// ---- Audience (ONIX List 28) ------------------------------------------
		$valid_audience = array( '01', '02', '03', '04', '05', '06', '07', '08', '09' );
		$aud_raw = isset( $_POST['mbs_audience_code'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_audience_code'] ) ) : '';
		update_post_meta( $post_id, 'mbs_audience_code',
			in_array( $aud_raw, $valid_audience, true ) ? $aud_raw : '' );

		// ---- Age Range (ONIX List 30 qualifier 17, interest age years) --------
		$valid_ages = array( '3','4','5','6','7','8','9','10','11','12','13',
								'14','15','16','17','18','19','20','21','' );
		$age_from_raw = isset( $_POST['mbs_age_range_from'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_age_range_from'] ) ) : '';
		$age_to_raw   = isset( $_POST['mbs_age_range_to'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_age_range_to'] ) ) : '';
		update_post_meta( $post_id, 'mbs_age_range_from',
			in_array( $age_from_raw, $valid_ages, true ) ? $age_from_raw : '' );
		update_post_meta( $post_id, 'mbs_age_range_to',
			in_array( $age_to_raw, $valid_ages, true ) ? $age_to_raw : '' );

		// ---- Grade Range (ONIX List 30 qualifier 11 / List 77) ----------------
		$valid_grades = array( 'P','K','1','2','3','4','5','6','7','8','9','10','11','12','' );
		$grade_from_raw = isset( $_POST['mbs_grade_range_from'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_grade_range_from'] ) ) : '';
		$grade_to_raw   = isset( $_POST['mbs_grade_range_to'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_grade_range_to'] ) ) : '';
		update_post_meta( $post_id, 'mbs_grade_range_from',
			in_array( $grade_from_raw, $valid_grades, true ) ? $grade_from_raw : '' );
		update_post_meta( $post_id, 'mbs_grade_range_to',
			in_array( $grade_to_raw, $valid_grades, true ) ? $grade_to_raw : '' );

		// ---- Bowker Subject Codes (primary + secondary, max 2) ----------------
		$bowker_final = array();
		foreach ( array( 'mbs_bowker_primary', 'mbs_bowker_secondary' ) as $bf ) {
			if ( isset( $_POST[ $bf ] ) ) {
				$bc = sanitize_text_field( wp_unslash( $_POST[ $bf ] ) );
				if ( '' !== $bc && ! in_array( $bc, $bowker_final, true ) ) {
					$bowker_final[] = $bc;
				}
			}
		}
		MBS_Classification_DAO::save_book_codes( $post_id, 'bowker', array_slice( $bowker_final, 0, 2 ) ); // R2-003

		// ---- Content Warnings (taxonomy) --------------------------------------
		$warning_ids = array();
		if ( isset( $_POST['mbs_cat_warnings'] ) && is_array( $_POST['mbs_cat_warnings'] ) ) {
			$warning_ids = array_map( 'absint', wp_unslash( $_POST['mbs_cat_warnings'] ) );
		}
		wp_set_object_terms( $post_id, $warning_ids, MBS_TAX_CONTENT_WARNING );

		// ---- LCCN --------------------------------------------------------------
		update_post_meta(
			$post_id,
			'mbs_lccn',
			isset( $_POST['mbs_lccn'] ) ? sanitize_text_field( wp_unslash( $_POST['mbs_lccn'] ) ) : ''
		);

		// ---- ONIX genre slugs — persist to classification table, not taxonomy ---
		MBS_Classification_DAO::save_book_codes( $post_id, 'onix_genre', $onix_genre_slugs );
	}

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	/**
	 * Return the active tier code maximum.
	 *
	 * @return int PHP_INT_MAX for PUB (unlimited), 10 for PRO, 1 for FREE.
	 */
	private function get_tier_max(): int {
		if ( mbs_is_pub() ) {
			return PHP_INT_MAX;
		}
		if ( mbs_is_pro() ) {
			return self::TIER_MAX_PRO;
		}
		return self::TIER_MAX_FREE;
	}

	/**
	 * Fetch descriptions for a set of codes from mbs_classification_codes.
	 *
	 * @param string[] $codes  Codes to look up.
	 * @param string   $scheme 'bisac' or 'thema'.
	 * @return array<string, string> Code => description map.
	 */
	private function get_code_descriptions( array $codes, string $scheme ): array {
		if ( empty( $codes ) ) {
			return array();
		}
		$dao = new MBS_Classification_DAO();
		return $dao->get_descriptions_batch( $scheme, $codes );
	}

	/**
	 * Render one editor section within the unified book editor shell.
	 *
	 * Dispatches to the appropriate render method based on $section_key.
	 *
	 * @since  1.0.0
	 * @param  string  $section_key  Section key: 'codes', 'target-audience', or 'trade-data'.
	 * @param  WP_Post $post         Current post object.
	 * @return void
	 */
	public function render_section( string $section_key, WP_Post $post ): void {
		switch ( $section_key ) {
			case 'codes':
				$this->render_codes( $post );
				break;
			case 'target-audience':
				$this->render_target_audience( $post );
				break;
			case 'trade-data':
				$this->render_trade_data( $post );
				break;
		}
	}

	/**
	 * Save one editor section within the unified book editor shell.
	 *
	 * Dispatches to the appropriate save method based on $section_key.
	 * Each section saves only its own fields; all three run on every post save.
	 *
	 * @since  1.0.0
	 * @param  string  $section_key  Section key: 'codes', 'target-audience', or 'trade-data'.
	 * @param  int     $post_id      Post ID.
	 * @param  WP_Post $post         Post object.
	 * @return void
	 */
	public function save_section( string $section_key, int $post_id, WP_Post $post ): void {
		switch ( $section_key ) {
			case 'codes':
				$this->save_codes( $post_id, $post );
				break;
			case 'target-audience':
				$this->save_target_audience( $post_id, $post );
				break;
			case 'trade-data':
				$this->save_trade_data( $post_id );
				break;
		}
	}

	// -------------------------------------------------------------------------
	// ONIX trade fields — moved from Book Details tab (Sprint 178)
	// Table of Contents, Illustration Count, Illustration Types
	// None of these are rendered on the front end; all feed the ONIX export.
	// -------------------------------------------------------------------------

	/**
	 * Render ONIX trade fields appended to the Classification section.
	 *
	 * Called from render() after the main Classification template.
	 * Uses the same mbs_classification_nonce already output by the template.
	 *
	 * @param WP_Post $post Current post.
	 * @return void
	 */
	public function render_onix_trade_fields( WP_Post $post ): void {
		$toc                = (string) get_post_meta( $post->ID, 'mbs_toc',                true );
		$illustration_count = (string) get_post_meta( $post->ID, 'mbs_illustration_count', true );
		$ill_types_saved    = MBS_Classification_DAO::get_book_codes( $post->ID, 'illustration' );
		$ill_type_options   = MBS_ONIX_Lists::illustration_types();
		?>
		<hr style="margin:20px 0 16px;">
		<h3 style="margin-bottom:12px; font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:#50575e;">
			<?php esc_html_e( 'Trade Data', 'my-book-showroom' ); ?>
		</h3>
		<table class="form-table mbs-classification-trade-table">

			<?php /* ── Table of Contents ────────────────────────────────────── */ ?>
			<tr>
				<th scope="row">
					<label for="mbs_toc"><?php esc_html_e( 'Table of Contents', 'my-book-showroom' ); ?></label>
				</th>
				<td>
					<textarea id="mbs_toc" name="mbs_toc"
						class="large-text"
						rows="6"
						placeholder="<?php esc_attr_e( 'Chapter titles, sections, etc.', 'my-book-showroom' ); ?>"><?php echo esc_textarea( $toc ); ?></textarea>
					<p class="description"><?php esc_html_e( 'Exported to ONIX TableOfContents. Not displayed on the front end.', 'my-book-showroom' ); ?></p>
				</td>
			</tr>

			<?php /* ── Illustration Count ──────────────────────────────────── */ ?>
			<tr>
				<th scope="row">
					<label for="mbs_illustration_count"><?php esc_html_e( 'Illustration Count', 'my-book-showroom' ); ?></label>
				</th>
				<td>
					<input type="number"
						id="mbs_illustration_count"
						name="mbs_illustration_count"
						class="small-text"
						min="0" max="9999" step="1"
						value="<?php echo esc_attr( $illustration_count ); ?>">
					<p class="description"><?php esc_html_e( 'ONIX NumberOfIllustrations. Not displayed on the front end.', 'my-book-showroom' ); ?></p>
				</td>
			</tr>

			<?php /* ── Illustration Types (ONIX List 25) ─────────────────── */ ?>
			<tr>
				<th scope="row">
					<button type="button"
					        class="button-link mbs-ill-toggle"
					        aria-expanded="<?php echo ! empty( $ill_types_saved ) ? 'true' : 'false'; ?>"
					        aria-controls="mbs-ill-types-fieldset">
						<span class="mbs-ill-chevron" aria-hidden="true"><?php echo ! empty( $ill_types_saved ) ? '&#9660;' : '&#9654;'; ?></span>
						<?php esc_html_e( 'Illustration Types', 'my-book-showroom' ); ?>
						<span class="mbs-ill-count"><?php
							$_ill_count = count( $ill_types_saved );
							echo $_ill_count > 0 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- integer count() value; esc_html__() escapes the label
								? '(' . $_ill_count . ' ' . esc_html__( 'selected', 'my-book-showroom' ) . ')' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static HTML wrapper + integer count; esc_html__() escapes the label
								: esc_html__( '(none)', 'my-book-showroom' );
						?></span>
					</button>
				</th>
				<td>
					<fieldset id="mbs-ill-types-fieldset"
					          <?php echo empty( $ill_types_saved ) ? 'hidden' : ''; ?>>
					<?php foreach ( $ill_type_options as $code => $label ) : ?>
						<label style="display:inline-block;margin-right:16px;margin-bottom:4px;">
							<input type="checkbox"
								name="mbs_illustration_types[]"
								value="<?php echo esc_attr( $code ); ?>"
								class="mbs-ill-cb"
								<?php checked( in_array( $code, $ill_types_saved, true ) ); ?>>
							<?php echo esc_html( $code . ' — ' . $label ); ?>
						</label>
					<?php endforeach; ?>
					</fieldset>
				</td>
			</tr>

			<script>
			(function(){
				var btn      = document.querySelector('.mbs-ill-toggle');
				if(!btn){return;}
				var fieldset = document.getElementById('mbs-ill-types-fieldset');
				var chevron  = btn.querySelector('.mbs-ill-chevron');
				var countEl  = btn.querySelector('.mbs-ill-count');
				function updateCount(){
					var n = fieldset.querySelectorAll('input.mbs-ill-cb:checked').length;
					countEl.textContent = n > 0 ? '('+n+' selected)' : '(none)';
				}
				btn.addEventListener('click',function(){
					var open = btn.getAttribute('aria-expanded')=='true';
					if(open){
						fieldset.setAttribute('hidden','');
						btn.setAttribute('aria-expanded','false');
						chevron.innerHTML='&#9654;';
					}else{
						fieldset.removeAttribute('hidden');
						btn.setAttribute('aria-expanded','true');
						chevron.innerHTML='&#9660;';
					}
				});
				fieldset.addEventListener('change',updateCount);
			})();
			</script>

		</table>
		<?php
	}


	// -------------------------------------------------------------------------
	// Section render methods (Codes / Target Audience / Trade Data)
	// -------------------------------------------------------------------------

	/**
	 * Render the Codes section (Genre, BISAC, Thema, Display, Bowker, LCCN).
	 *
	 * @param WP_Post $post Current post.
	 * @return void
	 */
	private function render_codes( WP_Post $post ): void {
		$tier_max = $this->get_tier_max();
		$is_free  = ! mbs_is_pro() && ! mbs_is_pub();
		$is_pub   = mbs_is_pub();

		// ONIX industry genre classification: read saved slugs from the
		// classification table (scheme 'onix_genre'). Separate from the
		// mbs_genre reader-facing taxonomy managed by the Categorization metabox.
		$onix_genre_saved = MBS_Classification_DAO::get_book_codes( $post->ID, 'onix_genre' );

		$bisac_all      = MBS_Classification_DAO::get_book_codes( $post->ID, 'bisac' );
		$bisac_active   = array_slice( $bisac_all, 0, $tier_max );
		$bisac_overflow = array_slice( $bisac_all, $tier_max );
		$bisac_descs    = $this->get_code_descriptions( $bisac_active, 'bisac' );

		$thema_all      = MBS_Classification_DAO::get_book_codes( $post->ID, 'thema' );
		$thema_active   = array_slice( $thema_all, 0, $tier_max );
		$thema_overflow = array_slice( $thema_all, $tier_max );
		$thema_descs    = $this->get_code_descriptions( $thema_active, 'thema' );

		$show_genre = get_post_meta( $post->ID, 'mbs_show_genre_public', true );
		$show_bisac = get_post_meta( $post->ID, 'mbs_show_bisac_public', true );
		$show_thema = get_post_meta( $post->ID, 'mbs_show_thema_public', true );

		$bowker_saved     = MBS_Classification_DAO::get_book_codes( $post->ID, 'bowker' );
		$bowker_primary   = $bowker_saved[0] ?? '';
		$bowker_secondary = $bowker_saved[1] ?? '';
		$bowker_all_codes = array();
		$_bj = MBS_DIR . 'data/bowker-codes.json';
		if ( file_exists( $_bj ) ) {
			$_br = json_decode( (string) file_get_contents( $_bj ), true );
			if ( is_array( $_br ) ) {
				$bowker_all_codes = $_br;
			}
		}

		$lccn = get_post_meta( $post->ID, 'mbs_lccn', true );

		mbs_get_template_part( 'admin/metabox-classification-codes', compact(
			'tier_max', 'is_free', 'is_pub',
			'onix_genre_saved',
			'bisac_active', 'bisac_overflow', 'bisac_descs',
			'thema_active', 'thema_overflow', 'thema_descs',
			'show_genre', 'show_bisac', 'show_thema',
			'bowker_primary', 'bowker_secondary', 'bowker_all_codes',
			'lccn'
		) );
	}

	/**
	 * Render the Target Audience section (Audience, Age Range, Grade Range, Warnings).
	 *
	 * @param WP_Post $post Current post.
	 * @return void
	 */
	private function render_target_audience( WP_Post $post ): void {
		$audience_code    = (string) get_post_meta( $post->ID, 'mbs_audience_code',    true );
		$audience_options = MBS_ONIX_Lists::audience_codes();
		$age_from         = (string) get_post_meta( $post->ID, 'mbs_age_range_from',   true );
		$age_to           = (string) get_post_meta( $post->ID, 'mbs_age_range_to',     true );
		$grade_from       = (string) get_post_meta( $post->ID, 'mbs_grade_range_from', true );
		$grade_to         = (string) get_post_meta( $post->ID, 'mbs_grade_range_to',   true );

		$warning_terms     = get_terms( array( 'taxonomy' => MBS_TAX_CONTENT_WARNING, 'hide_empty' => false ) );
		$warning_terms     = is_wp_error( $warning_terms ) ? array() : $warning_terms;
		$assigned_warnings = wp_get_post_terms( $post->ID, MBS_TAX_CONTENT_WARNING, array( 'fields' => 'ids' ) );
		$assigned_warnings = is_wp_error( $assigned_warnings ) ? array() : $assigned_warnings;

		mbs_get_template_part( 'admin/metabox-classification-target-audience', compact(
			'audience_code', 'audience_options',
			'age_from', 'age_to',
			'grade_from', 'grade_to',
			'warning_terms', 'assigned_warnings'
		) );
	}

	/**
	 * Render the Trade Data section (Table of Contents, Illustrations).
	 *
	 * Wraps render_onix_trade_fields() in the CSS scoping div and suppresses
	 * the legacy inline separator/heading that was used when appended to render().
	 *
	 * @param WP_Post $post Current post.
	 * @return void
	 */
	private function render_trade_data( WP_Post $post ): void {
		echo '<div class="mbs-classification-metabox">';
		$this->render_onix_trade_fields( $post );
		echo '</div><!-- .mbs-classification-metabox -->';
	}

	// -------------------------------------------------------------------------
	// Section save methods (Codes / Target Audience / Trade Data)
	// -------------------------------------------------------------------------

	/**
	 * Save the Codes section fields.
	 *
	 * Handles: ONIX industry genre classification, BISAC, Thema, BIC, display flags, Bowker, LCCN.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 * @return void
	 */
	private function save_codes( int $post_id, WP_Post $post ): void {
		if ( ! $this->verify_save( $post_id, 'mbs_classification_nonce', 'mbs_classification_save' ) ) {
			return;
		}

		$tier_max = $this->get_tier_max();

		// ---- ONIX industry genre classification slugs -----------------------------
		// Slugs from the Classification tab genre picker (genre-map.json).
		// Stored under scheme 'onix_genre' — no relation to the mbs_genre taxonomy.
		$onix_genre_slugs = array();
		if ( isset( $_POST['mbs_onix_genres'] ) && is_array( $_POST['mbs_onix_genres'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_onix_genres'] ) as $slug ) {
				$onix_genre_slugs[] = sanitize_key( $slug );
			}
			$onix_genre_slugs = array_values( array_unique( array_filter( $onix_genre_slugs ) ) );
		}

		// ---- BISAC codes — soft cap model -----------------------------------------
		$bisac_new = array();
		if ( isset( $_POST['mbs_bisac_codes'] ) && is_array( $_POST['mbs_bisac_codes'] ) ) {
			foreach ( array_slice( array_values( array_unique( wp_unslash( $_POST['mbs_bisac_codes'] ) ) ), 0, $tier_max ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate BISAC code format before storing.
					if ( ! preg_match( '/^[A-Z]{3}[0-9]{6}$/', $clean ) ) {
						continue; // Skip invalid BISAC codes silently.
					}
					$bisac_new[] = $clean;
				}
			}
		}
		$bisac_overflow = array();
		if ( isset( $_POST['mbs_bisac_overflow'] ) && is_array( $_POST['mbs_bisac_overflow'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_bisac_overflow'] ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate BISAC code format before storing.
					if ( ! preg_match( '/^[A-Z]{3}[0-9]{6}$/', $clean ) ) {
						continue; // Skip invalid BISAC codes silently.
					}
					$bisac_overflow[] = $clean;
				}
			}
		}
		$bisac_final = array_values( array_unique( array_merge( $bisac_new, $bisac_overflow ) ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'bisac', $bisac_final );

		// ---- Thema codes — soft cap model -----------------------------------------
		$thema_new = array();
		if ( isset( $_POST['mbs_thema_codes'] ) && is_array( $_POST['mbs_thema_codes'] ) ) {
			foreach ( array_slice( array_values( array_unique( wp_unslash( $_POST['mbs_thema_codes'] ) ) ), 0, $tier_max ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate Thema code format before storing.
					if ( ! preg_match( '/^[A-Z][A-Z0-9]{0,4}$/', $clean ) ) {
						continue;
					}
					$thema_new[] = $clean;
				}
			}
		}
		$thema_overflow = array();
		if ( isset( $_POST['mbs_thema_overflow'] ) && is_array( $_POST['mbs_thema_overflow'] ) ) {
			foreach ( wp_unslash( $_POST['mbs_thema_overflow'] ) as $code ) {
				$clean = strtoupper( sanitize_text_field( $code ) );
				if ( $clean ) {
					// V8-C-001: Validate Thema code format before storing.
					if ( ! preg_match( '/^[A-Z][A-Z0-9]{0,4}$/', $clean ) ) {
						continue;
					}
					$thema_overflow[] = $clean;
				}
			}
		}
		$thema_final = array_values( array_unique( array_merge( $thema_new, $thema_overflow ) ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'thema', $thema_final );

		// ---- BIC codes — derived from Thema at save time --------------------------
		$bic_limit = ( self::TIER_MAX_FREE === $tier_max ) ? 1 : 999;
		$bic_codes = array();
		if ( ! empty( $thema_final ) ) {
			$dao      = new MBS_Classification_DAO();
			$bic_rows = $dao->get_bic_for_thema_batch( $thema_final, $bic_limit );
			foreach ( $bic_rows as $bic_row ) {
				$bic_code = strtoupper( sanitize_text_field( (string) ( $bic_row['bic_code'] ?? '' ) ) );
				if ( '' !== $bic_code ) {
					$bic_codes[] = $bic_code;
				}
			}
			$bic_codes = array_values( array_unique( $bic_codes ) );
		}
		MBS_Classification_DAO::save_book_codes( $post_id, 'bic', $bic_codes );

		// ---- Display flags --------------------------------------------------------
		update_post_meta( $post_id, 'mbs_show_genre_public', isset( $_POST['mbs_show_genre_public'] ) ? '1' : '0' );
		update_post_meta( $post_id, 'mbs_show_bisac_public', isset( $_POST['mbs_show_bisac_public'] ) ? '1' : '0' );
		update_post_meta( $post_id, 'mbs_show_thema_public', isset( $_POST['mbs_show_thema_public'] ) ? '1' : '0' );

		// ---- Bowker Subject Codes -------------------------------------------------
		$bowker_final = array();
		foreach ( array( 'mbs_bowker_primary', 'mbs_bowker_secondary' ) as $bf ) {
			if ( isset( $_POST[ $bf ] ) ) {
				$bc = sanitize_text_field( wp_unslash( $_POST[ $bf ] ) );
				if ( '' !== $bc && ! in_array( $bc, $bowker_final, true ) ) {
					$bowker_final[] = $bc;
				}
			}
		}
		MBS_Classification_DAO::save_book_codes( $post_id, 'bowker', array_slice( $bowker_final, 0, 2 ) );

		// ---- LCCN -----------------------------------------------------------------
		update_post_meta(
			$post_id,
			'mbs_lccn',
			isset( $_POST['mbs_lccn'] ) ? sanitize_text_field( wp_unslash( $_POST['mbs_lccn'] ) ) : ''
		);

		// ---- ONIX genre slugs — persist to classification table, not taxonomy ------
		MBS_Classification_DAO::save_book_codes( $post_id, 'onix_genre', $onix_genre_slugs );
	}

	/**
	 * Save the Target Audience section fields.
	 *
	 * Handles: ONIX audience code, age range, grade range, content warnings.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 * @return void
	 */
	private function save_target_audience( int $post_id, WP_Post $post ): void {
		if ( ! $this->verify_save( $post_id, 'mbs_classification_nonce', 'mbs_classification_save' ) ) {
			return;
		}

		// ---- Audience (ONIX List 28) ----------------------------------------------
		$valid_audience = array( '01', '02', '03', '04', '05', '06', '07', '08', '09' );
		$aud_raw = isset( $_POST['mbs_audience_code'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_audience_code'] ) ) : '';
		update_post_meta( $post_id, 'mbs_audience_code',
			in_array( $aud_raw, $valid_audience, true ) ? $aud_raw : '' );

		// ---- Age Range (ONIX List 30 qualifier 17) --------------------------------
		$valid_ages = array( '3','4','5','6','7','8','9','10','11','12','13',
							'14','15','16','17','18','19','20','21','' );
		$age_from_raw = isset( $_POST['mbs_age_range_from'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_age_range_from'] ) ) : '';
		$age_to_raw   = isset( $_POST['mbs_age_range_to'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_age_range_to'] ) ) : '';
		update_post_meta( $post_id, 'mbs_age_range_from',
			in_array( $age_from_raw, $valid_ages, true ) ? $age_from_raw : '' );
		update_post_meta( $post_id, 'mbs_age_range_to',
			in_array( $age_to_raw, $valid_ages, true ) ? $age_to_raw : '' );

		// ---- Grade Range (ONIX List 30 qualifier 11 / List 77) -------------------
		$valid_grades = array( 'P','K','1','2','3','4','5','6','7','8','9','10','11','12','' );
		$grade_from_raw = isset( $_POST['mbs_grade_range_from'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_grade_range_from'] ) ) : '';
		$grade_to_raw   = isset( $_POST['mbs_grade_range_to'] )
			? sanitize_text_field( wp_unslash( $_POST['mbs_grade_range_to'] ) ) : '';
		update_post_meta( $post_id, 'mbs_grade_range_from',
			in_array( $grade_from_raw, $valid_grades, true ) ? $grade_from_raw : '' );
		update_post_meta( $post_id, 'mbs_grade_range_to',
			in_array( $grade_to_raw, $valid_grades, true ) ? $grade_to_raw : '' );

		// ---- Content Warnings (taxonomy) ------------------------------------------
		$warning_ids = array();
		if ( isset( $_POST['mbs_cat_warnings'] ) && is_array( $_POST['mbs_cat_warnings'] ) ) {
			$warning_ids = array_map( 'absint', wp_unslash( $_POST['mbs_cat_warnings'] ) );
		}
		wp_set_object_terms( $post_id, $warning_ids, MBS_TAX_CONTENT_WARNING );
	}

	/**
	 * Save the Trade Data section fields (TOC, Illustration Count, Types).
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	private function save_trade_data( int $post_id ): void {
		if ( ! $this->verify_save( $post_id, 'mbs_classification_nonce', 'mbs_classification_save' ) ) {
			return;
		}
		$this->save_onix_trade_fields( $post_id );
	}

	/**
	 * Save ONIX trade fields (TOC, Illustration Count, Illustration Types).
	 *
	 * Called from save() — guarded by the same mbs_classification_nonce.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public function save_onix_trade_fields( int $post_id ): void {
		// mbs_toc.
		update_post_meta(
			$post_id,
			'mbs_toc',
			isset( $_POST['mbs_toc'] )
				? sanitize_textarea_field( wp_unslash( $_POST['mbs_toc'] ) )
				: ''
		);

		// mbs_illustration_count.
		$ill_count_raw = isset( $_POST['mbs_illustration_count'] ) ? wp_unslash( $_POST['mbs_illustration_count'] ) : '';
		update_post_meta(
			$post_id,
			'mbs_illustration_count',
			( '' !== $ill_count_raw ) ? absint( $ill_count_raw ) : ''
		);

		// mbs_illustration_types (ONIX List 25).
		$raw_types   = isset( $_POST['mbs_illustration_types'] ) && is_array( $_POST['mbs_illustration_types'] )
			? wp_unslash( $_POST['mbs_illustration_types'] )
			: array();
		$valid_types = array();
		foreach ( $raw_types as $code ) {
			if ( MBS_ONIX_Lists::is_valid_illustration_type( sanitize_key( $code ) ) ) {
				$valid_types[] = sanitize_key( $code );
			}
		}
		if ( ! empty( $valid_types ) ) {
			MBS_Classification_DAO::save_book_codes( $post_id, 'illustration', $valid_types );
		} else {
			MBS_Classification_DAO::delete_book_codes( $post_id, 'illustration' );
		}
	}

}
