<?php

namespace CelerSearch\Utilities;

use CelerSearch\DataTransfer\IndexSynonym;

/**
 * Converts between the admin UI's synonym shape (one-way / two-way entries)
 * and the IndexSynonym[] shape used by storage and Meilisearch, which only
 * has a notion of one-way mappings ("word" → "corrections").
 *
 * A two-way group {a, b, c} is expanded into three one-way IndexSynonyms:
 *   a → [b, c], b → [a, c], c → [a, b]
 * On read, we reverse that by detecting symmetric cliques.
 */
class SynonymNormalizer {

	/**
	 * Convert UI entries into the flat IndexSynonym[] storage shape.
	 *
	 * Expected UI entries:
	 *   [ 'type' => 'one_way', 'word' => 'foo', 'corrections' => ['bar','baz'] ]
	 *   [ 'type' => 'two_way', 'terms' => ['cream','creme','studio color'] ]
	 *
	 * @param array $ui_entries
	 *
	 * @return IndexSynonym[]
	 */
	public static function to_storage( array $ui_entries ): array {
		$out = [];

		foreach ( $ui_entries as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$type = isset( $entry['type'] ) ? (string) $entry['type'] : 'one_way';

			if ( 'two_way' === $type ) {
				$terms = self::clean_terms( $entry['terms'] ?? [] );
				if ( count( $terms ) < 2 ) {
					continue;
				}
				foreach ( $terms as $i => $term ) {
					$others = $terms;
					unset( $others[ $i ] );
					$out[] = new IndexSynonym( $term, array_values( $others ) );
				}
				continue;
			}

			// one_way
			$word        = isset( $entry['word'] ) ? trim( (string) $entry['word'] ) : '';
			$corrections = self::clean_terms( $entry['corrections'] ?? [] );
			if ( '' === $word || empty( $corrections ) ) {
				continue;
			}
			$out[] = new IndexSynonym( $word, $corrections );
		}

		return $out;
	}

	/**
	 * Convert stored IndexSynonym[] (or raw arrays) back into UI entries,
	 * collapsing symmetric clusters into two-way groups.
	 *
	 * @param array $stored IndexSynonym[] or [['word'=>..., 'corrections'=>[...]], ...]
	 *
	 * @return array<array{type:string, word?:string, corrections?:array, terms?:array}>
	 */
	public static function from_storage( array $stored ): array {
		// Normalize to map: word => sorted corrections
		$map = [];
		foreach ( $stored as $entry ) {
			if ( $entry instanceof IndexSynonym ) {
				$word        = $entry->word;
				$corrections = $entry->corrections;
			} elseif ( is_array( $entry ) ) {
				$word        = isset( $entry['word'] ) ? (string) $entry['word'] : '';
				$corrections = isset( $entry['corrections'] ) && is_array( $entry['corrections'] )
					? array_map( 'strval', $entry['corrections'] )
					: [];
			} else {
				continue;
			}

			$word = trim( (string) $word );
			if ( '' === $word ) {
				continue;
			}
			$corrections = self::clean_terms( $corrections );
			if ( empty( $corrections ) ) {
				continue;
			}
			$map[ $word ] = $corrections;
		}

		$ui        = [];
		$consumed  = [];

		foreach ( $map as $word => $corrections ) {
			if ( isset( $consumed[ $word ] ) ) {
				continue;
			}

			// Candidate two-way cluster = {word} ∪ corrections, but only if every
			// member's stored corrections equal the cluster minus itself.
			$cluster = array_values( array_unique( array_merge( [ $word ], $corrections ) ) );

			if ( self::is_symmetric_cluster( $cluster, $map ) ) {
				foreach ( $cluster as $term ) {
					$consumed[ $term ] = true;
				}
				$ui[] = [
					'type'  => 'two_way',
					'terms' => $cluster,
				];
				continue;
			}

			// Fall back to one-way
			$consumed[ $word ] = true;
			$ui[]              = [
				'type'        => 'one_way',
				'word'        => $word,
				'corrections' => array_values( $corrections ),
			];
		}

		return $ui;
	}

	/**
	 * A cluster is symmetric if every term is in the map and its stored
	 * corrections (as a set) equals the cluster minus itself.
	 *
	 * @param array $cluster
	 * @param array $map
	 *
	 * @return bool
	 */
	private static function is_symmetric_cluster( array $cluster, array $map ): bool {
		if ( count( $cluster ) < 2 ) {
			return false;
		}
		foreach ( $cluster as $term ) {
			if ( ! isset( $map[ $term ] ) ) {
				return false;
			}
			$expected = array_values( array_diff( $cluster, [ $term ] ) );
			sort( $expected );
			$actual = $map[ $term ];
			sort( $actual );
			if ( $expected !== $actual ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Trim, dedupe, and drop empties.
	 *
	 * @param mixed $terms
	 *
	 * @return array<string>
	 */
	private static function clean_terms( $terms ): array {
		if ( ! is_array( $terms ) ) {
			return [];
		}
		$out = [];
		foreach ( $terms as $t ) {
			$t = trim( (string) $t );
			if ( '' === $t ) {
				continue;
			}
			$out[ $t ] = $t;
		}
		return array_values( $out );
	}
}
