<?php
/**
 * File Path: includes/class-mbs-dummy-book-installer.php
 *
 * Dummy Book Installer
 *
 * Installs the built-in demo book “The Shadow Architect” on first activation.
 * Creates the book post privately and populates all five format rows, buy
 * links, taxonomy terms, endorsements, contributors, classification codes,
 * awards, and cover images from the seed data defined in
 * PATCHES/shadow-architect-seed-data.md (Session 166).
 *
 * Cover images are sideloaded directly via media_handle_sideload() rather
 * than via MBS_Cover_Sideloader because the bundled placeholder images
 * (400×600 px and 432×432 px) fall below the 500 px hard-floor check.
 * Each source file is copied to a unique temp path before sideloading so
 * that WordPress’s rename operation never removes the originals from
 * assets/dummy/.
 *
 * Write rule: WRITE-PROTOCOL.md §A + §B.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

/**
 * Installs the built-in “The Shadow Architect” demo book on activation.
 *
 * Idempotent: checks both the mbs_dummy_book_installed option and for any
 * existing mbs_book post before inserting. All format rows, buy links,
 * taxonomy terms, endorsements, contributors, classification codes, awards,
 * and cover images are populated from the seed data.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */
class MBS_Dummy_Book_Installer { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/**
	 * Install the demo book if it has not been installed yet.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	public static function install(): void {
		// PLG-NEW-011: data-gated idempotency. Do NOT rely on the
		// mbs_dummy_book_installed option alone. That option can fall out of
		// sync with reality -- e.g. cleared by a reset or delete/reinstall cycle
		// while the demo posts survive -- in which case the old option-gated
		// guard was skipped entirely and install() inserted a fresh
		// "The Shadow Architect" on every activation with no existing-copy
		// check, producing duplicates. Always check the actual posts.
		//
		// A partial install (bare four-field original installer, or an
		// interrupted run) leaves a demo post with fewer than 5 format rows;
		// purge and reinstall rather than returning early.
		if ( self::is_fully_installed() ) {
			// A complete demo book already exists; keep the option in sync and bail.
			update_option( 'mbs_dummy_book_installed', true );
			return;
		}
		self::purge_demo_book();

		// Register the post type and taxonomy stubs if they are not yet
		// registered. Required during activation: plugins_loaded and init
		// have already fired WITHOUT MBS loaded, so MBS_Plugin::get_instance()
		// was never called. Without this guard, wp_insert_post() will reject
		// the post type and wp_set_object_terms() will silently discard all
		// term assignments (Session 166 Bug B -- activation timing).
		self::ensure_post_type();
		self::ensure_taxonomies();

		$post_id = wp_insert_post(
			array(
				'post_title'   => 'The Shadow Architect',
				'post_content' => self::long_text( 'synopsis' ),
				'post_status'  => 'private',
				'post_type'    => MBS_POST_TYPE,
			),
			true
		);

		if ( is_wp_error( $post_id ) || ! $post_id ) {
			return;
		}

		// Stamp the demo-book flag before any other writes so that
		// is_fully_installed() and purge_demo_book() can locate this post
		// even if a subsequent step fails partway through the install.
		update_post_meta( $post_id, '_mbs_demo_book', '1' );

		self::save_book_meta( $post_id );
		self::assign_taxonomies( $post_id );
		self::save_author_meta( $post_id );
		self::sideload_author_photo( $post_id );
		$cover_ids      = self::sideload_covers( $post_id );
		$format_row_ids = self::insert_format_rows( $post_id, $cover_ids );
		self::set_format_row_pub_fields( $format_row_ids );
		self::insert_format_buy_links( $post_id, $format_row_ids );
		self::insert_platform_links( $post_id );
		self::insert_endorsements( $post_id );
		self::insert_contributors( $post_id );
		self::insert_classification( $post_id );

		update_option( 'mbs_dummy_book_installed', true );
	}

	// -------------------------------------------------------------------------
	// Long-text content helpers
	// -------------------------------------------------------------------------

	/**
	 * Return a long demo text string by key.
	 *
	 * Centralises multi-paragraph content so install() stays concise.
	 *
	 * @param  string $key 'synopsis' | 'toc' | 'sample'
	 * @return string
	 */
	private static function long_text( string $key ): string {
		switch ( $key ) {
			case 'synopsis':
				return "When forensic architect Mara Voss is called to assess a condemned building in Philadelphia, she finds something that does not belong: a chamber hidden behind a load-bearing column, sealed with plaster and sixty years of silence. It does not appear on any blueprint on record.\n\nShe has seen this before. Seven times. Different buildings, different decades, different cities. The same proportions. The same concealed door mechanism. The same unsigned permit stamp leading to a dead-end address. And every single building has been the site of a disappearance that was never solved.\n\nThe one person who might have answers has just been found sealed inside a wall in downtown Chicago.\n\nMara has forty-eight hours before the demolition crew arrives. Before the last room is destroyed. Before the pattern disappears into rubble.\n\nAnd before whoever has been signing their work in hidden spaces realises she has finally learned how to read it.\n\nThe Shadow Architect is a dark, propulsive standalone thriller set in the places we never think to look — the spaces behind the walls of the buildings we trust.";

			case 'toc':
				return "Part One: Load-Bearing\nChapter 1 — The Philadelphia Assessment\nChapter 2 — What the Walls Remember\nChapter 3 — Cross-Reference\nChapter 4 — The Permit Stamp\nChapter 5 — Dead End, Dead Address\nPart Two: Structural\nChapter 6 — Chicago\nChapter 7 — Six Before It\nChapter 8 — The Proportions\nChapter 9 — Sixty Years\nChapter 10 — Unsigned\nPart Three: Demolition Order\nChapter 11 — Forty-Eight Hours\nChapter 12 — The Seventh Room\nChapter 13 — What She Found in the Wall\nChapter 14 — The Architect\nChapter 15 — Final Assessment";

			case 'sample':
				return "The building should have come down six months ago. Three successive owners, two failed renovation bids, and a fire that wasn’t quite accidental enough to trigger an insurance payout — Voss Property Forensics had been hired to determine what, exactly, was holding it together, and whether the answer had anything to do with why no one seemed willing to touch it.\n\nMara parked on the diagonal and looked up at the facade. Six stories. Neo-Gothic revival, 1907, with the slightly embarrassed ornamentation of a building whose original client had run out of money before the architect ran out of ideas. She could date the three failed renovation attempts by the caulk lines: 1988, 2003, 2021. Someone had put new windows in the third floor sometime in the nineties and never come back to finish.\n\nShe pulled her site bag from the passenger seat and walked toward the entrance.\n\nThe door was unlocked.\n\nThat was the first thing wrong.";

			default:
				return '';
		}
	}

