import { afterEach, describe, expect, it, vi } from 'vitest';
import {
    createFrenchDictionary,
    DEFAULT_SUGGESTION_COUNT,
    DICTIONARY_AFF_URL,
    DICTIONARY_DIC_URL,
    findSuspectWord,
    FREQUENT_SHORT_ELISIONS,
    loadFrenchDictionary,
    MAX_ACCENT_COMBINATIONS,
    MIN_ELISION_REST_LENGTH,
    resetFrenchDictionary,
    SHORT_FUNCTION_WORDS,
    SUSPECT_WORDS,
    type Speller,
} from './dictionary';

/**
 * Faux correcteur : le vrai dictionnaire (1,4 Mo) n'est JAMAIS chargé en test.
 * On ne teste pas Hunspell, on teste ce que l'on ajoute autour.
 */
function fauxSpeller(mots: string[], suggestions: Record<string, string[]> = {}): Speller {
    const connus = new Set(mots);

    return {
        correct: (mot) => connus.has(mot),
        suggest: (mot) => suggestions[mot] ?? [],
    };
}

describe('createFrenchDictionary — check', () => {
    it('accepte un mot présent tel quel', () => {
        expect(createFrenchDictionary(fauxSpeller(['chambre'])).check('chambre')).toBe(true);
    });

    it('accepte un mot capitalisé dont la forme minuscule est connue', () => {
        expect(createFrenchDictionary(fauxSpeller(['chambre'])).check('Chambre')).toBe(true);
    });

    it('refuse un mot absent', () => {
        expect(createFrenchDictionary(fauxSpeller(['chambre'])).check('chmabre')).toBe(false);
    });

    it('découpe l’élision : « l’hôtel » se vérifie comme « hôtel »', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['hôtel']));

        expect(dictionnaire.check("l'hôtel")).toBe(true);
        expect(dictionnaire.check('l’hôtel')).toBe(true);
        expect(dictionnaire.check("qu'hôtel")).toBe(true);
    });

    it('ne découpe pas une apostrophe qui n’est pas une élision', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['hui']));

        expect(dictionnaire.check("aujourd'hui")).toBe(false);
    });

    it('accepte un composé dont chaque morceau est connu', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['rez', 'de', 'chaussée']));

        expect(dictionnaire.check('rez-de-chaussée')).toBe(true);
    });

    it('refuse un composé dont un morceau est inconnu', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['rez', 'de']));

        expect(dictionnaire.check('rez-de-chausse')).toBe(false);
    });
});

describe('createFrenchDictionary — suggest', () => {
    it('limite à trois suggestions par défaut', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { chmabre: ['chambre', 'chambré', 'chambres', 'cambre'] }));

        expect(DEFAULT_SUGGESTION_COUNT).toBe(3);
        expect(dictionnaire.suggest('chmabre')).toEqual(['chambre', 'chambré', 'chambres']);
    });

    it('respecte la limite demandée par l’appelant', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { chmabre: ['chambre', 'chambré'] }));

        expect(dictionnaire.suggest('chmabre', 1)).toEqual(['chambre']);
    });

    it('réapplique l’élision d’origine aux suggestions', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { hotle: ['hôtel'] }));

        expect(dictionnaire.suggest("l'hotle")).toEqual(["l'hôtel"]);
    });

    it('rend une liste vide quand le correcteur ne propose rien', () => {
        expect(createFrenchDictionary(fauxSpeller([])).suggest('zzzz')).toEqual([]);
    });
});

