<?php
/**
 * File Path: includes/api/class-mbs-onix-reconciler.php
 *
 * ONIX 3.0 Reconciler -- My Book Showroom
 *
 * Accepts an array of normalised product arrays (from MBS_Onix_Parser) and
 * reconciles them against the existing book catalog using a two-pass strategy:
 *
 *   Pass 1 -- RelatedProduct ISBN grouping: Products linked by relation
 *             code 06 are grouped together as a single book (union-find).
 *
 *   Pass 2 -- Title + A01 KeyNames fallback: ungrouped Products matched
 *             against other groups by normalised title and last name.
 *
 * Import branches:
 *   A = ISBN match           -- update existing format row fields
 *   B = Book match, new ISBN -- append new format row to existing book
 *   C = No match             -- create new draft book + format row(s)
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Api
 * @since      1.1.0
 */

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

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

	/**
	 * Persistence delegate (REF-003).
	 *
	 * @var MBS_Onix_Book_Writer
	 */
	private MBS_Onix_Book_Writer $writer;

	/**
	 * Initialize the book writer dependency.
	 */
	public function __construct() {
		$this->writer = new MBS_Onix_Book_Writer();
	}

	// -------------------------------------------------------------------------
	// Public entry point
	// -------------------------------------------------------------------------

	/**
	 * Reconcile a flat array of normalised products against the catalog.
	 *
	 * Groups products into book clusters, then dispatches each cluster to
	 * branch A (update), B (append), or C (create).
	 *
	 * @param array[] $products Normalised product arrays from MBS_Onix_Parser.
	 * @param array   &$stats   Import stats array updated in place.
	 * @return void
	 */
	public function reconcile( array $products, array &$stats ): void {
		$groups = $this->group_by_related_products( $products );
		$groups = $this->apply_title_fallback( $groups, $stats );

		foreach ( $groups as $group ) {
			$this->reconcile_group( $group, $stats );
		}
	}

	// -------------------------------------------------------------------------
	// Pass 1: RelatedProduct grouping
	// -------------------------------------------------------------------------

	/**
	 * Group products by RelatedProduct ISBN clusters using union-find.
	 *
	 * @param array[] $products Normalised product arrays.
	 * @return array[] Array of groups; each group is an array of product arrays.
	 */
	private function group_by_related_products( array $products ): array {
		$by_isbn = array();
		foreach ( $products as $product ) {
			$by_isbn[ $product['isbn'] ] = $product;
		}

		// Union-Find: representative[isbn] = canonical isbn for the group.
		$rep = array();
		foreach ( array_keys( $by_isbn ) as $isbn ) {
			$rep[ $isbn ] = $isbn;
		}

		// Iterative path compression — avoids a self-referential closure
		// ( use( &$find ) ) that hangs on some PHP/Windows runtime configurations.
		$find = static function ( string $x ) use ( &$rep ): string {
			$root = $x;
			while ( $rep[ $root ] !== $root ) {
				$root = $rep[ $root ];
			}
			while ( $rep[ $x ] !== $root ) {
				$next      = $rep[ $x ];
				$rep[ $x ] = $root;
				$x         = $next;
			}
			return $root;
		};

		foreach ( $by_isbn as $isbn => $product ) {
			foreach ( $product['sibling_isbns'] as $sib ) {
				if ( ! isset( $rep[ $sib ] ) ) {
					continue; // Sibling not in this file -- ignore.
				}
				$ra = $find( $isbn );
				$rb = $find( $sib );
				if ( $ra !== $rb ) {
					$rep[ $rb ] = $ra; // Union.
				}
			}
		}

		$groups = array();
		foreach ( $by_isbn as $isbn => $product ) {
			$canonical = $find( $isbn );
			$groups[ $canonical ][] = $product;
		}

		return array_values( $groups );
	}

	// -------------------------------------------------------------------------
	// Pass 2: Title + A01 fallback
	// -------------------------------------------------------------------------

	/**
	 * For single-item groups not merged via RelatedProduct, attempt to merge
	 * groups that share the same normalised title and A01 contributor key name.
	 *
	 * @param array[] $groups
	 * @param array   &$stats
	 * @return array[]
	 */
	private function apply_title_fallback( array $groups, array &$stats ): array {
		$merged       = array();
		$by_title_key = array(); // 'normalised_title|key_names' => index in $merged.

		foreach ( $groups as $group ) {
			$first     = $group[0];
			$title_key = $this->normalise_title_key( $first['title'], $first['author_key_names'] );

			if ( isset( $by_title_key[ $title_key ] ) ) {
				$idx = $by_title_key[ $title_key ];
				foreach ( $group as $product ) {
					$merged[ $idx ][] = $product;
				}
			} else {
				$merged[]                   = $group;
				$by_title_key[ $title_key ] = count( $merged ) - 1;
			}
		}

		return $merged;
	}

	/**
	 * Return a normalised string key from title + contributor last name.
	 *
	 * @param string $title
	 * @param string $key_names
	 * @return string
	 */
	private function normalise_title_key( string $title, string $key_names ): string {
		// Delegates to the shared utility so normalisation logic lives in one place.
		// MBS_Import_Book_Grouper strips non-letter/digit/space from both parts;
		// key_names (contributor last name) is used as the author discriminator here.
		return MBS_Import_Book_Grouper::normalise_book_key( $title, $key_names );
	}

	// -------------------------------------------------------------------------
	// Reconcile: one group = one book
	// -------------------------------------------------------------------------

	/**
	 * Reconcile a single product group against the catalog.
	 *
	 * Determines the existing post (if any) by ISBN or title+author fallback,
	 * updates book-level meta, then dispatches each product to branch A, B, or C.
	 *
	 * @param array[] $group
	 * @param array   &$stats
	 * @return void
	 */
	private function reconcile_group( array $group, array &$stats ): void {

		// ── ISBN match pass ──────────────────────────────────────────────────────────
		$isbn_data        = $this->resolve_isbn_matches( $group );
		$existing_post_id = $isbn_data['existing_post_id'];
		$matched_by_isbn  = $isbn_data['matched_by_isbn'];

		// §8.6 cross-group conflict: skip any ISBN that resolves to a different book.
		$skip_isbns = array();
		if ( null !== $existing_post_id ) {
			$skip_isbns = $this->filter_isbn_conflicts(
				$existing_post_id,
				$isbn_data['isbn_post_map'],
				$matched_by_isbn,
				$stats
			);
		}

		// If no ISBN match, try title + contributor fallback against existing posts.
		if ( null === $existing_post_id ) {
			$first            = $group[0];
			$existing_post_id = $this->find_post_by_title_and_author(
				$first['title'],
				$first['author_key_names']
			);
		}

		// Update book-level meta for any matched book.
		$book_data = $group[0];
		if ( null !== $existing_post_id ) {
			$this->writer->update_book( $existing_post_id, $book_data );
		}

		// Dispatch each product to the appropriate branch.
		foreach ( $group as $product ) {
			// §8.6: skip ISBNs that belong to a different catalog book.
			if ( in_array( $product['isbn'], $skip_isbns, true ) ) {
				continue;
			}
			if ( isset( $matched_by_isbn[ $product['isbn'] ] ) ) {
				// Branch A: ISBN match -- update format row.
				$this->branch_a_update( $matched_by_isbn[ $product['isbn'] ], $product, $existing_post_id, $stats );
			} elseif ( null !== $existing_post_id ) {
				// Branch B: Book matched, new format -- append format row.
				$this->branch_b_append( $existing_post_id, $product, $stats );
			} else {
				// Branch C: No match -- create new draft book.
				$new_post_id = $this->branch_c_create( $book_data, $stats );
				if ( null !== $new_post_id ) {
					$existing_post_id = $new_post_id;
					$this->writer->insert_format_row( $new_post_id, $product );
				}
			}
		}

		// Resolve lifecycle state once per book after all formats are dispatched.
		// Uses priority table: active > preorder > out_of_stock > out_of_print > discontinued > draft.
		if ( null !== $existing_post_id ) {
			$pub_statuses = array_map( fn( $p ) => (string) ( $p['pub_status'] ?? '' ), $group );
			$lifecycle    = $this->resolve_lifecycle_state( $pub_statuses );
			MBS_Book_Meta_DAO::upsert( $existing_post_id, 'mbs_lifecycle_state', $lifecycle );
		}
	}

	// -------------------------------------------------------------------------
	// Branch A: ISBN match -- update
	// -------------------------------------------------------------------------

	/**
	 * Update an existing format row and its parent book's format-level fields.
	 *
	 * @param int      $format_row_id
	 * @param array    $product
	 * @param int|null $post_id
	 * @param array    &$stats
	 * @return void
	 */
	private function branch_a_update( int $format_row_id, array $product, ?int $post_id, array &$stats ): void {
		// Series position is a book-level attribute: write to post_meta, not format_rows.
		// ARCH-SERIES-001: mbs_series_position is the single source of truth.
		if ( null !== $post_id && isset( $product['series_number'] ) && '' !== $product['series_number'] ) {
			update_post_meta( $post_id, 'mbs_series_position', sanitize_text_field( $product['series_number'] ) );
		}

		$this->writer->update_format_row( $format_row_id, $post_id, $product ); // REF-003

		$edit_link = null !== $post_id ? get_edit_post_link( $post_id ) : '';
		$label     = '' !== $product['title'] ? $product['title'] : $product['isbn'];

		$stats['updated']++;
		$stats['notices'][] = sprintf(
			/* translators: 1: book title or ISBN, 2: ISBN-13 */
			__( 'Updated: %1$s (ISBN %2$s)', 'my-book-showroom' ),
			esc_html( $label ),
			esc_html( $product['isbn'] )
		) . ( $edit_link ? ' &mdash; <a href="' . esc_url( $edit_link ) . '">' . __( 'View book', 'my-book-showroom' ) . '</a>' : '' );
	}

	// -------------------------------------------------------------------------
	// Branch B: Book match, new format -- append
	// -------------------------------------------------------------------------

	/**
	 * Append a new format row to an existing book.
	 *
	 * @param int   $post_id
	 * @param array $product
	 * @param array &$stats
	 * @return void
	 */
	private function branch_b_append( int $post_id, array $product, array &$stats ): void {
		$this->writer->insert_format_row( $post_id, $product );

		$edit_link   = get_edit_post_link( $post_id );
		$label       = '' !== $product['title'] ? $product['title'] : $product['isbn'];
		$format_slug = MBS_Onix_Field_Map::product_form_to_slug(
			$product['product_form'],
			$product['product_detail']
		);

		$stats['appended']++;
		$stats['notices'][] = sprintf(
			/* translators: 1: format slug, 2: book title/ISBN */
			__( 'Appended format %1$s to %2$s', 'my-book-showroom' ),
			esc_html( $format_slug ),
			esc_html( $label )
		) . ( $edit_link ? ' &mdash; <a href="' . esc_url( $edit_link ) . '">' . __( 'View book', 'my-book-showroom' ) . '</a>' : '' );
	}

	// -------------------------------------------------------------------------
	// Branch C: No match -- create draft
	// -------------------------------------------------------------------------

	/**
	 * Create a new draft book post and write all available book-level meta.
	 *
	 * @param array $book_data Normalised product array (first product in group).
	 * @param array &$stats
	 * @return int|null New post ID, or null on failure.
	 */
	private function branch_c_create( array $book_data, array &$stats ): ?int {
		$result = $this->writer->insert_book( $book_data ); // REF-003

		if ( is_wp_error( $result ) ) {
			$stats['errors'][] = sprintf(
				/* translators: 1: book title, 2: error message */
				__( 'Could not create book "%1$s": %2$s', 'my-book-showroom' ),
				esc_html( $book_data['title'] ),
				$result->get_error_message()
			);
			return null;
		}

		$post_id   = (int) $result;
		$edit_link = get_edit_post_link( $post_id );
		$stats['created']++;
		$stats['notices'][] = sprintf(
			/* translators: 1: book title */
			__( 'Created draft: %s', 'my-book-showroom' ),
			esc_html( $book_data['title'] )
		) . ( $edit_link ? ' &mdash; <a href="' . esc_url( $edit_link ) . '">' . __( 'View book', 'my-book-showroom' ) . '</a>' : '' );

		return $post_id;
	}

	// -------------------------------------------------------------------------
	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Locate an existing book post by normalised title and A01 contributor key name.
	 *
	 * @param string $title
	 * @param string $key_names A01 contributor KeyNames (last name).
	 * @return int|null Post ID or null if not found.
	 */
	private function find_post_by_title_and_author( string $title, string $key_names ): ?int {
		if ( '' === $title ) {
			return null;
		}

		$query = new WP_Query( array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => array( 'publish', 'draft', 'private', 'pending', 'future' ),
			'title'          => $title,
			'posts_per_page' => 5,
			'no_found_rows'  => true,
			'fields'         => 'ids',
		) );

		if ( empty( $query->posts ) ) {
			return null;
		}

		// If author key names provided, verify at least one author term matches.
		if ( '' !== $key_names ) {
			foreach ( $query->posts as $post_id ) {
				$author_terms = wp_get_object_terms( (int) $post_id, MBS_TAX_AUTHOR, array( 'fields' => 'all' ) );
				if ( is_wp_error( $author_terms ) ) {
					continue;
				}
				foreach ( $author_terms as $term ) {
					$term_key = (string) get_term_meta( $term->term_id, 'mbs_key_names', true );
					if ( strtolower( trim( $term_key ) ) === strtolower( trim( $key_names ) ) ) {
						return (int) $post_id;
					}
				}
			}
			return null;
		}

		// No author check -- return first title match.
		return (int) $query->posts[0];
	}

	/**
	 * Resolve the book-level lifecycle state from an array of ONIX pub_status codes.
	 *
	 * When a multi-format group contains conflicting pub_status codes (e.g. one
	 * format is Active 04, another is Out of stock 06), the most-alive state wins:
	 * active > preorder/coming_soon > out_of_stock > out_of_print > discontinued > draft.
	 *
	 * @param string[] $pub_statuses Array of ONIX Code List 64 values from the group.
	 * @return string mbs_lifecycle_state value ('' = published/active).
	 */
	private function resolve_lifecycle_state( array $pub_statuses ): string {
		$best_state    = 'draft'; // Unrecognized codes fall back to draft.
		$best_priority = MBS_Onix_Field_Map::lifecycle_state_priority( 'draft' );

		foreach ( $pub_statuses as $code ) {
			if ( '' === $code ) {
				continue;
			}
			$state    = MBS_Onix_Field_Map::onix_code_to_lifecycle_state( $code );
			$priority = MBS_Onix_Field_Map::lifecycle_state_priority( $state );
			if ( $priority > $best_priority ) {
				$best_state    = $state;
				$best_priority = $priority;
			}
		}

		return $best_state;
	}

	// ── Additional helpers ──────────────────────────────────────────────────────────

	/**
	 * Query the format-rows table for every ISBN in the group.
	 *
	 * @param array[] $group Product arrays for one book cluster.
	 * @return array{
	 *     existing_post_id: int|null,
	 *     matched_by_isbn:  array<string, int>,
	 *     isbn_post_map:    array<string, int>
	 * }
	 */
	private function resolve_isbn_matches( array $group ): array {
		$existing_post_id = null;
		$matched_by_isbn  = array(); // isbn => format_row_id.
		$isbn_post_map    = array(); // isbn => post_id (for §8.6 conflict detection).

		foreach ( $group as $product ) {
			$row = MBS_Format_Row_DAO::get_format_by_isbn( $product['isbn'] );
			if ( $row ) {
				$matched_by_isbn[ $product['isbn'] ] = (int) $row->id;
				$isbn_post_map[ $product['isbn'] ]   = (int) $row->post_id;
				if ( null === $existing_post_id ) {
					$existing_post_id = (int) $row->post_id;
				}
			}
		}

		return compact( 'existing_post_id', 'matched_by_isbn', 'isbn_post_map' );
	}

	/**
	 * §8.6 cross-group conflict: identify ISBNs that resolve to a different catalog book.
	 *
	 * Removes conflicting ISBNs from $matched_by_isbn in place.
	 * Updates $stats['skipped'] and $stats['notices'].
	 *
	 * @param int                $existing_post_id  The post ID this group resolved to.
	 * @param array<string, int> $isbn_post_map     isbn => post_id map for this group.
	 * @param array<string, int> &$matched_by_isbn  isbn => format_row_id; conflicting keys removed.
	 * @param array              &$stats            Import stats updated in place.
	 * @return string[]  ISBNs to skip during branch dispatch.
	 */
	private function filter_isbn_conflicts( int $existing_post_id, array $isbn_post_map, array &$matched_by_isbn, array &$stats ): array {
		$skip_isbns = array();
		foreach ( $isbn_post_map as $isbn => $pid ) {
			if ( $pid !== $existing_post_id ) {
				$skip_isbns[] = $isbn;
				unset( $matched_by_isbn[ $isbn ] );
				$conflict_link = get_edit_post_link( $pid );
				$stats['skipped']++;
				$stats['notices'][] = sprintf(
					/* translators: %s: ISBN-13 */
					__( 'ISBN %s was skipped: it already exists in your catalog.', 'my-book-showroom' ),
					esc_html( $isbn )
				) . ( $conflict_link ? ' &mdash; <a href="' . esc_url( $conflict_link ) . '">' . __( 'View book', 'my-book-showroom' ) . '</a>' : '' );
			}
		}
		return $skip_isbns;
	}

}
