/*
|--------------------------------------------------------------------------
| Dictionnaire français — chargé à la demande, dans le navigateur
|--------------------------------------------------------------------------
|
| Aucun appel à un service tiers : le dictionnaire Hunspell est servi en statique
| depuis `public/dictionaries/fr/` et interprété par `nspell` (42 Ko, MIT) DANS le
| navigateur. Aucun mot du message d'un voyageur ne sort de la machine.
|
| Trois contraintes tenues ici :
|   - importer ce module ne déclenche AUCUN fetch (chargement paresseux au 1er appel) ;
|   - `nspell` lui-même est en import dynamique : il ne pèse pas sur le bundle initial ;
|   - un échec de chargement ne lève JAMAIS. On renvoie `null` et le correcteur se réduit
|     à la couche typographie (cf. `index.ts`).
|
| Coût mesuré (Node 22, MacBook, 84 310 entrées) :
|   - téléchargement : 1,4 Mo bruts, 338 Ko gzip (314 Ko index.dic + 24 Ko index.aff) ;
|   - construction du nspell : ~1,9 s, UNE SEULE FOIS par session ;
|   - relecture d'un message ensuite : ~15 ms.
| C'est le premier appel qui coûte : l'appelant a intérêt à lancer `loadFrenchDictionary()`
| dès que le gérant ouvre la zone de saisie, pas au moment où il clique sur « Envoyer ».
*/

import { splitElision } from './tokenize';
import { restoreCapitalization } from './typography';

export const DICTIONARY_AFF_URL = '/dictionaries/fr/index.aff';
export const DICTIONARY_DIC_URL = '/dictionaries/fr/index.dic';

/** Trois suggestions : au-delà, le gérant ne lit plus, il clique au hasard. */
export const DEFAULT_SUGGESTION_COUNT = 3;

/**
 * Préfixes essayés pour RECONSTRUIRE l'apostrophe d'un mot tapé collé (« jenvois » → « j'envois »),
 * du PLUS LONG au plus court.
 *
 * POURQUOI cet ordre : « lorsquil » doit donner « lorsqu'il » et non « l'orsquil », « quon »
 * doit donner « qu'on » et non « q'uon ». Le premier préfixe qui aboutit gagne.
 *
 * POURQUOI une liste plus courte que `ELISION_PREFIXES` de `tokenize.ts` : celle-là DÉCOUPE un
 * mot déjà apostrophé (elle peut être large sans risque), celle-ci INVENTE une apostrophe
 * absente. Chaque préfixe en trop est une suggestion fausse de plus.
 */
export const ELISION_REBUILD_PREFIXES: readonly string[] = ['lorsqu', 'puisqu', 'quoiqu', 'jusqu', 'qu', 'j', 'l', 'd', 'c', 'n', 's', 't', 'm'];

/**
 * En dessous de 2 lettres, ce qui reste après le préfixe n'est plus un mot à vérifier :
 * « jusqua » laisserait « a », et l'on reconstruirait n'importe quoi. Ces agglutinations-là
 * sont traitées ailleurs, dans la table déterministe de `typography.ts`.
 *
 * POURQUOI le seuil reste à 2 et ne monte pas à 3 : le cas utile le plus court mesuré est
 * « jai » → reste « ai », qui fait justement 2. Monter le seuil le supprimerait. Ce sont les
 * RESTES mots-outils qui posent problème, pas leur longueur — cf. `SHORT_FUNCTION_WORDS`.
 */
export const MIN_ELISION_REST_LENGTH = 2;

/**
 * Mots-outils courts qu'on refuse comme reste d'élision.
 *
 * Mesuré sur le vrai dictionnaire : « Nou » → « N'ou », « tou » → « t'ou », « jou » → « j'ou »,
 * « lou » → « l'ou ». Le reste « ou » est un vrai mot, `check()` l'accepte, et l'on fabrique
 * une élision qui n'existe pas. Ces restes-là sont trop passe-partout pour servir de preuve :
 * n'importe quelle faute de frappe d'un nom propre en produit une.
 */
export const SHORT_FUNCTION_WORDS: ReadonlySet<string> = new Set(['ou', 'on', 'en', 'un', 'il', 'la', 'le', 'les', 'des', 'ce', 'se', 'me', 'te']);