describe('createFrenchDictionary — élision oubliée', () => {
    // Défaut signalé par un gérant : il tape « test dorthographe jenvois ». Les deux mots sont
    // bien refusés par le dictionnaire, mais nspell propose « orthographe » et « envois »,
    // c'est-à-dire la SUPPRESSION de la lettre élidée. Appliquées, ces suggestions donnent
    // « Test orthographe envois » — du français faux, fabriqué par le correcteur.
    it('remet l’apostrophe au lieu de supprimer la lettre élidée, et passe devant nspell', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['orthographe'], { dorthographe: ['orthographe'] }));

        expect(dictionnaire.suggest('dorthographe')).toEqual(["d'orthographe", 'orthographe']);
    });

    it('essaie les préfixes du plus long au plus court', () => {
        // « orsquil » est connu exprès : sans l'ordre décroissant, « l » gagnerait et
        // « lorsquil » ressortirait en « l'orsquil ».
        const dictionnaire = createFrenchDictionary(fauxSpeller(['il', 'orsquil', 'on']));

        expect(dictionnaire.suggest('lorsquil')).toEqual(["lorsqu'il"]);
        expect(dictionnaire.suggest('quon')).toEqual(["qu'on"]);
    });

    it('n’élide JAMAIS un mot capitalisé, même en tête de phrase', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ai']));

        // Une capitale est un nom propre bien plus souvent qu'une élision oubliée, et se
        // tromper renomme une personne. Les élisions fréquentes capitalisées (« Jai » →
        // « J'ai ») sont recollées en amont par la couche typographie, donc elles n'arrivent
        // même pas jusqu'ici — voir COMMON_MISSPELLINGS.
        expect(dictionnaire.suggest('Jai', DEFAULT_SUGGESTION_COUNT, { sentenceStart: true })).toEqual([]);
        expect(dictionnaire.suggest('Jai', DEFAULT_SUGGESTION_COUNT, { sentenceStart: false })).toEqual([]);
    });

    it('emploie l’apostrophe droite, celle que les gérants tapent', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ai']));

        expect(dictionnaire.suggest('jai')[0]).toBe("j'ai");
        expect(dictionnaire.suggest('jai')[0]).not.toContain('’');
    });

    it('accepte une suggestion du reste seulement si ce sont les mêmes lettres, accents en plus', () => {
        // Mesuré sur le vrai dictionnaire : suggest('hotel') rend « motel » AVANT « hôtel »,
        // et suggest('arrivee') rend « arrive » AVANT « arrivée ». On balaie toute la liste.
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { hotel: ['motel', 'hôtel'], arrivee: ['arrive', 'arrivée'] }));

        expect(dictionnaire.suggest('lhotel')).toEqual(["l'hôtel"]);
        expect(dictionnaire.suggest('larrivee')).toEqual(["l'arrivée"]);
        expect(dictionnaire.suggest('larrivee')).not.toContain("l'arrive");
    });

    it('n’invente rien quand la suggestion du reste change les lettres', () => {
        // « ava » se fait proposer « ana » : c'est ainsi que naissait « c'ana » pour « cava ».
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { ava: ['ana'], ete: ['eue'] }));

        expect(dictionnaire.suggest('cava')).toEqual([]);
        expect(dictionnaire.suggest('lete')).toEqual([]);
    });

    it('n’accepte pas une suggestion qui n’ajoute qu’une capitale', () => {
        // « ava » → « Ava » (un prénom) : rien n'a été réparé, et « c'Ava » n'aide personne.
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { ava: ['Ava'] }));

        expect(dictionnaire.suggest('cava')).toEqual([]);
    });

    it('refuse un reste de moins de deux caractères', () => {
        // « jusqua » laisserait « a » : c'est `typography.ts` qui le recolle, d'office.
        const dictionnaire = createFrenchDictionary(fauxSpeller(['a', 'au']));

        expect(dictionnaire.suggest('jusqua')).toEqual([]);
        expect(dictionnaire.suggest('jusquau')).toEqual(["jusqu'au"]);
    });

    it('ne reconstruit pas une apostrophe déjà présente', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { ai: ['ait'] }));

        expect(dictionnaire.suggest("j'ai")).toEqual(["j'ait"]);
    });

    it('dédoublonne et respecte maxSuggestions', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ai'], { jai: ["j'ai", 'gai', 'jar'] }));

        expect(dictionnaire.suggest('jai')).toEqual(["j'ai", 'gai', 'jar']);
        expect(dictionnaire.suggest('jai', 2)).toEqual(["j'ai", 'gai']);
        expect(dictionnaire.suggest('jai', 0)).toEqual([]);
    });
});