	// -------------------------------------------------------------------------
	// Book-level postmeta
	// -------------------------------------------------------------------------

	/**
	 * Save all book-level postmeta and mbs_book_meta table entries.
	 *
	 * Fields in MBS_Book_Meta_DAO::KEY_MAP are written to both postmeta and
	 * the mbs_book_meta lookup table via paired update_post_meta() /
	 * MBS_Book_Meta_DAO::upsert() calls.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function save_book_meta( int $post_id ): void {
		$scalar = array(
			'mbs_book_title'               => 'The Shadow Architect',
			'mbs_tagline'                  => 'Someone designed the perfect hiding place. Again and again.',
			'mbs_teaser'                   => "A forensic architect. Seven hidden chambers. One impossible pattern spanning sixty years. When Mara Voss finds the same unsigned blueprint hidden in the bones of seven different buildings — all scenes of unsolved disappearances — she has forty-eight hours to follow the thread before the last building falls. And before whoever designed these rooms realises she has found them.",
			'mbs_synopsis'                 => self::long_text( 'synopsis' ),
			'mbs_publisher'                => 'Architrave Press',
			'mbs_publisher_url'            => 'https://architravepress.example.com',
			'mbs_imprint'                  => 'Shadow Line Books',
			'mbs_pub_date'                 => '2025-09-02',
			'mbs_copyright_year'           => '2025',
			'mbs_language'                 => 'eng',
			'mbs_edition_statement'        => 'First Edition',
			'mbs_toc'                      => self::long_text( 'toc' ),
			'mbs_lccn'                     => '2025012874',
			'mbs_illustration_count'       => '7',
			'mbs_illustration_types'       => "DRW (architectural drawings — one per part, unlabelled)",
			'mbs_show_word_count'          => '1',
			'mbs_enable_reader_reviews'    => '1',
			'mbs_show_genre_public'        => '1',
			'mbs_show_bisac_public'        => '0',
			'mbs_show_thema_public'        => '0',
			'mbs_share_text'               => "I just discovered The Shadow Architect by Paige Turner — a thriller about the rooms hidden inside walls.",
			'mbs_share_section_enabled'    => '1',
			'_mbs_admin_notes'             => "Built-in demo book. Installed privately on activation. Do not publish. Do not delete — re-activation restores it. All ISBNs use the 979-9 prefix (unassigned by the International ISBN Agency). All ASINs are syntactically valid but fictional.",
			'mbs_audience_code'            => '01',
		);

		foreach ( $scalar as $key => $value ) {
			update_post_meta( $post_id, $key, $value );
		}

		// Awards stored via MBS_Book_Awards_DAO (R1-003). Statuses must match MBS_Book_Header_Extended_Meta::award_statuses().
		MBS_Book_Awards_DAO::save( $post_id, array(
			array( 'name' => "International Thriller Writers Award — Best First Novel", 'year' => '2026', 'status' => 'winner' ),
			array( 'name' => "Edgar Award — Best Novel",                               'year' => '2026', 'status' => 'finalist' ),
			array( 'name' => "Goodreads Choice Award — Thriller & Suspense",           'year' => '2025', 'status' => 'longlist' ),
		) );

		// Bowker subject codes -- stored via MBS_Classification_DAO (R2-003). Values must match data/bowker-codes.json.
		MBS_Classification_DAO::save_book_codes( $post_id, 'bowker', array(
			'FICTION_PSYCHOLOGICAL',
			'FICTION_MYSTERY_AND_DETECTIVE_GENERAL',
		) );

		// Age range: 13+ open upper bound ('and Up' stored as empty string).
		update_post_meta( $post_id, 'mbs_age_range_from', '13' );
		update_post_meta( $post_id, 'mbs_age_range_to',   '' );

		// Grade range: Grade 9 and up, open upper bound.
		update_post_meta( $post_id, 'mbs_grade_range_from', '9' );
		update_post_meta( $post_id, 'mbs_grade_range_to',   '' );

		// Fields stored in both postmeta and the mbs_book_meta lookup table.
		update_post_meta( $post_id, 'mbs_lifecycle_state', 'active' );
		MBS_Book_Meta_DAO::upsert( $post_id, 'mbs_lifecycle_state', 'active' );

		update_post_meta( $post_id, 'mbs_featured', '1' );
		MBS_Book_Meta_DAO::upsert( $post_id, 'mbs_featured', '1' );

		update_post_meta( $post_id, 'mbs_coming_soon', '0' );
		MBS_Book_Meta_DAO::upsert( $post_id, 'mbs_coming_soon', '0' );

		update_post_meta( $post_id, 'mbs_word_count', '94200' );
		MBS_Book_Meta_DAO::upsert( $post_id, 'mbs_word_count', '94200' );
	}

	// -------------------------------------------------------------------------
	// Taxonomy terms
	// -------------------------------------------------------------------------

	/**
	 * Assign taxonomy terms to the demo book.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function assign_taxonomies( int $post_id ): void {
		wp_set_object_terms( $post_id, array( 'Paige Turner' ),     'mbs_author' );
		wp_set_object_terms( $post_id, array( 'Architrave Press' ), 'mbs_publisher' );
		wp_set_object_terms( $post_id, array( 'Shadow Line Books' ), 'mbs_imprint' );
		wp_set_object_terms( $post_id, array( 'Thriller', 'Architectural Fiction' ), 'mbs_genre' );
		wp_set_object_terms(
			$post_id,
			array(
				'Forensic Architecture',
				'Hidden Rooms',
				'Cold Case',
				'Female Protagonist',
				'Standalone',
				'Debut Novel',
				'Psychological Thriller',
			),
			'mbs_tag'
		);
	}

	// -------------------------------------------------------------------------
	// Author term metadata
	// -------------------------------------------------------------------------

	/**
	 * Write term meta for the primary author (Paige Turner).
	 *
	 * Called after assign_taxonomies() so the mbs_author term is guaranteed
	 * to exist. Adds bio, inverted name, and birth year -- all three fields
	 * scored by MBS_Book_Bible_Field_Counter for the primary author.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function save_author_meta( int $post_id ): void {
		$term = get_term_by( 'name', 'Paige Turner', MBS_TAX_AUTHOR );
		if ( ! $term || is_wp_error( $term ) ) {
			return;
		}
		$tid = (int) $term->term_id;
		update_term_meta(
			$tid,
			'mbs_author_bio',
			'Paige Turner is a structural engineer turned crime novelist based in Philadelphia. Before turning to fiction, she spent fifteen years in forensic architecture, assessing condemned and structurally compromised buildings across the northeastern United States. The Shadow Architect is her debut novel.'
		);
		update_term_meta( $tid, 'mbs_name_inverted',     'Turner, Paige' );
		update_term_meta( $tid, 'mbs_author_birth_year', '1983' );
	}

	// -------------------------------------------------------------------------
	// Cover image sideload
	// -------------------------------------------------------------------------

	/**
	 * Sideload all five demo cover images into the WordPress media library.
	 *
	 * Bypasses MBS_Cover_Sideloader dimension validation deliberately: the
	 * bundled placeholder images (400×600 and 432×432 px) fall below the
	 * 500 px hard-floor check. Size quality rules do not apply to demo content.
	 *
	 * Each plugin asset is copied to a unique temp path before calling
	 * media_handle_sideload() so that WordPress’s rename step never removes
	 * the originals from assets/dummy/. Sets cover-fallback.jpg as the post
	 * thumbnail (main header cover).
	 *
	 * @param  int                $post_id Book post ID.
	 * @return array<string,int>  Map of slug to attachment ID (0 on failure).
	 */
	private static function sideload_covers( int $post_id ): array {
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$base    = MBS_DIR . 'assets/dummy/';
		$tmp_dir = get_temp_dir();

		$map = array(
			'fallback'  => 'cover-fallback.jpg',
			'ebook'     => 'cover-ebook.png',
			'paperback' => 'cover-paperback.png',
			'hardcover' => 'cover-hardcover.png',
			'audiobook' => 'cover-audiobook.png',
		);

		$ids = array_fill_keys( array_keys( $map ), 0 );

		foreach ( $map as $key => $filename ) {
			$src = $base . $filename;
			if ( ! file_exists( $src ) ) {
				continue;
			}
			// Copy to a temp path: media_handle_sideload() moves (renames) its
			// tmp_name, which would remove the original plugin asset.
			$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
			$tmp = $tmp_dir . 'mbs-dummy-' . $key . '-' . wp_generate_password( 8, false ) . '.' . $ext;
			if ( ! copy( $src, $tmp ) ) {
				continue;
			}
			$file_array = array(
				'name'     => sanitize_file_name( $filename ),
				'tmp_name' => $tmp,
			);
			$alt       = 'The Shadow Architect ' . ucfirst( $key ) . ' cover';
			$attach_id = media_handle_sideload( $file_array, $post_id, $alt );
			if ( is_wp_error( $attach_id ) ) {
				wp_delete_file( $tmp );
				continue;
			}
			$ids[ $key ] = (int) $attach_id;
			update_post_meta( $attach_id, '_wp_attachment_image_alt', $alt );
			if ( file_exists( $tmp ) ) {
				wp_delete_file( $tmp );
			}
		}

		if ( $ids['fallback'] > 0 ) {
			set_post_thumbnail( $post_id, $ids['fallback'] );
		}

		return $ids;
	}

