<?php
/**
 * File Path: includes/data/class-mbs-reading-order-dao-base.php
 *
 * Reading Order DAO — Abstract Base
 *
 * Shared data-access logic for all taxonomy reading-order DAOs. Eliminates
 * structural duplication between MBS_Collection_Reading_Order_DAO and
 * MBS_Universe_Reading_Order_DAO (D-005, Session 117).
 *
 * R2-002 (Session 185): Replaced JSON-in-term_meta storage with the
 * mbs_reading_order junction table. Each subclass declares type_key() to
 * identify its partition ('series', 'collection', 'universe'). The legacy
 * META_KEY term_meta column is retained as an initialization-flag and
 * backward-compatibility fallback for pre-migration installs.
 *
 * Subclass contract
 * -----------------
 * Each concrete subclass MUST:
 *   1. Declare: public const META_KEY = '<term_meta_key>';
 *   2. Implement: protected static function taxonomy(): string
 *      Return the taxonomy slug (e.g. MBS_TAX_COLLECTION).
 *   3. Implement: protected static function type_key(): string
 *      Return the mbs_reading_order type partition key: 'series',
 *      'collection', or 'universe'.
 *   4. Implement: protected static function compute_default_order(int $term_id): array
 *      Return ordered book IDs when no persisted order exists.
 *   5. Implement: public static function get_book_display_map(array $book_ids, ?int $term_id = null): array
 *      Return a display map for the admin drag-list. Collection passes $term_id
 *      for position postmeta; Universe ignores it.
 *
 * All shared concrete methods use static:: (late static binding) to resolve
 * META_KEY, taxonomy(), and type_key() against the concrete subclass at runtime.
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Data
 * @since      1.1.0
 */

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

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DAO/migration: table names via $wpdb->prefix are trusted server-controlled values, not user input
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- same reason: $wpdb->prefix table names are safe; $where_sql is built from sanitized server-controlled inputs

abstract class MBS_Reading_Order_DAO_Base { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	// -------------------------------------------------------------------------
	// Abstract interface — subclasses must implement
	// -------------------------------------------------------------------------

	/**
	 * Return the taxonomy slug this DAO operates on.
	 *
	 * @return string
	 */
	abstract protected static function taxonomy(): string;

	/**
	 * Return the mbs_reading_order.type partition key for this subclass.
	 *
	 * Must be one of: 'series', 'collection', 'universe'.
	 *
	 * @return string
	 * @since 1.5.0
	 */
	abstract protected static function type_key(): string;

	/**
	 * Compute the entity-specific default reading order when no persisted
	 * order exists for a term.
	 *
	 * @param int $term_id Term ID.
	 * @return int[] Ordered book post IDs.
	 */
	abstract protected static function compute_default_order( int $term_id ): array;

	/**
	 * Build a display map for the admin reading-order drag list.
	 *
	 * Collection implementations accept a non-null $term_id to look up the
	 * per-book position postmeta. Universe implementations ignore $term_id.
	 *
	 * @param int[]    $book_ids Ordered list of book post IDs.
	 * @param int|null $term_id  Term ID (required by Collection; ignored by Universe).
	 * @return array<int, array<string, mixed>>
	 */
	abstract public static function get_book_display_map( array $book_ids, ?int $term_id = null ): array;

	/**
	 * Return book post IDs for this term in native taxonomy sort order.
	 *
	 * Used by MBS_Archive_Reader for 'order' display mode. Must NOT use
	 * reading-order positions — this is the canonical browse order only.
	 *
	 * @param int $term_id Term ID.
	 * @return int[] Post IDs in native sort order.
	 * @since 4.34.0
	 */
	abstract public static function get_order_sorted_ids( int $term_id ): array;

	// -------------------------------------------------------------------------
	// Public read API
	// -------------------------------------------------------------------------

	/**
	 * Get the reading order for a term as an array of book post IDs.
	 *
	 * Returns the persisted order if one exists; otherwise delegates to the
	 * subclass compute_default_order(). Default is never persisted.
	 *
	 * @param int $term_id Term ID.
	 * @return int[] Ordered list of book post IDs.
	 */
	public static function get_ordered_book_ids( int $term_id ): array {
		$stored = static::get_stored_order( $term_id );
		if ( null !== $stored ) {
			return $stored;
		}
		return static::compute_default_order( $term_id );
	}