describe('createFrenchDictionary — l’élision n’invente pas d’apostrophe', () => {
    // Quatre défauts mesurés sur le vrai dictionnaire, tous nés du même excès de confiance :
    // le reste de l'élision était accepté dès qu'il formait un mot, même rare.
    it('répare d’abord le mot ENTIER : « depart » → « départ », jamais « d’épart »', () => {
        // « épart » (une pièce de charrette) est un vrai mot : l'élision sortait « d'épart »
        // EN TÊTE, devant « départ ». Le gérant qui clique écrit « le jour du d'épart ».
        const dictionnaire = createFrenchDictionary(fauxSpeller(['épart'], { depart: ['départ', 'repart'] }));

        expect(dictionnaire.suggest('depart')).toEqual(['départ', 'repart']);
        expect(dictionnaire.suggest('depart')).not.toContain("d'épart");
    });

    it('garde l’élision quand le mot entier n’a pas de réparation de même squelette', () => {
        // « orthographe » a un « d » en moins : ce n'est pas un accent remis, c'est un autre mot.
        const dictionnaire = createFrenchDictionary(fauxSpeller(['orthographe'], { dorthographe: ['orthographe'] }));

        expect(dictionnaire.suggest('dorthographe')[0]).toBe("d'orthographe");
    });

    it('n’élide pas une capitale, où qu’elle soit dans la phrase — c’est un nom propre', () => {
        // Mesuré : « Sabri » → « S'abri », « Dorra » → « D'orra ». Le chauffeur n'y est pour rien.
        // Une première version ne gardait que le milieu de phrase : les quatre prénoms fautifs
        // du corpus ouvraient justement leur phrase (« Sabri vous attend devant le portail. »),
        // donc le défaut n'était pas réparé, il était déplacé.
        const dictionnaire = createFrenchDictionary(fauxSpeller(['abri'], { Sabri: ['Sabra'] }));

        expect(dictionnaire.suggest('Sabri', DEFAULT_SUGGESTION_COUNT, { sentenceStart: false })).toEqual(['Sabra']);
        expect(dictionnaire.suggest('Sabri', DEFAULT_SUGGESTION_COUNT, { sentenceStart: true })).toEqual(['Sabra']);
    });

    it('sans contexte, s’abstient : une capitale est un nom propre jusqu’à preuve du contraire', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['abri'], { Sabri: ['Sabra'] }));

        expect(dictionnaire.suggest('Sabri')).toEqual(['Sabra']);
    });

    it('laisse intacte l’élision d’un mot en minuscules, où qu’il soit dans la phrase', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ai', 'adresse']));

        expect(dictionnaire.suggest('jai', DEFAULT_SUGGESTION_COUNT, { sentenceStart: false })).toEqual(["j'ai"]);
        expect(dictionnaire.suggest('ladresse', DEFAULT_SUGGESTION_COUNT, { sentenceStart: false })).toEqual(["l'adresse"]);
    });

    it('refuse un reste mot-outil : « Nou » ne donne pas « N’ou »', () => {
        // Mesuré : « Nou » → « N'ou », « tou » → « t'ou », « jou » → « j'ou », « lou » → « l'ou ».
        // « ou » est un vrai mot, `check()` l'accepte — il est simplement trop passe-partout.
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ou', 'on', 'il', 'un']));

        for (const mot of ['nou', 'tou', 'jou', 'lou']) {
            expect(dictionnaire.suggest(mot)).toEqual([]);
        }
    });

    it('garde les quelques élisions fréquentes dont le reste est un mot-outil', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['ou', 'on', 'il', 'un']));

        expect(dictionnaire.suggest('quon')).toEqual(["qu'on"]);
        expect(dictionnaire.suggest('quil')).toEqual(["qu'il"]);
        expect(dictionnaire.suggest('dun')).toEqual(["d'un"]);
        expect(dictionnaire.suggest('lorsquil')).toEqual(["lorsqu'il"]);
    });

    it('garde le seuil de reste à 2 — « jai » en dépend', () => {
        // Monter le seuil à 3 supprimerait « j'ai », dont le reste « ai » fait justement 2.
        expect(MIN_ELISION_REST_LENGTH).toBe(2);
        expect(createFrenchDictionary(fauxSpeller(['ai'])).suggest('jai')).toEqual(["j'ai"]);
    });

    it('garde les deux tables fermées — chaque entrée rouvre la porte à un « N’ou »', () => {
        expect([...SHORT_FUNCTION_WORDS]).toEqual(['ou', 'on', 'en', 'un', 'il', 'la', 'le', 'les', 'des', 'ce', 'se', 'me', 'te']);
        expect([...FREQUENT_SHORT_ELISIONS]).toEqual(["qu'il", "qu'on", "s'il", "d'un", "l'un", "lorsqu'il", "puisqu'il"]);
    });
});

