<?php
/**
 * File Path: includes/import/class-mbs-duplicate-detector.php
 *
 * Duplicate Detector
 *
 * Core logic for BR-IMP-3: ISBN → Title+Author → Title only with Mod-1 similarity.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

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

	/**
	 * Batch duplicate check for import candidates.
	 *
	 * Returns two lists:
	 *   'clean'      — candidates with no existing match.
	 *   'duplicates' — candidates that match an existing book, each wrapped
	 *                  as array( 'data' => $candidate, 'id' => $existing_post_id ).
	 *
	 * @param array[] $candidates
	 * @return array{ clean: array[], duplicates: array[] }
	 */
	public static function check( array $candidates ): array {
		$clean      = array();
		$duplicates = array();

		foreach ( $candidates as $candidate ) {
			$duplicate_id = self::find_duplicate( $candidate );
			if ( $duplicate_id ) {
				$duplicates[] = array(
					'data' => $candidate,
					'id'   => $duplicate_id,
				);
			} else {
				$clean[] = $candidate;
			}
		}

		return array(
			'clean'      => $clean,
			'duplicates' => $duplicates,
		);
	}

	/**
	 * Check for an existing book by ISBN-13 (highest priority — BR-IMP-3 level 1).
	 *
	 * Queries the mbs_format_rows table via the DAO — ISBN lives in the format
	 * row, not in postmeta, so this is the only correct path.
	 *
	 * @param string $isbn Raw (may be unnormalised) ISBN-13.
	 * @return int|null Existing post ID or null.
	 */
	public static function query_by_isbn( string $isbn ): ?int {
		$normalized = MBS_ISBN_Helper::normalize_isbn( $isbn );
		if ( empty( $normalized ) ) {
			return null;
		}

		return MBS_Format_Row_DAO::get_book_id_by_isbn( $normalized );
	}

	/**
	 * Check for an existing book by Title + Author (BR-IMP-3 level 2).
	 *
	 * NEW-M1 fix: uses post_title for title matching and the mbs_author taxonomy
	 * for author matching. The original code used _mbs_book_title and
	 * _mbs_primary_author postmeta which are never written by the CSV importer,
	 * making level-2 duplicate detection silently broken for all CSV-imported books.
	 *
	 * @param string $title  Book title.
	 * @param string $author Author name.
	 * @return int|null Existing post ID or null.
	 */
	public static function query_by_title_author( string $title, string $author ): ?int {
		$title  = trim( $title );
		$author = trim( $author );

		if ( empty( $title ) || empty( $author ) ) {
			return null;
		}

		// WordPress 4.4+ supports exact post_title match via 'title' parameter.
		$args = array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => 'publish',
			'posts_per_page' => 1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
			// Exact title match against post_title.
			'title'          => $title,
			// Author term must exist in the mbs_author taxonomy.
			'tax_query'      => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- standard WP query pattern; VIP rule does not apply to this plugin
				array(
					'taxonomy' => MBS_TAX_AUTHOR,
					'field'    => 'name',
					'terms'    => $author,
					'operator' => 'IN',
				),
			),
		);

		$query = new WP_Query( $args );
		return $query->have_posts() ? (int) $query->posts[0] : null;
	}

	/**
	 * Check for an existing book by Title only with Levenshtein similarity (BR-IMP-3 level 3).
	 *
	 * NEW-M1 fix: reads post_title directly via get_the_title() instead of the
	 * phantom _mbs_book_title postmeta. The original code's meta_query approach
	 * produced no matches for any book imported by the CSV importer because
	 * _mbs_book_title is never written to.
	 *
	 * Threshold: similarity score > 0.85 OR exact slug match.
	 * Batch: up to 200 published books (Mod-1 spec).
	 *
	 * E-008: Performance note — this method is O(n×m) where n is the number
	 * of candidates and m is posts_per_page (≤200). levenshtein() runs on
	 * every (candidate, existing book) pair with no pre-filtering. For catalogs
	 * approaching 200 books, a first-word or slug prefix pre-filter would
	 * substantially reduce comparisons. Tracked for Sprint 12+ hardening —
	 * current scale is acceptable under the Mod-1 200-book cap.
	 *
	 * @param string $title Candidate title.
	 * @return int|null Existing post ID or null.
	 */
	public static function query_by_title_only( string $title ): ?int {
		$title = trim( $title );
		if ( empty( $title ) ) {
			return null;
		}
		// FIX V2-B-012: Guard against false positives on very short titles.
		if ( mb_strlen( $title ) < 4 ) {
			return null; // title too short for reliable Levenshtein matching
		}

		$args = array(
			'post_type'      => MBS_POST_TYPE,
			'post_status'    => 'publish',
			'posts_per_page' => 200,
			'fields'         => 'ids',
			'no_found_rows'  => true,
		);

		$query       = new WP_Query( $args );
		$best_id     = null;
		$best_score  = 0.0;
		$target_slug = sanitize_title( $title );

		foreach ( $query->posts as $post_id ) {
			// NEW-M1: read from post_title, not phantom _mbs_book_title postmeta.
			$existing_title = get_the_title( $post_id );
			if ( empty( $existing_title ) ) {
				continue;
			}

			$distance = levenshtein( strtolower( $title ), strtolower( $existing_title ) );
			$max_len  = max( strlen( $title ), strlen( $existing_title ) );

			if ( 0 === $max_len ) {
				continue;
			}

			$score         = 1.0 - ( $distance / $max_len );
			$existing_slug = sanitize_title( $existing_title );

			if ( $score > 0.85 || $target_slug === $existing_slug ) {
				if ( $score > $best_score ) {
					$best_score = $score;
					$best_id    = (int) $post_id;
				}
			}
		}

		return $best_id;
	}

	/**
	 * Master duplicate check — follows BR-IMP-3 precedence order.
	 *
	 * 1. ISBN-13 match (highest priority — format row table).
	 * 2. Title + Author exact match (post_title + mbs_author taxonomy).
	 * 3. Title-only Levenshtein similarity.
	 *
	 * @param array $book_data Candidate array — must include 'title' at minimum.
	 * @return int|null ID of the matching existing book, or null if no duplicate.
	 */
	public static function find_duplicate( array $book_data ): ?int {
		// 1. ISBN (highest priority) -- check ALL ISBNs from format_rows (FIX V2-B-011).
		$isbns = array_filter( array_column( $book_data['format_rows'] ?? array(), 'isbn13' ) );
		// Also accept top-level isbn13/isbn keys for callers that pass a flat structure.
		if ( ! empty( $book_data['isbn13'] ) ) {
			$isbns[] = $book_data['isbn13'];
		} elseif ( ! empty( $book_data['isbn'] ) ) {
			$isbns[] = $book_data['isbn'];
		}
		$isbns = array_values( array_unique( array_filter( $isbns ) ) );
		foreach ( $isbns as $isbn ) {
			$id = self::query_by_isbn( $isbn );
			if ( $id ) {
				return $id;
			}
		}

		// 2. Title + Author (exact, case-sensitive on slug level).
		// Accepts both 'author' (CSV path) and 'primary_author' (legacy API shape).
		$author = $book_data['author'] ?? $book_data['primary_author'] ?? '';
		if ( ! empty( $book_data['title'] ) && ! empty( $author ) ) {
			$id = self::query_by_title_author( $book_data['title'], $author );
			if ( $id ) {
				return $id;
			}
		}

		// 3. Title-only Levenshtein fallback — only when no author was provided.
		// When an author key IS present but the title+author query returned null
		// (different author), we must not escalate to a title-only match, as that
		// would incorrectly flag books by different authors as duplicates.
		if ( ! empty( $book_data['title'] ) && empty( $author ) ) {
			return self::query_by_title_only( $book_data['title'] );
		}

		return null;
	}
}