	/**
	 * Whether this term has a persisted reading order (vs. computed default).
	 *
	 * @param int $term_id Term ID.
	 * @return bool
	 */
	public static function has_persisted_order( int $term_id ): bool {
		return null !== static::get_stored_order( $term_id );
	}

	/**
	 * Get all book post IDs assigned to a term (unordered).
	 *
	 * Excludes trashed and auto-draft posts.
	 *
	 * @param int $term_id Term ID.
	 * @return int[]
	 */
	public static function get_books_in_term( int $term_id ): array {
		global $wpdb;

		$sql = $wpdb->prepare(
			"SELECT DISTINCT p.ID
			 FROM {$wpdb->posts} p
			 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
			 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
			 WHERE tt.taxonomy = %s
			   AND tt.term_id = %d
			   AND p.post_type = %s
			   AND p.post_status NOT IN ('trash', 'auto-draft')",
			static::taxonomy(),
			$term_id,
			MBS_POST_TYPE
		);

		return array_map( 'intval', (array) $wpdb->get_col( $sql ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- DAO layer -- direct DB access is intentional per MBS architecture
	}

	// -------------------------------------------------------------------------
	// Public write API
	// -------------------------------------------------------------------------

	/**
	 * Persist a reading order for a term.
	 *
	 * Defensive: filters the submitted list to valid integer IDs actually
	 * assigned to the term, then appends any members omitted from the
	 * submission. A stale form cannot silently orphan a book.
	 *
	 * Stores each (term_id, post_id, sort_order, type) row in the
	 * mbs_reading_order junction table. Uses delete-and-reinsert for
	 * simplicity and atomicity. Also writes a presence flag to term_meta
	 * so get_stored_order() can distinguish "never saved" from "saved but
	 * table is temporarily inaccessible" on pre-migration installs.
	 *
	 * @param int   $term_id  Term ID.
	 * @param int[] $book_ids Submitted order.
	 * @return void
	 * @since 1.1.0 (R2-002: switched to mbs_reading_order table, Session 185)
	 */
	public static function save_reading_order( int $term_id, array $book_ids ): void {
		global $wpdb;

		// Step 1: sanitize to positive ints, deduplicate.
		$submitted = array();
		foreach ( $book_ids as $id ) {
			$id = (int) $id;
			if ( $id > 0 && ! in_array( $id, $submitted, true ) ) {
				$submitted[] = $id;
			}
		}

		// Step 2: restrict to books actually in this term.
		$in_term = static::get_books_in_term( $term_id );
		$valid   = array_values( array_intersect( $submitted, $in_term ) );

		// Step 3: append any term members not in the submission.
		foreach ( $in_term as $id ) {
			if ( ! in_array( $id, $valid, true ) ) {
				$valid[] = $id;
			}
		}

		$ro_table = $wpdb->prefix . 'mbs_reading_order';
		$type     = static::type_key();

		// Step 4: delete all existing rows for this (term_id, type) and reinsert.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- DAO layer -- direct DB access is intentional per MBS architecture
			$ro_table,
			array(
				'term_id' => $term_id,
				'type'    => $type,
			),
			array( '%d', '%s' )
		);

		$sort = 0;
		foreach ( array_values( $valid ) as $post_id ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$wpdb->insert(
				$ro_table,
				array(
					'term_id'    => $term_id,
					'post_id'    => $post_id,
					'sort_order' => $sort,
					'type'       => $type,
				),
				array( '%d', '%d', '%d', '%s' )
			);
			$sort++;
		}

		// Step 5: mark as initialized in term_meta (presence flag only; value
		// is not the authoritative data source after R2-002).
		update_term_meta( $term_id, static::META_KEY, '1' );
	}

	/**
	 * Append a book to the end of a term's reading order if not already
	 * present. No-op if the term has no persisted reading order yet —
	 * auto-append must not silently initialize the order (R1: lock in only
	 * on explicit Save).
	 *
	 * @param int $term_id Term ID.
	 * @param int $book_id Book post ID.
	 * @return void
	 * @since 1.1.0 (R2-002: switched to mbs_reading_order table, Session 185)
	 */
	public static function append_book( int $term_id, int $book_id ): void {
		$current = static::get_stored_order( $term_id );
		if ( null === $current ) {
			return; // Never persisted — leave uninitialized.
		}
		if ( in_array( $book_id, $current, true ) ) {
			return; // Already present.
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$wpdb->insert(
			$wpdb->prefix . 'mbs_reading_order',
			array(
				'term_id'    => $term_id,
				'post_id'    => $book_id,
				'sort_order' => count( $current ),
				'type'       => static::type_key(),
			),
			array( '%d', '%d', '%d', '%s' )
		);
	}

	/**
	 * Remove a book from a term's reading order if present.
	 * No-op if the term has no persisted reading order yet.
	 *
	 * @param int $term_id Term ID.
	 * @param int $book_id Book post ID.
	 * @return void
	 * @since 1.1.0 (R2-002: switched to mbs_reading_order table, Session 185)
	 */
	public static function prune_book( int $term_id, int $book_id ): void {
		$current = static::get_stored_order( $term_id );
		if ( null === $current ) {
			return;
		}
		if ( ! in_array( $book_id, $current, true ) ) {
			return; // Not present — nothing to prune.
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- DAO layer -- direct DB access is intentional per MBS architecture
			$wpdb->prefix . 'mbs_reading_order',
			array(
				'term_id' => $term_id,
				'post_id' => $book_id,
				'type'    => static::type_key(),
			),
			array( '%d', '%d', '%s' )
		);
	}

	/**
	 * Remove a book from ALL reading orders in this taxonomy. Called on
	 * trashed_post and deleted_post to prevent stale IDs accumulating.
	 *
	 * @param int $book_id Book post ID being removed.
	 * @return void
	 */
	public static function prune_from_all( int $book_id ): void {
		$terms = get_terms( array(
			'taxonomy'   => static::taxonomy(),
			'hide_empty' => false,
			'fields'     => 'ids',
		) );
		if ( is_wp_error( $terms ) ) {
			return;
		}
		foreach ( $terms as $term_id ) {
			static::prune_book( (int) $term_id, $book_id );
		}
	}

	// -------------------------------------------------------------------------
	// Internal
	// -------------------------------------------------------------------------

	/**
	 * Read the persisted reading order for a term.
	 *
	 * Primary source: mbs_reading_order junction table (R2-002).
	 * Initialization check: the legacy META_KEY term_meta row must be present
	 * (any non-empty value) to distinguish "never saved" (return null) from
	 * "saved but DB table temporarily inaccessible" (return empty array).
	 *
	 * Backward-compatibility fallback: if the DB query fails (e.g., the
	 * mbs_reading_order table has not yet been created on a pre-migration
	 * install), the meta value is json_decoded and returned instead.
	 *
	 * The null-vs-empty-array distinction matters:
	 *   null        = "never been saved" (auto-append must not initialize)
	 *   empty array = "admin explicitly saved an empty order"
	 *
	 * @param int $term_id Term ID.
	 * @return int[]|null
	 * @since 1.1.0 (R2-002: DB-first read with term_meta fallback, Session 185)
	 */
	protected static function get_stored_order( int $term_id ): ?array {
		// Initialization check: meta presence flag must be non-empty.
		$meta_val = get_term_meta( $term_id, static::META_KEY, true );
		if ( '' === $meta_val || null === $meta_val || false === $meta_val ) {
			return null; // Never been saved.
		}

		global $wpdb;

		$ro_table = $wpdb->prefix . 'mbs_reading_order';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				"SELECT post_id FROM {$ro_table}
				 WHERE term_id = %d AND type = %s
				 ORDER BY sort_order ASC",
				$term_id,
				static::type_key()
			)
		);

		// DB query succeeded — use the result (may be an empty array if admin
		// saved an empty order or migration has not yet run on this term).
		if ( '' === $wpdb->last_error ) {
			$clean = array();
			foreach ( (array) $rows as $id ) {
				$id = (int) $id;
				if ( $id > 0 && ! in_array( $id, $clean, true ) ) {
					$clean[] = $id;
				}
			}
			return $clean;
		}

		// Fallback for pre-migration installs: the meta value may still be
		// the legacy JSON string. Attempt to decode it.
		$decoded = json_decode( (string) $meta_val, true );
		if ( ! is_array( $decoded ) ) {
			return array(); // Initialized but data unreadable; treat as empty.
		}
		$clean = array();
		foreach ( $decoded as $id ) {
			$id = (int) $id;
			if ( $id > 0 && ! in_array( $id, $clean, true ) ) {
				$clean[] = $id;
			}
		}
		return $clean;
	}
}
