<?php
if ( ! defined( 'ABSPATH' ) ) { exit; }

/**
 * GLSE_Morphology - reconnaître qu'un mot est la forme fléchie d'un autre.
 *
 * Le problème est étroit et se mesure. Un client tape « chaussures », le
 * catalogue dit « Chaussure » : aucun des deux chemins de recherche ne trouve
 * quoi que ce soit, parce que le pluriel n'est pas un préfixe du singulier.
 * Dans l'autre sens tout va bien - « chaussure » trouve « chaussures » grâce au
 * joker - et les accents sont déjà réglés par la collation de la base
 * (utf8mb4_unicode_520_ci), qui compare « creme » et « crème » comme égaux.
 *
 * D'où le principe : cette classe ne remplace jamais un mot, elle en propose
 * des variantes. Une règle qui se trompe ajoute une forme qui ne correspond à
 * rien ; elle ne fait pas disparaître de résultat.
 *
 * Les règles sont françaises et ne s'appliquent qu'à un site francophone. Les
 * appliquer ailleurs abîmerait des mots corrects.
 *
 * @since 1.7.0
 */
final class GLSE_Morphology {

    /**
     * Pluriels irréguliers du vocabulaire courant, pluriel => singulier.
     *
     * Volontairement court : ce qui n'est pas ici passe par les règles de
     * terminaison ci-dessous, et ce qu'elles ratent laisse simplement le mot
     * d'origine, qui reste cherché.
     */
    private const IRREGULARS = [
        'yeux'      => 'oeil',
        'aieux'     => 'aieul',
        'cieux'     => 'ciel',
        'messieurs' => 'monsieur',
        'mesdames'  => 'madame',
        'travaux'   => 'travail',
        'vitraux'   => 'vitrail',
        'coraux'    => 'corail',
        'emaux'     => 'email',
        'baux'      => 'bail',
    ];

    /**
     * Mots déjà au singulier bien qu'ils finissent par s, x ou z.
     *
     * Sans cette liste, « souris » deviendrait « souri » et « prix » « pri » -
     * des formes qui ne correspondent à rien et qui, suivies du joker,
     * ramèneraient du bruit.
     */
    private const INVARIABLES = [
        'souris', 'prix', 'nez', 'bras', 'temps', 'pays', 'corps', 'gaz',
        'dos', 'mois', 'tapis', 'colis', 'avis', 'cas', 'repas', 'palais',
        'jus', 'univers', 'vers', 'puits', 'pois', 'poids', 'choix', 'croix',
        'voix', 'noix', 'toux', 'faux', 'roux', 'doux', 'houx', 'flux',
        'index', 'sphinx', 'lynx', 'relais', 'succes', 'proces', 'acces',
        'os', 'bus', 'plus', 'sens', 'fils', 'velours', 'concours', 'discours',
        'parcours', 'secours', 'cours', 'ours', 'jours',
    ];

    /**
     * Terminaisons de pluriel, de la plus spécifique à la plus générale.
     *
     * Une même terminaison peut venir de deux singuliers différents : -aux
     * vient de -al (cheval) comme de -au (tuyau). Les deux sont proposées,
     * puisque proposer coûte moins qu'exclure.
     */
    private const ENDINGS = [
        [ 'eaux', [ 'eau' ] ],
        [ 'aux',  [ 'al', 'au' ] ],
        [ 'oux',  [ 'ou' ] ],
        [ 'eux',  [ 'eu' ] ],
    ];

    /** Longueur en dessous de laquelle on ne touche à rien. */
    private const MIN_LENGTH = 4;

    /**
     * Les formes sous lesquelles chercher un mot.
     *
     * Le mot d'origine vient toujours en premier : c'est celui que le client a
     * tapé, et il doit rester celui qui compte le plus.
     *
     * @return string[] Une à trois formes, sans doublon.
     */
    public static function variants( string $word, ?string $locale = null ): array {
        $word = trim( $word );

        if ( '' === $word ) {
            return [];
        }

        if ( ! self::applies( $locale ) ) {
            return [ $word ];
        }

        $forms = [ $word ];

        foreach ( self::singulars( $word ) as $singular ) {
            if ( ! in_array( $singular, $forms, true ) ) {
                $forms[] = $singular;
            }
        }

        /**
         * Filtre les formes cherchées pour un mot.
         *
         * @param string[] $forms Formes retenues, la saisie en premier.
         * @param string   $word  Mot d'origine.
         */
        return (array) apply_filters( 'glse/search/word_variants', $forms, $word );
    }

    /**
     * Les singuliers possibles d'un mot, s'il ressemble à un pluriel.
     *
     * @return string[] Vide quand rien ne s'applique.
     */
    public static function singulars( string $word ): array {
        $lower = mb_strtolower( $word, 'UTF-8' );

        if ( isset( self::IRREGULARS[ $lower ] ) ) {
            return [ self::IRREGULARS[ $lower ] ];
        }

        if ( mb_strlen( $lower ) < self::MIN_LENGTH ) {
            return [];
        }

        if ( in_array( $lower, self::INVARIABLES, true ) ) {
            return [];
        }

        foreach ( self::ENDINGS as [ $ending, $replacements ] ) {
            if ( ! str_ends_with( $lower, $ending ) ) {
                continue;
            }
            $stem = mb_substr( $lower, 0, -mb_strlen( $ending ) );
            if ( '' === $stem ) {
                return [];
            }

            return array_map( static fn( $r ) => $stem . $r, $replacements );
        }

        /*
         * Le -s final, le cas de loin le plus fréquent. Le -x est traité par
         * les terminaisons ci-dessus ; celui qui reste appartient presque
         * toujours à un invariable.
         */
        if ( str_ends_with( $lower, 's' ) ) {
            $stem = mb_substr( $lower, 0, -1 );
            if ( mb_strlen( $stem ) >= self::MIN_LENGTH - 1 ) {
                return [ $stem ];
            }
        }

        return [];
    }

    /**
     * Si les règles françaises ont lieu de s'appliquer.
     *
     * Sur un site anglophone elles abîmeraient des mots corrects - « boxes »
     * n'a rien à voir avec « boxe ».
     */
    public static function applies( ?string $locale = null ): bool {
        $locale = $locale ?? ( function_exists( 'determine_locale' ) ? determine_locale() : get_locale() );

        /**
         * Filtre l'activation de la morphologie française.
         *
         * @param bool   $applies Décision par défaut, tirée de la langue du site.
         * @param string $locale  Langue examinée.
         */
        return (bool) apply_filters(
            'glse/search/french_morphology',
            str_starts_with( (string) $locale, 'fr' ),
            (string) $locale
        );
    }
}
