<?php
/**
 * File Path: includes/import/class-mbs-import-book-grouper.php
 *
 * Import Book Grouper
 *
 * Shared utility for grouping a flat array of import candidates (one row per
 * format) into book-level clusters where multiple rows represent different
 * formats of the same title.
 *
 * Used by:
 *   - MBS_Child_Importer_CSV_Process (CSV + JSON import pipelines)
 *   - MBS_Onix_Reconciler            (normalise_book_key only; Pass 2 title fallback)
 *
 * The grouping algorithm is the portable subset of the ONIX reconciler's
 * apply_title_fallback() pass, generalised to work with any candidate array
 * that carries a title field and an author field.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

/**
 * Groups flat import candidate arrays into book-level clusters and merges
 * multi-format rows into a single primary candidate with an _extra_formats list.
 */
class MBS_Import_Book_Grouper { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/**
	 * Format-specific fields carried from secondary rows into _extra_formats.
	 * All other fields (book-level metadata) are taken from the primary row only.
	 *
	 * @var string[]
	 */
	const FORMAT_FIELDS = array(
		'format',
		'format_label',
		'isbn13',
		'asin',
		'price',
		'page_length',
		'edition_number',
		'edition_type',
		'pub_date',
		'_line',
	);

	/**
	 * Produce a normalised lookup key from a title and author string.
	 *
	 * Strips all non-letter/non-digit/non-space characters, lowercases the
	 * result, collapses internal whitespace, then appends the author string
	 * with the same treatment. The two parts are joined by a pipe so a
	 * title of "Foo" with author "Bar" cannot collide with a title of "Foo|Bar"
	 * with no author.
	 *
	 * Extracted from MBS_Onix_Reconciler::normalise_title_key() and generalised
	 * for use with any string pair (title + full name, or title + key names).
	 *
	 * @since  1.0.0
	 * @param  string $title  Book title (any language).
	 * @param  string $author Author string -- full name, last name, or pipe-joined list.
	 * @return string Normalised lookup key.
	 */
	public static function normalise_book_key( string $title, string $author ): string {
		$t = strtolower( preg_replace( '/[^\\p{L}\\p{N}\\s]/u', '', $title ) );
		$t = preg_replace( '/\\s+/', ' ', trim( $t ) );
		$a = strtolower( preg_replace( '/[^\\p{L}\\p{N}\\s]/u', '', $author ) );
		$a = preg_replace( '/\\s+/', ' ', trim( $a ) );
		return $t . '|' . $a;
	}

	/**
	 * Group a flat candidate array by title + author key.
	 *
	 * Each returned group is an array of candidates that share the same
	 * normalised book key. Order within each group matches the original
	 * input order; the first candidate in each group is considered primary.
	 *
	 * @since  1.0.0
	 * @param  array  $candidates   Flat array of candidate arrays.
	 * @param  string $title_field  Key holding the title string (default 'title').
	 * @param  string $author_field Key holding the author string (default 'author').
	 * @return array[] Array of groups; each group is a non-empty array of candidates.
	 */
	public static function group(
		array $candidates,
		string $title_field = 'title',
		string $author_field = 'author'
	): array {
		$groups       = array();
		$key_to_index = array();

		foreach ( $candidates as $candidate ) {
			$title  = (string) ( $candidate[ $title_field ]  ?? '' );
			$author = (string) ( $candidate[ $author_field ] ?? '' );
			$key    = self::normalise_book_key( $title, $author );

			if ( isset( $key_to_index[ $key ] ) ) {
				$groups[ $key_to_index[ $key ] ][] = $candidate;
			} else {
				$key_to_index[ $key ] = count( $groups );
				$groups[]             = array( $candidate );
			}
		}

		return $groups;
	}

	/**
	 * Merge a flat candidate array into one primary candidate per book.
	 *
	 * For single-row groups this is a no-op. For multi-row groups:
	 *   - The first row becomes the primary candidate.
	 *   - pub_date on the primary is replaced with the earliest date found
	 *     across all rows in the group (Decision 1 -- staggered format releases;
	 *     the earliest date represents the book's first publication).
	 *   - _extra_formats is added to the primary: a slim array of FORMAT_FIELDS
	 *     for each non-primary row, passed to insert_format_row() after the
	 *     primary insert.
	 *
	 * Note: format-level pub_date variation is carried in _extra_formats for
	 * completeness but is not persisted in this sprint. Format-level pub_date
	 * storage is a future enhancement.
	 *
	 * @since  1.0.0
	 * @param  array $candidates Flat array of raw candidate arrays.
	 * @return array Flat array of merged primary candidates.
	 */
	public static function merge_to_primary( array $candidates ): array {
		$groups = self::group( $candidates );
		$merged = array();

		foreach ( $groups as $group ) {
			$primary = $group[0];

			if ( count( $group ) > 1 ) {
				// Decision 1: pub_date = earliest across all formats in the group.
				$earliest = self::earliest_pub_date( $group );
				if ( '' !== $earliest ) {
					$primary['pub_date'] = $earliest;
				}

				// Carry format-specific fields from non-primary rows.
				$extra = array();
				for ( $i = 1, $len = count( $group ); $i < $len; $i++ ) {
					$row         = $group[ $i ];
					$extra_entry = array();
					foreach ( self::FORMAT_FIELDS as $field ) {
						if ( isset( $row[ $field ] ) ) {
							$extra_entry[ $field ] = $row[ $field ];
						}
					}
					$extra[] = $extra_entry;
				}
				$primary['_extra_formats'] = $extra;
			}

			$merged[] = $primary;
		}

		return $merged;
	}

	/**
	 * Return the earliest valid pub_date (YYYY-MM-DD) across all candidates in a group.
	 *
	 * Silently skips empty strings and values that do not match YYYY-MM-DD.
	 * Returns empty string when no valid date is found.
	 *
	 * @since  1.0.0
	 * @param  array[] $group Array of candidate arrays, each may carry a pub_date key.
	 * @return string Earliest YYYY-MM-DD string, or '' if none found.
	 */
	private static function earliest_pub_date( array $group ): string {
		$dates = array();
		foreach ( $group as $candidate ) {
			$date = (string) ( $candidate['pub_date'] ?? '' );
			if ( '' !== $date && preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $date ) ) {
				$dates[] = $date;
			}
		}
		if ( empty( $dates ) ) {
			return '';
		}
		sort( $dates ); // Lexicographic sort is correct for YYYY-MM-DD.
		return $dates[0];
	}
}