describe('createFrenchDictionary — l’accent passe devant l’amputation', () => {
    // Mesuré sur le vrai dictionnaire : nspell propose TOUJOURS d'abord la suggestion qui
    // supprime une lettre. Le gérant, lui, a tapé le bon mot sans son accent.
    it('remonte la suggestion de même squelette, hors élision', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { louee: ['loue', 'louée', 'louer'] }));

        expect(dictionnaire.suggest('louee')).toEqual(['louée', 'loue', 'louer']);
    });

    it('vaut aussi pour un mot qui porte déjà son apostrophe', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { entree: ['entre', 'entrer', 'entrée'] }));

        expect(dictionnaire.suggest("l'entree")).toEqual(["l'entrée", "l'entre", "l'entrer"]);
    });

    it('ne bouscule pas l’ordre de nspell entre suggestions de squelettes différents', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([], { chmabre: ['chambre', 'chambré', 'cambre'] }));

        expect(dictionnaire.suggest('chmabre')).toEqual(['chambre', 'chambré', 'cambre']);
    });
});

describe('createFrenchDictionary — isRepairableFrench, pour la seule sonde de langue', () => {
    it('reconnaît une élision reconstruite et un accent restauré', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller(['heure', 'prêt', 'chambre']));

        expect(dictionnaire.isRepairableFrench('lheure')).toBe(true); // « l'heure »
        expect(dictionnaire.isRepairableFrench('pret')).toBe(true); // « prêt »
        expect(dictionnaire.isRepairableFrench('chambre')).toBe(false); // déjà connu
        expect(dictionnaire.isRepairableFrench('tomorrow')).toBe(false); // ni l'un ni l'autre
    });

    it('suit les mêmes règles que l’affichage sur les noms propres', () => {
        // La sonde ne doit pas être plus permissive que ce qui est proposé au gérant : un nom
        // propre n'est PAS un mot français réparable, quelle que soit sa position.
        const dictionnaire = createFrenchDictionary(fauxSpeller(['abri']));

        expect(dictionnaire.isRepairableFrench('Sabri', { sentenceStart: false })).toBe(false);
        expect(dictionnaire.isRepairableFrench('Sabri', { sentenceStart: true })).toBe(false);
    });

    it('ne paie JAMAIS une suggestion pour répondre', () => {
        // Le point dur : la sonde interroge tous les mots inconnus d'un message, y compris ceux
        // d'un message anglais ou arabe qu'elle finira par écarter. Mesuré, `suggest()` coûte
        // 18 ms sur un mot français, 558 ms sur « tomorrow » : la sonde doit s'en passer.
        let appels = 0;
        const dictionnaire = createFrenchDictionary({
            correct: (mot) => mot === 'prêt',
            suggest: () => {
                appels += 1;

                return [];
            },
        });

        expect(dictionnaire.isRepairableFrench('pret')).toBe(true);
        expect(dictionnaire.isRepairableFrench('tomorrow')).toBe(false);
        expect(dictionnaire.isRepairableFrench('شكرا')).toBe(false);
        expect(appels).toBe(0);
    });

    it('renonce plutôt que de balayer un arbre d’accents qui explose', () => {
        const dictionnaire = createFrenchDictionary(fauxSpeller([]));

        expect(MAX_ACCENT_COMBINATIONS).toBe(4096);
        expect(dictionnaire.isRepairableFrench('eeeeeeeeeeeeeeeeeeee')).toBe(false);
    });
});