	// -------------------------------------------------------------------------
	// Author photo sideload
	// -------------------------------------------------------------------------

	/**
	 * Sideload the author headshot and set it as Paige Turner's profile photo.
	 *
	 * Copies assets/dummy/author-paige-turner.jpg to a temp path, sideloads it
	 * into the media library with $post_id as parent (so purge_demo_book() cleans
	 * it up via its existing post_parent query), then writes the attachment ID to
	 * the mbs_author_photo_id term meta on the Paige Turner author term.
	 *
	 * Returns silently if the source file is missing or the term does not exist.
	 *
	 * @param  int  $post_id Book post ID (used as attachment parent).
	 * @return void
	 */
	private static function sideload_author_photo( int $post_id ): void {
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$filename = 'author-paige-turner.jpg';
		$src      = MBS_DIR . 'assets/dummy/' . $filename;
		if ( ! file_exists( $src ) ) {
			return;
		}

		$term = get_term_by( 'name', 'Paige Turner', MBS_TAX_AUTHOR );
		if ( ! $term || is_wp_error( $term ) ) {
			return;
		}

		$tmp = get_temp_dir() . 'mbs-dummy-author-' . wp_generate_password( 8, false ) . '.jpg';
		if ( ! copy( $src, $tmp ) ) {
			return;
		}

		$file_array = array(
			'name'     => sanitize_file_name( $filename ),
			'tmp_name' => $tmp,
		);

		$attach_id = media_handle_sideload( $file_array, $post_id, 'Paige Turner author photo' );
		if ( is_wp_error( $attach_id ) ) {
			wp_delete_file( $tmp );
			return;
		}

		update_post_meta( $attach_id, '_wp_attachment_image_alt', 'Paige Turner author photo' );
		update_term_meta( (int) $term->term_id, 'mbs_author_photo_id', $attach_id );

		if ( file_exists( $tmp ) ) {
			wp_delete_file( $tmp );
		}
	}