/**
 * Les SEULES élisions qu'on reconstruit malgré un reste mot-outil : assez fréquentes pour que
 * la graphie collée n'ait qu'une lecture plausible.
 *
 * Table fermée, et tenue courte exprès : chaque entrée rouvre la porte à un « N'ou ».
 * « j'ai », « c'est », « n'est », « d'ici » n'y figurent pas et n'en ont pas besoin — leur
 * reste (« ai », « est », « ici ») n'est pas un mot-outil de la table ci-dessus. « j'y » et
 * « n'y » non plus : un reste d'une seule lettre est déjà écarté par `MIN_ELISION_REST_LENGTH`.
 */
export const FREQUENT_SHORT_ELISIONS: ReadonlySet<string> = new Set(["qu'il", "qu'on", "s'il", "d'un", "l'un", "lorsqu'il", "puisqu'il"]);

/**
 * Accents que le français pose sur chaque lettre. Sert à retrouver, par simples `check()`,
 * la graphie accentuée d'un mot tapé sans accent : « pret » → « prêt », « menage » → « ménage ».
 */
const ACCENT_VARIANTS: ReadonlyMap<string, readonly string[]> = new Map([
    ['a', ['à', 'â']],
    ['c', ['ç']],
    ['e', ['é', 'è', 'ê', 'ë']],
    ['i', ['î', 'ï']],
    ['o', ['ô', 'ö']],
    ['u', ['ù', 'û', 'ü']],
    ['y', ['ÿ']],
]);

/**
 * Garde-fou du balayage ci-dessus : un mot de dix voyelles ferait exploser le produit
 * cartésien. Au-delà, on renonce — le mot comptera simplement comme inconnu, comme avant.
 * Mesuré : un mot français ordinaire tient en 50 à 500 combinaisons.
 */
export const MAX_ACCENT_COMBINATIONS = 4096;

/**
 * Mots que le dictionnaire ACCEPTE alors qu'ils sont, dans une messagerie de location,
 * presque toujours une faute. Table FERMÉE, et volontairement minuscule.
 *
 * Mesuré sur le vrai dictionnaire : « cava » (verbe caver, « il cava »), « sil » (une ocre
 * naturelle), « dune », « quelle », « sa », « ta », « ma », « tas », « ils » et « nous » passent
 * tous `check()`. On n'en retient que deux.
 *
 * POURQUOI les autres restent dehors : ils sont RÉELLEMENT ambigus dans nos messages. L'agence
 * loue en bord de mer et « vue sur la dune » est une phrase que les gérants écrivent vraiment ;
 * « quelle heure », « sa clé », « ta caution », « ils arrivent » sont corrects. Les signaler,
 * c'est du bruit à chaque message — et un correcteur bruyant finit ignoré.
 *
 * POURQUOI une suggestion et non une règle typographique : « il cava » reste du français.
 * On signale, on n'applique pas (`applied: false`, cf. `index.ts`).
 */
export const SUSPECT_WORDS: ReadonlyMap<string, string> = new Map([
    ['cava', 'ça va'],
    ['sil', "s'il"],
]);

/**
 * Ce qu'une réparation a le droit de dépenser.
 *
 * `full` — l'AFFICHAGE : le mot est déjà signalé, on paie `speller.suggest` (18 ms et plus)
 *          pour rendre les suggestions dans l'ordre de nspell, le plus fréquent d'abord.
 * `cheap` — la SONDE DE LANGUE : elle interroge TOUS les mots inconnus d'un message, y compris
 *          ceux d'un message anglais ou arabe qu'elle finira par écarter. Elle n'a droit qu'à
 *          `check()` et au balayage d'accents (cf. `findKnownAccentedForm`).
 */
type RepairBudget = 'full' | 'cheap';

/** Le minimum que `dictionary.ts` demande à `nspell` — permet d'injecter un faux correcteur en test. */
export interface Speller {
    correct(word: string): boolean;
    suggest(word: string): string[];
}

/**
 * Ce que la couche dictionnaire ne peut pas deviner du mot seul, et que l'appelant, lui,
 * lit dans le texte.
 */
export interface SuggestContext {
    /**
     * `true` si le mot ouvre une phrase.
     *
     * Défaut : `false`. Sans preuve du contraire, une capitale est un nom propre — le prénom
     * du voyageur, celui du chauffeur — et l'on n'y invente pas d'apostrophe. C'est `index.ts`,
     * qui tient les offsets, qui dit la vérité pour chaque mot ; un appelant qui interroge un
     * mot hors de tout texte, lui, n'a rien à prouver et n'obtiendra pas « S'abri ».
     */
    sentenceStart?: boolean;
}