describe('findSuspectWord — mots acceptés par le dictionnaire mais faux ici', () => {
    it('propose la bonne graphie pour les deux entrées de la table', () => {
        expect(findSuspectWord('cava')).toBe('ça va');
        expect(findSuspectWord('sil')).toBe("s'il");
    });

    it('conserve la capitale initiale', () => {
        expect(findSuspectWord('Cava')).toBe('Ça va');
    });

    it('laisse tranquilles les mots réellement ambigus', () => {
        // « vue sur la dune » est une phrase que les gérants de cette agence écrivent
        // vraiment ; « quelle heure », « sa clé », « ils arrivent » aussi.
        for (const mot of ['dune', 'quelle', 'sa', 'ta', 'ma', 'tas', 'ils', 'nous']) {
            expect(findSuspectWord(mot)).toBeNull();
        }
    });

    it('garde la table fermée — chaque entrée coûte un faux positif si elle se trompe', () => {
        expect([...SUSPECT_WORDS.keys()]).toEqual(['cava', 'sil']);
    });
});

describe('loadFrenchDictionary — dégradation douce', () => {
    afterEach(() => {
        resetFrenchDictionary();
        vi.unstubAllGlobals();
    });

    it('rend null sans lever quand le réseau tombe', async () => {
        vi.stubGlobal(
            'fetch',
            vi.fn(() => Promise.reject(new Error('réseau coupé'))),
        );

        await expect(loadFrenchDictionary()).resolves.toBeNull();
    });

    it('rend null sans lever quand l’asset répond 404', async () => {
        vi.stubGlobal(
            'fetch',
            vi.fn(() => Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('') })),
        );

        await expect(loadFrenchDictionary()).resolves.toBeNull();
    });

    it('rend null sans lever quand il n’y a pas de fetch (rendu côté serveur)', async () => {
        vi.stubGlobal('fetch', undefined);

        await expect(loadFrenchDictionary()).resolves.toBeNull();
    });

    it('ne déclenche aucun fetch tant qu’on ne l’appelle pas', () => {
        const fetchEspion = vi.fn((url: string) => Promise.reject(new Error(`réseau coupé (${url})`)));
        vi.stubGlobal('fetch', fetchEspion);

        expect(fetchEspion).not.toHaveBeenCalled();
    });

    it('télécharge les deux fichiers du dictionnaire servi par notre domaine, et rien d’autre', async () => {
        const fetchEspion = vi.fn((url: string) => Promise.reject(new Error(`réseau coupé (${url})`)));
        vi.stubGlobal('fetch', fetchEspion);

        await loadFrenchDictionary();

        expect(fetchEspion.mock.calls.map((appel) => appel[0])).toEqual([DICTIONARY_AFF_URL, DICTIONARY_DIC_URL]);
        expect(DICTIONARY_AFF_URL.startsWith('/')).toBe(true);
        expect(DICTIONARY_DIC_URL.startsWith('/')).toBe(true);
    });

    it('réessaie au prochain appel après un échec — une coupure passagère ne condamne pas la session', async () => {
        const fetchEspion = vi.fn((url: string) => Promise.reject(new Error(`réseau coupé (${url})`)));
        vi.stubGlobal('fetch', fetchEspion);

        await loadFrenchDictionary();
        await loadFrenchDictionary();

        expect(fetchEspion).toHaveBeenCalledTimes(4); // 2 fichiers × 2 tentatives
    });
});