	// -------------------------------------------------------------------------
	// Format rows
	// -------------------------------------------------------------------------

	/**
	 * Insert all five demo format rows.
	 *
	 * ISBNs use the 979-9 prefix (unassigned by the International ISBN Agency)
	 * and carry mathematically valid check digits, so they pass the plugin’s
	 * normalize_isbn() check without matching any real book.
	 *
	 * @param  int               $post_id   Book post ID.
	 * @param  array<string,int> $cover_ids Attachment IDs keyed by format slug.
	 * @return array<string,int> Format row IDs keyed by format slug.
	 */
	private static function insert_format_rows( int $post_id, array $cover_ids ): array {
		$sample = self::long_text( 'sample' );

		$formats = array(
			'ebook' => array(
				'format'         => 'ebook',
				'isbn13'         => '9799000000103',
				'asin'           => 'BSHADOW0001',
				'price'          => '6.99',
				'page_length'    => 342,
				'edition_number' => 1,
				'edition_type'   => 'NED',
				'file_format'    => 'epub',
				'pub_date'       => '2025-09-02',
				'cover_image_id' => $cover_ids['ebook'] ?: null,
			),
			'paperback' => array(
				'format'         => 'paperback',
				'isbn13'         => '9799000000110',
				'price'          => '16.99',
				'page_length'    => 342,
				'edition_number' => 1,
				'edition_type'   => 'NED',
				'pub_date'       => '2025-09-02',
				'cover_image_id' => $cover_ids['paperback'] ?: null,
			),
			'hardcover' => array(
				'format'         => 'hardcover',
				'subtitle'       => 'First Edition',
				'isbn13'         => '9799000000127',
				'price'          => '27.99',
				'page_length'    => 342,
				'edition_number' => 1,
				'edition_type'   => 'NED',
				'pub_date'       => '2025-09-02',
				'cover_image_id' => $cover_ids['hardcover'] ?: null,
			),
			'audiobook' => array(
				'format'         => 'audiobook',
				'subtitle'       => 'Unabridged',
				'isbn13'         => '9799000000134',
				'asin'           => 'BSHADOW0004',
				'price'          => '24.99',
				'narrator'       => 'Victoria Strand',
				'duration_hh'    => 10,
				'duration_mm'    => 12,
				'edition_number' => 1,
				'edition_type'   => 'NED',
				'pub_date'       => '2025-09-02',
				'cover_image_id' => $cover_ids['audiobook'] ?: null,
			),
			'boardbook' => array(
				'format'         => 'boardbook',
				'subtitle'       => "Illustrated Collector’s Edition",
				'isbn13'         => '9799000000141',
				'price'          => '19.99',
				'page_length'    => 48,
				'edition_number' => 1,
				'edition_type'   => 'ILL',
				'pub_date'       => '2025-09-02',
				'cover_image_id' => $cover_ids['audiobook'] ?: null,
			),
		);

		$row_ids = array();
		foreach ( $formats as $slug => $data ) {
			$row_id           = MBS_Format_Row_DAO::save( $post_id, $data );
			$row_ids[ $slug ] = $row_id ?: 0;
		}
		// Write Look Inside postmeta — $sample is plain text; wpautop() converts to <p> tags.
		update_post_meta( $post_id, 'mbs_look_inside_text',
			MBS_Look_Inside_Sanitizer::clean( wpautop( $sample ), false, 0 )
		);

		return $row_ids;
	}