export interface FrenchDictionary {
    /** `true` si le mot est connu (élisions et mots composés compris). */
    check(word: string): boolean;
    /**
     * Au plus `max` suggestions. L'élision d'origine est réappliquée, une élision ABSENTE est
     * reconstruite en tête de liste (« jenvois » → « j'envois »), et les suggestions qui se
     * contentent de remettre un accent passent devant celles qui suppriment une lettre.
     */
    suggest(word: string, max?: number, context?: SuggestContext): string[];
    /**
     * `true` si le mot est inconnu MAIS que les couches françaises savent le réparer : élision
     * reconstruite, ou accent restauré. C'est du français mal tapé, pas un mot étranger.
     *
     * Sert à la SONDE DE LANGUE, et à elle seule : sans ce garde-fou, plus le gérant oubliait
     * d'apostrophes, plus son message passait pour de l'anglais — et le correcteur s'éteignait
     * précisément sur les messages qu'il devait réparer.
     */
    isRepairableFrench(word: string, context?: SuggestContext): boolean;
}

/** Promesse mémorisée : le dictionnaire n'est téléchargé qu'une fois par session. */
let dictionaryPromise: Promise<FrenchDictionary | null> | null = null;

/**
 * Charge (une seule fois) le dictionnaire français.
 * Renvoie `null` — jamais une exception — si l'asset est absent ou le réseau coupé.
 */
export function loadFrenchDictionary(): Promise<FrenchDictionary | null> {
    if (dictionaryPromise === null) {
        dictionaryPromise = fetchFrenchDictionary().catch(() => {
            // On oublie la promesse ratée : une coupure réseau passagère ne doit pas condamner
            // le correcteur jusqu'au rechargement de la page. Le coût d'un nouvel essai est d'un
            // aller-retour par message, ce qui est négligeable.
            dictionaryPromise = null;

            return null;
        });
    }

    return dictionaryPromise;
}

/** Vide le cache — utilisé par les tests, et par l'appelant s'il veut forcer un rechargement. */
export function resetFrenchDictionary(): void {
    dictionaryPromise = null;
}

async function fetchFrenchDictionary(): Promise<FrenchDictionary | null> {
    if (typeof fetch !== 'function') {
        return null; // rendu côté serveur : pas de correcteur, pas d'erreur
    }

    const [affResponse, dicResponse] = await Promise.all([fetch(DICTIONARY_AFF_URL), fetch(DICTIONARY_DIC_URL)]);

    if (!affResponse.ok || !dicResponse.ok) {
        throw new Error(`Dictionnaire français indisponible (${affResponse.status}/${dicResponse.status})`);
    }

    const [aff, dic] = await Promise.all([affResponse.text(), dicResponse.text()]);
    const imported = await import('nspell');
    // Interop CJS→ESM : Vite expose la fabrique sur `default`, d'autres runtimes l'exposent directement.
    const createSpeller = imported.default ?? (imported as unknown as typeof imported.default);

    return createFrenchDictionary(createSpeller(aff, dic));
}

/**
 * Enveloppe un correcteur brut (`nspell` ou un faux, en test) avec les règles françaises
 * que Hunspell ne couvre pas seul : élisions et mots composés.
 *
 * POURQUOI l'élision : le dictionnaire « Classique » ne contient ni « l'hôtel » ni « qu'il »,
 * il contient « hôtel » et « il ». Sans ce découpage, un message français normal ressort avec
 * une faute imaginaire tous les cinq mots. Le même découpage joue à l'envers dans `suggest` :
 * l'apostrophe que le gérant a oubliée est REMISE, au lieu que la lettre élidée soit supprimée.
 */
export function createFrenchDictionary(speller: Speller): FrenchDictionary {
    function check(word: string): boolean {
        const normalized = word.replace(/[’‘ʼ]/gu, "'");

        if (normalized === '') {
            return true;
        }

        if (speller.correct(normalized) || speller.correct(normalized.toLocaleLowerCase('fr'))) {
            return true;
        }

        const elided = splitElision(normalized);

        if (elided !== null) {
            return check(elided.rest);
        }

        // « check-in », « rez-de-chaussée » : si chaque morceau est connu, le composé l'est.
        if (normalized.includes('-')) {
            const parts = normalized.split('-').filter((part) => part !== '');

            return parts.length > 1 && parts.every((part) => check(part));
        }

        return false;
    }

    /**
     * « dorthographe » → « d'orthographe ». `null` dès que la reconstruction n'est pas sûre.
     *
     * POURQUOI cette couche existe : mesuré sur le vrai dictionnaire, nspell propose
     * « orthographe » pour « dorthographe », « envois » pour « jenvois », « est » pour « cest ».
     * Autrement dit il SUPPRIME la lettre élidée. Appliquée, sa suggestion donne
     * « Test orthographe envois » — du français faux, produit par le correcteur lui-même.
     *
     * Le reste après le préfixe est accepté selon DEUX conditions seulement :
     *   1. `check(reste)` est vrai — le cas courant : « jai », « cest », « dun », « quil »,
     *      « dorthographe », « jenvois », « ladresse », « dabord »… ;
     *   2. sinon, une suggestion du reste a EXACTEMENT les mêmes lettres, aux accents et à la
     *      casse près — c'est le discriminant mesuré, et il est indispensable :
     *      BON  « lhotel » → « l'hôtel », « jespere » → « j'espère », « nhesitez » → « n'hésitez »,
     *           « larrivee » → « l'arrivée » (« arrivee »/« arrivée » : mêmes lettres) ;
     *      ÉCARTÉ « larrivee » → « l'arrive » (une lettre en moins), « cava » → « c'ana »,
     *           « lete » → « l'eue », « jusqua » → « j'musqua ». Sans cette règle, toutes ces
     *           suggestions-là ressortaient, et fausses.
     *
     * Deux garde-fous s'ajoutent, tous deux mesurés sur des messages réels : pas d'élision sur
     * une capitale en milieu de phrase (c'est un nom propre), et pas d'élision sur un reste
     * mot-outil hors des formes fréquentes (cf. `SHORT_FUNCTION_WORDS`).
     */
    function suggestElision(word: string, context: SuggestContext, budget: RepairBudget): string | null {
        if (word.includes("'")) {
            return null; // déjà élidé : il n'y a pas d'apostrophe à inventer
        }

        // AUCUNE élision inventée sur un mot capitalisé, où qu'il soit dans la phrase.
        //
        // Un mot qui commence par une majuscule est un nom propre bien plus souvent qu'une
        // élision oubliée : prénom du voyageur, du chauffeur, de la femme de ménage, nom du
        // logement. Mesuré sur 48 prénoms tunisiens EN TÊTE DE PHRASE : quatre se faisaient
        // élider en première suggestion — « Sabri » → « S'abri », « Saber » → « S'aber »,
        // « Jaber » → « J'aber », « Dorra » → « D'orra ». Or « Sabri vous attend devant le
        // portail. » est la forme la plus courante d'un prénom dans cette messagerie, et un
        // gérant qui clique renomme une personne.
        //
        // Une première version ne gardait que le milieu de phrase (`context.sentenceStart`),
        // ce qui laissait les quatre cas intacts : le défaut n'était pas réparé, il était
        // déplacé. La contrepartie — perdre « Jai » → « J'ai » en tête de phrase — est payée
        // ailleurs, et mieux : les élisions fréquentes non ambiguës (jai, cest, quil, quon,
        // nhesitez, dabord…) sont recollées d'office par la couche typographie, donc elles
        // n'arrivent même plus jusqu'ici. Reste ce que ce garde-fou refuse : les capitales
        // rares, c'est-à-dire les noms propres.
        if (startsWithCapital(word)) {
            return null;
        }

        const lower = word.toLocaleLowerCase('fr');

        for (const prefix of ELISION_REBUILD_PREFIXES) {
            if (!lower.startsWith(prefix)) {
                continue;
            }

            const rest = word.slice(prefix.length);

            if (rest.length < MIN_ELISION_REST_LENGTH) {
                continue;
            }

            const repaired = repairElisionRest(rest, budget);

            if (repaired === null) {
                continue;
            }

            if (isWeakElision(prefix, repaired)) {
                continue;
            }

            // La tranche vient du mot D'ORIGINE : « Jai » ressort en « J'ai ».
            return `${word.slice(0, prefix.length)}'${repaired}`;
        }

        return null;
    }

    /** Élision dont le reste est un mot-outil passe-partout, hors des quelques formes fréquentes. */
    function isWeakElision(prefix: string, rest: string): boolean {
        if (!SHORT_FUNCTION_WORDS.has(rest.toLocaleLowerCase('fr'))) {
            return false;
        }

        return !FREQUENT_SHORT_ELISIONS.has(`${prefix}'${rest}`.toLocaleLowerCase('fr'));
    }

    function repairElisionRest(rest: string, budget: RepairBudget): string | null {
        if (check(rest)) {
            return rest;
        }

        // On balaie TOUTE la liste, pas seulement la première suggestion : sur « arrivee »,
        // nspell propose « arrive » avant « arrivée ».
        return budget === 'full' ? findAccentRepair(rest, speller.suggest(rest)) : findKnownAccentedForm(rest);
    }

    /**
     * Graphie ACCENTUÉE du mot que le dictionnaire connaît, trouvée par simple balayage des
     * accents possibles : « pret » → « prêt ». `null` s'il n'y en a pas.
     *
     * POURQUOI ne pas demander à `speller.suggest` : mesuré, `suggest()` coûte 18 ms sur un mot
     * français, 558 ms sur « tomorrow » et de 0,6 à 0,9 s sur un mot arabe — il balaie tout le
     * dictionnaire. Or la sonde de langue interroge TOUS les mots inconnus d'un message : une
     * sonde qui l'appellerait porterait la relecture d'un message anglais de 2 ms à 1,6 s, et
     * celle d'un message arabe à 6,9 s (mesuré sur une première version de cette couche).
     * `check()`, lui, coûte 1,5 µs — quelques centaines d'appels ne se voient pas.
     *
     * Ce balayage répond EXACTEMENT à la question « existe-t-il un mot du dictionnaire de même
     * squelette ? », là où `suggest()` n'en donne que ses premières réponses. Il ne sert qu'à
     * la sonde : pour l'affichage, l'ordre de nspell (le plus fréquent d'abord) vaut mieux —
     * il rend « espère » là où le balayage rendrait « espéré », tout aussi vrai mais plus rare.
     */
    function findKnownAccentedForm(word: string): string | null {
        const lowered = word.toLocaleLowerCase('fr');
        let candidates: string[] = [''];

        for (const letter of lowered) {
            const variants = ACCENT_VARIANTS.get(letter);
            const next: string[] = [];

            for (const candidate of candidates) {
                next.push(candidate + letter);

                for (const variant of variants ?? []) {
                    next.push(candidate + variant);
                }
            }

            if (next.length > MAX_ACCENT_COMBINATIONS) {
                return null; // trop de branches : on renonce plutôt que de balayer un arbre qui explose
            }

            candidates = next;
        }

        return candidates.find((candidate) => candidate !== lowered && check(candidate)) ?? null;
    }

    /**
     * Suggestions brutes du correcteur pour `word`, l'élision d'origine réappliquée :
     * « l'entree » est demandé à nspell sous la forme « entree », et les réponses ressortent
     * avec le « l' » du gérant.
     */
    function spellerSuggestions(word: string): string[] {
        const elided = splitElision(word);
        const target = elided === null ? word : elided.rest;
        const prefix = elided === null ? '' : elided.prefix;

        return speller.suggest(target).map((suggestion) => prefix + suggestion);
    }

    function suggest(word: string, max: number = DEFAULT_SUGGESTION_COUNT, context: SuggestContext = {}): string[] {
        const limit = Math.max(0, max);
        const normalized = word.replace(/[’‘ʼ]/gu, "'");
        const spelled = spellerSuggestions(normalized);

        // La réparation du mot ENTIER passe avant l'élision. Mesuré : « depart » a « départ »
        // dans ses suggestions, mais « épart » (une pièce de charrette) est un vrai mot, donc
        // l'élision sortait « d'épart » EN TÊTE. Le gérant qui clique écrit « le jour du
        // d'épart ». Quand le mot se répare d'un accent, il n'y a pas d'apostrophe à inventer :
        // on n'essaie même pas.
        //
        // « dorthographe » n'a pas de suggestion de même squelette (« orthographe » a un « d »
        // en moins) : l'élision reste, et rend bien « d'orthographe ».
        const wholeWordRepair = findAccentRepair(normalized, spelled);
        const elision = wholeWordRepair === null ? suggestElision(normalized, context, 'full') : null;

        // L'élision reconstruite passe DEVANT nspell : c'est la seule des deux qui rende du
        // français applicable tel quel. Les suggestions de nspell suivent, dédoublonnées.
        const ordered = [elision, ...rankAccentRepairsFirst(normalized, spelled)];
        const unique: string[] = [];

        for (const candidate of ordered) {
            if (unique.length >= limit) {
                break;
            }

            if (candidate !== null && !unique.includes(candidate)) {
                unique.push(candidate);
            }
        }

        return unique;
    }

    function isRepairableFrench(word: string, context: SuggestContext = {}): boolean {
        const normalized = word.replace(/[’‘ʼ]/gu, "'");

        if (normalized === '' || check(normalized)) {
            return false; // déjà connu : la question ne se pose pas
        }

        if (findKnownAccentedForm(normalized) !== null) {
            return true; // « pret » → « prêt », « menage » → « ménage » : du français mal tapé
        }

        return suggestElision(normalized, context, 'cheap') !== null; // « lheure » → « l'heure »
    }

    return { check, suggest, isRepairableFrench };
}