	// -------------------------------------------------------------------------
	// PUB-tier format row fields
	// -------------------------------------------------------------------------

	/**
	 * Write PUB-tier supply-chain fields onto the format rows after insert.
	 *
	 * Separated from insert_format_rows() so that a missing returnability /
	 * price_type / supplier_name column (migration 2.6 not yet run) will not
	 * cause MBS_Format_Row_DAO::save() to fail and leave zero format rows.
	 *
	 * Values follow ONIX 3.0 List 65 (price type) and List 66 (returnability):
	 *   price_type    '01' = RRP excluding tax
	 *   returnability '01' = Non-returnable, '02' = Returnable
	 *
	 * @param  array<string,int> $row_ids Format row IDs keyed by format slug.
	 * @return void
	 */
	private static function set_format_row_pub_fields( array $row_ids ): void {
		// ONIX returnability: '01' = non-returnable, '02' = returnable.
		// ONIX price type:    '01' = RRP excluding tax.
		$pub = array(
			'ebook'     => array( 'returnability' => '01', 'price_type' => '01', 'supplier_name' => 'Draft2Digital' ),
			'paperback' => array( 'returnability' => '02', 'price_type' => '01', 'supplier_name' => 'Ingram Content Group' ),
			'hardcover' => array( 'returnability' => '02', 'price_type' => '01', 'supplier_name' => 'Ingram Content Group' ),
			'audiobook' => array( 'returnability' => '01', 'price_type' => '01', 'supplier_name' => 'Findaway Voices' ),
			'boardbook' => array( 'returnability' => '02', 'price_type' => '01', 'supplier_name' => 'Ingram Content Group' ),
		);

		foreach ( $pub as $slug => $fields ) {
			$row_id = $row_ids[ $slug ] ?? 0;
			if ( ! $row_id ) {
				continue;
			}
			MBS_Format_Row_DAO::set_pub_fields( $row_id, $fields );
		}
	}

	// -------------------------------------------------------------------------
	// Buy links
	// -------------------------------------------------------------------------

	/**
	 * Insert buy links for every format row.
	 *
	 * DA-001 compliance: format_row_id is always the row’s integer ID for
	 * format-level links. The key is never set to null; it is simply absent
	 * when a link has no format association. This installer creates only
	 * format-level links (no shared book-level links).
	 *
	 * retailer_key is omitted from the data array when null so that
	 * MBS_Buy_Link_DAO::save() uses its empty-string default.
	 *
	 * @param  int               $post_id        Book post ID.
	 * @param  array<string,int> $format_row_ids Format row IDs keyed by slug.
	 * @return void
	 */
	/**
	 * Insert demo reader-platform links (Goodreads, StoryGraph, BookBub) for the
	 * dummy book. Stored as link_type = 'platform' rows in mbs_buy_links.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function insert_platform_links( int $post_id ): void {
		$platforms = array(
			array( 'goodreads',  'https://www.goodreads.com/book/show/00000000-the-shadow-architect' ),
			array( 'storygraph', 'https://app.thestorygraph.com/books/00000000-shadow-architect' ),
			array( 'bookbub',    'https://www.bookbub.com/authors/paige-turner' ),
		);
		foreach ( $platforms as $i => $p ) {
			MBS_Buy_Link_DAO::save( array(
				'post_id'       => $post_id,
				'url'           => $p[1],
				'retailer_key'  => $p[0],
				'link_type'     => 'platform',
				'format_row_id' => null,
				'sort_order'    => $i,
			) );
		}
	}

	private static function insert_format_buy_links( int $post_id, array $format_row_ids ): void {
		$author = 'https://paigeturner.example.com/shadow-architect';

		$spec = array(
			'ebook' => array(
				array( 'https://amazon.com/dp/BSHADOW0001',                                        'amazon' ),
				array( 'https://books.apple.com/us/book/the-shadow-architect/id00000000',           'apple' ),
				array( 'https://www.kobo.com/us/en/search?query=9799000000103',                    'kobo' ),
				array( 'https://www.barnesandnoble.com/s/9799000000103',                           'bn' ),
				array( 'https://books.google.com/books?vid=ISBN9799000000103',                     'google' ),
				array( $author,                                                                     null ),
			),
			'paperback' => array(
				array( 'https://amazon.com/dp/9799000000110',                                      'amazon' ),
				array( 'https://www.barnesandnoble.com/s/9799000000110',                           'bn' ),
				array( 'https://bookshop.org/p/books/the-shadow-architect/00000000',               'bookshop' ),
				array( $author,                                                                     null ),
			),
			'hardcover' => array(
				array( 'https://amazon.com/dp/9799000000127',                                      'amazon' ),
				array( 'https://www.barnesandnoble.com/s/9799000000127',                           'bn' ),
				array( 'https://bookshop.org/p/books/the-shadow-architect/00000001',               'bookshop' ),
				array( 'https://www.indiebound.org/book/9799000000127',                            null ),
				array( $author,                                                                     null ),
			),
			'audiobook' => array(
				array( 'https://www.audible.com/pd/BSHADOW0004',                                   'audible' ),
				array( 'https://books.apple.com/us/audiobook/the-shadow-architect-unabridged/id00000001', 'apple' ),
				array( 'https://libro.fm/audiobooks/9799000000134',                                null ),
				array( $author,                                                                     null ),
			),
			'boardbook' => array(
				array( $author, null ),
			),
		);

		$order = 0;
		foreach ( $spec as $slug => $links ) {
			$row_id = $format_row_ids[ $slug ] ?? 0;
			if ( ! $row_id ) {
				continue;
			}
			foreach ( $links as [ $url, $retailer_key ] ) {
				$data = array(
					'post_id'       => $post_id,
					'format_row_id' => $row_id,
					'url'           => $url,
					'sort_order'    => $order++,
				);
				if ( null !== $retailer_key ) {
					$data['retailer_key'] = $retailer_key;
				}
				MBS_Buy_Link_DAO::save( $data );
			}
		}
	}

	// -------------------------------------------------------------------------
	// Endorsements
	// -------------------------------------------------------------------------

	/**
	 * Insert the three demo endorsements via MBS_Endorsement_DAO.
	 *
	 * The post_data array mirrors the shape of $_POST submitted by the
	 * endorsements metabox. mbs_endorsement_visible is keyed by index;
	 * presence of the key at index $i signals visible = 1.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function insert_endorsements( int $post_id ): void {
		MBS_Endorsement_DAO::save_from_post(
			$post_id,
			array(
				'mbs_endorsement_quote' => array(
					0 => "A masterwork of architectural suspense. Turner knows exactly where to hide the shadows — and exactly when to reveal them.",
					1 => 'I read it in one sitting and checked every room in my house afterward. A stunning debut.',
					2 => 'Forensic architecture meets psychological thriller in this propulsive, terrifying debut. Five stars.',
				),
				'mbs_endorsement_attributed_to' => array(
					0 => 'James Morley',
					1 => 'Sarah Quinn',
					2 => 'Publishers Weekly',
				),
				'mbs_endorsement_attribution_title' => array(
					0 => 'New York Times bestselling author',
					1 => 'Author of The Lock Picker',
					2 => "★★★★★",
				),
				'mbs_endorsement_visible' => array( 0 => '1', 1 => '1', 2 => '1' ),
			)
		);
	}

	// -------------------------------------------------------------------------
	// Contributors
	// -------------------------------------------------------------------------

	/**
	 * Create or retrieve mbs_contributor taxonomy terms and insert pivot rows.
	 *
	 * get_term_by() is called before wp_insert_term() to avoid duplicate
	 * term errors on repeated activations or test runs.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function insert_contributors( int $post_id ): void {
		$contributors = array(
			array( 'name' => 'Paige Turner', 'onix_role_code' => 'A01', 'role_label' => 'By (author)',     'sort_order' => 0 ),
			array( 'name' => 'Dominic Rael',  'onix_role_code' => 'A12', 'role_label' => 'Cover design by', 'sort_order' => 1 ),
		);

		foreach ( $contributors as $c ) {
			$term = get_term_by( 'name', $c['name'], 'mbs_contributor' );
			if ( $term ) {
				$term_id = (int) $term->term_id;
			} else {
				$inserted = wp_insert_term( $c['name'], 'mbs_contributor' );
				if ( is_wp_error( $inserted ) ) {
					continue;
				}
				$term_id = (int) $inserted['term_id'];
			}

			MBS_Contributor_Book_DAO::insert( array(
				'post_id'        => $post_id,
				'term_id'        => $term_id,
				'onix_role_code' => $c['onix_role_code'],
				'role_label'     => $c['role_label'],
				'sort_order'     => $c['sort_order'],
				'public'         => 1,
				'statement'      => '',
			) );
		}
	}

	// -------------------------------------------------------------------------
	// Classification codes
	// -------------------------------------------------------------------------

	/**
	 * Save BISAC, Thema, and BIC classification codes.
	 *
	 * @param  int  $post_id Book post ID.
	 * @return void
	 */
	private static function insert_classification( int $post_id ): void {
		MBS_Classification_DAO::save_book_codes( $post_id, 'bisac', array( 'FIC031010', 'FIC022000', 'FIC030000' ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'thema', array( 'FHP', 'FH', 'FRH' ) );
		MBS_Classification_DAO::save_book_codes( $post_id, 'bic',   array( 'FFP', 'FF' ) );
	}

	// -------------------------------------------------------------------------
	// Idempotency and activation-timing helpers
	// -------------------------------------------------------------------------

	/**
	 * Check whether the demo book is fully installed.
	 *
	 * Returns true only when: (a) a post flagged with _mbs_demo_book = '1'
	 * exists in postmeta, AND (b) that post has at least 5 rows in
	 * mbs_format_rows (one per format). A count below 5 means the original
	 * bare installer ran but the full rewrite never executed.
	 *
	 * @since  1.0.0
	 * @return bool
	 */
	private static function is_fully_installed(): bool {
		global $wpdb;

		$post_id = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
			'SELECT post_id FROM ' . $wpdb->postmeta . ' WHERE meta_key = %s AND meta_value = %s LIMIT 1',
			'_mbs_demo_book',
			'1'
		) );