/**
 * Première suggestion qui a EXACTEMENT les lettres de `word`, aux accents et à la casse près,
 * et qui diffère du mot en minuscules — autrement dit celle qui s'est contentée de remettre un
 * accent. `null` s'il n'y en a pas.
 *
 * POURQUOI ce discriminant : nspell propose d'abord la suggestion qui SUPPRIME une lettre
 * (« loue » avant « louée », « arrive » avant « arrivée », « orthographe » pour
 * « dorthographe »). Appliquée, elle fabrique une faute.
 *
 * POURQUOI la casse doit différer AUSSI : une suggestion qui ne change qu'une capitale n'a rien
 * réparé. Mesuré, « ava » se fait proposer le prénom « Ava », d'où un « c'Ava » pour « cava ».
 * Aucun cas juste n'en pâtit — « hotel »/« hôtel », « espere »/« espère », « arrivee »/« arrivée »
 * diffèrent tous une fois passés en minuscules.
 */
function findAccentRepair(word: string, suggestions: readonly string[]): string | null {
    const skeleton = letterSkeleton(word);
    const lowered = word.toLocaleLowerCase('fr');

    return suggestions.find((suggestion) => letterSkeleton(suggestion) === skeleton && suggestion.toLocaleLowerCase('fr') !== lowered) ?? null;
}