		if ( ! $post_id ) {
			return false;
		}

		$count = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
			'SELECT COUNT(*) FROM ' . $wpdb->prefix . 'mbs_format_rows WHERE post_id = %d',
			$post_id
		) );

		return $count >= 5;
	}

	/**
	 * Remove the demo book and all related custom-table rows, then clear
	 * the mbs_dummy_book_installed option so install() will run again.
	 *
	 * Primary lookup is by the _mbs_demo_book postmeta flag. Falls back to
	 * a title/post-type query for installs created before the flag existed
	 * (i.e. the bare four-field book from the original installer).
	 *
	 * @since  1.0.0
	 * @return void
	 */
	private static function purge_demo_book(): void {
		global $wpdb;

		$post_id = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
			'SELECT post_id FROM ' . $wpdb->postmeta . ' WHERE meta_key = %s AND meta_value = %s LIMIT 1',
			'_mbs_demo_book',
			'1'
		) );

		if ( ! $post_id ) {
			// Fallback: bare four-field book has no _mbs_demo_book flag.
			$post_id = (int) $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
				'SELECT ID FROM ' . $wpdb->posts . ' WHERE post_title = %s AND post_type = %s LIMIT 1',
				'The Shadow Architect',
				MBS_POST_TYPE
			) );
		}

		delete_option( 'mbs_dummy_book_installed' );

		if ( ! $post_id ) {
			return;
		}

		$prefix = $wpdb->prefix;

		// Collect format row IDs before deleting so buy links keyed on
		// format_row_id can be removed first.
		$row_ids = $wpdb->get_col( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
			'SELECT id FROM ' . $prefix . 'mbs_format_rows WHERE post_id = %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- safe: $prefix is $wpdb->prefix (trusted); query wrapped in $wpdb->prepare()
			$post_id
		) );

		if ( $row_ids ) {
			$ph = implode( ',', array_fill( 0, count( $row_ids ), '%d' ) );
			$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
				$wpdb->prepare(
					'DELETE FROM ' . $prefix . 'mbs_buy_links WHERE format_row_id IN (' . $ph . ')', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- safe: $prefix is $wpdb->prefix (trusted); query wrapped in $wpdb->prepare()
					...$row_ids
				)
			);
		}

		// Remove book-level buy links (format_row_id = 0 or not set).
		$wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
			'DELETE FROM ' . $prefix . 'mbs_buy_links WHERE post_id = %d', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- safe: $prefix is $wpdb->prefix (trusted); query wrapped in $wpdb->prepare()
			$post_id
		) );

		foreach ( array( 'mbs_format_rows', 'mbs_endorsements', 'mbs_book_contributors', 'mbs_book_classification_codes', 'mbs_book_meta' ) as $tbl ) {
			$wpdb->delete( $prefix . $tbl, array( 'post_id' => $post_id ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- DAO layer -- direct DB access is intentional per MBS architecture
		}

		// Delete book awards via DAO so no orphan rows remain in mbs_book_awards.
		MBS_Book_Awards_DAO::delete_all( $post_id );

		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		// Delete sideloaded cover images from the media library.
		$attachment_ids = get_posts( array(
			'post_type'      => 'attachment',
			'posts_per_page' => -1,
			'post_parent'    => $post_id,
			'fields'         => 'ids',
		) );
		foreach ( $attachment_ids as $att_id ) {
			wp_delete_attachment( (int) $att_id, true );
		}

		wp_delete_post( $post_id, true );
	}

	/**
	 * Register the MBS_POST_TYPE post type stub if not already registered.
	 *
	 * Called at the top of install() so wp_insert_post() can accept the
	 * custom post type during the activation hook, before init has fired.
	 *
	 * @since  1.0.0
	 * @return void
	 */
	private static function ensure_post_type(): void {
		if ( post_type_exists( MBS_POST_TYPE ) ) {
			return;
		}
		register_post_type(
			MBS_POST_TYPE,
			array(
				'public'   => false,
				'supports' => array( 'title', 'editor', 'thumbnail' ),
			)
		);
	}

	/**
	 * Register minimal taxonomy stubs if not already registered.
	 *
	 * Prevents wp_set_object_terms() from silently discarding all term
	 * assignments during the activation hook (before init fires and
	 * MBS_Plugin registers the full taxonomy objects).
	 *
	 * @since  1.0.0
	 * @return void
	 */
	private static function ensure_taxonomies(): void {
		$stubs = array(
			'mbs_author',
			'mbs_publisher',
			'mbs_imprint',
			'mbs_genre',
			'mbs_tag',
			'mbs_contributor',
		);
		foreach ( $stubs as $taxonomy ) {
			if ( ! taxonomy_exists( $taxonomy ) ) {
				register_taxonomy( $taxonomy, MBS_POST_TYPE, array( 'public' => false ) );
			}
		}
	}
}