/**
 * Remonte en tête les suggestions de même squelette que le mot saisi, sans toucher à l'ordre
 * relatif du reste (le tri de `Array.prototype.sort` est stable).
 *
 * Mesuré sur le vrai dictionnaire, sans ce classement : « louee » → [loue, louée, louer],
 * « chauffee » → [chauffe, chauffée], « fermee » → [ferme, fermer], « l'entree » →
 * [l'entre, l'entrer]. C'est toujours l'amputation qui arrivait en tête, alors que le gérant
 * a tapé le bon mot sans son accent.
 */
function rankAccentRepairsFirst(word: string, suggestions: readonly string[]): string[] {
    const skeleton = letterSkeleton(word);
    const rank = (suggestion: string): number => (letterSkeleton(suggestion) === skeleton ? 0 : 1);

    return [...suggestions].sort((left, right) => rank(left) - rank(right));
}

/** `true` si le mot commence par une capitale (une écriture sans casse, comme l'arabe, ne compte pas). */
function startsWithCapital(word: string): boolean {
    const first = word.slice(0, 1);

    return first !== '' && first.toLocaleUpperCase('fr') === first && first.toLocaleLowerCase('fr') !== first;
}

/**
 * Remplacement proposé pour un mot suspect (cf. `SUSPECT_WORDS`), capitale initiale conservée,
 * ou `null`. Sert à signaler des mots que `check()` accepte : c'est un contournement ASSUMÉ du
 * dictionnaire, cantonné à la table fermée ci-dessus.
 */
export function findSuspectWord(word: string): string | null {
    const replacement = SUSPECT_WORDS.get(word.replace(/[’‘ʼ]/gu, "'").toLocaleLowerCase('fr'));

    return replacement === undefined ? null : restoreCapitalization(word, replacement);
}

/**
 * Suite de lettres nue : accents retirés, casse ignorée. « hôtel », « Hotel » et « hotel »
 * rendent tous « hotel » ; « arrive » et « arrivée » ne se confondent pas.
 */
function letterSkeleton(word: string): string {
    return word
        .normalize('NFD')
        .replace(/\p{Diacritic}/gu, '')
        .toLocaleLowerCase('fr');
}
