import { describe, expect, it } from 'vitest';
import {
    applyCorrections,
    locateCorrections,
    MAX_CORRECTION_LENGTH,
    mergeWithSpelling,
    summarize,
    type GrammarCorrection,
    type GrammarKind,
    type LocatedCorrection,
} from './grammar';

/** Une correction du serveur, réduite à ce que le test regarde vraiment. */
function correction(from: string, to: string, kind: GrammarKind = 'grammaire'): GrammarCorrection {
    return { from, to, kind, reason: 'explication de test' };
}

/** Raccourci de lecture : le texte, puis les corrections telles que le modèle les rendrait. */
function localise(texte: string, ...corrections: GrammarCorrection[]): LocatedCorrection[] {
    return locateCorrections(texte, corrections);
}

describe('locateCorrections', () => {
    it('place la correction sur l’occurrence du texte, avec des offsets exploitables tels quels', () => {
        const texte = 'Je vous envois les clés demain.';
        const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'));

        expect(localisees).toEqual([
            {
                from: 'envois',
                to: 'envoie',
                kind: 'conjugaison',
                reason: 'explication de test',
                start: texte.indexOf('envois'),
                end: texte.indexOf('envois') + 'envois'.length,
            },
        ]);
        expect(texte.slice(localisees[0].start, localisees[0].end)).toBe('envois');
    });

    it('écarte en silence un from absent du texte', () => {
        // Le modèle a halluciné, ou le gérant a continué de taper pendant l'aller-retour réseau.
        expect(localise('Je vous envoie les clés demain.', correction('chèque', 'chèques', 'accord'))).toEqual([]);
    });

    it('rend une correction localisée par occurrence quand le mot revient trois fois', () => {
        const texte = 'Je envois le code, ensuite je envois les clés, enfin je envois l’adresse.';
        const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'));

        expect(localisees).toHaveLength(3);
        expect(localisees.map((localisee) => texte.slice(localisee.start, localisee.end))).toEqual(['envois', 'envois', 'envois']);
    });

    it('ne rend rien quand le from n’apparaît qu’en fragment d’un autre mot', () => {
        // « envois » vit dans « renvois » : le corriger écrirait « renvoie », une faute créée de toutes pièces.
        expect(localise('Les renvois sont déjà faits.', correction('envois', 'envoie', 'conjugaison'))).toEqual([]);
    });

    it('distingue le fragment du vrai mot dans une même phrase', () => {
        const texte = 'Les renvois sont faits, les envois aussi.';
        const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'));

        expect(localisees).toHaveLength(1);
        expect(localisees[0].start).toBe(texte.lastIndexOf('envois'));
    });

    it('localise un from porteur d’une apostrophe droite', () => {
        const texte = "Je vous envoie l'adresse et l'heure.";
        const localisees = localise(texte, correction("l'heure", "l'heure exacte"));

        expect(localisees).toHaveLength(1);
        expect(texte.slice(localisees[0].start, localisees[0].end)).toBe("l'heure");
    });

    it('localise un from porteur d’une apostrophe typographique', () => {
        const texte = 'Je vous envoie l’adresse et l’heure.';
        const localisees = localise(texte, correction('l’heure', 'l’heure exacte'));

        expect(localisees).toHaveLength(1);
        expect(texte.slice(localisees[0].start, localisees[0].end)).toBe('l’heure');
    });

    it('traite l’apostrophe comme un joncteur interne : « heure » ne matche pas dans « l’heure »', () => {
        expect(localise("Je vous envoie l'heure.", correction('heure', 'heures', 'accord'))).toEqual([]);
        expect(localise('Je vous envoie l’heure.', correction('heure', 'heures', 'accord'))).toEqual([]);
    });

    it('écarte un from dont l’apostrophe n’est pas celle du texte', () => {
        // Le contrat promet la sous-chaîne EXACTE. Accepter la variante reviendrait à remplacer
        // silencieusement l'apostrophe du gérant par celle du modèle, à un endroit où il n'a
        // demandé qu'une correction de grammaire.
        expect(localise('Je vous envoie l’heure.', correction("l'heure", "l'heure exacte"))).toEqual([]);
    });

    it('traite le trait d’union comme un joncteur interne', () => {
        expect(localise('Le rendez-vous est confirmé.', correction('vous', 'nous'))).toEqual([]);
        expect(localise('Le rendez-vous est confirmé.', correction('rendez-vous', 'rendez-vous de midi'))).toHaveLength(1);
    });

    it('ne prend pas le tiret de liste pour un joncteur', () => {
        // Rien ne suit le tiret côté lettre : il ferme bien le mot, comme dans `tokenize`.
        expect(localise('- draps fournis', correction('draps', 'draps propres'))).toHaveLength(1);
    });

    it('garde la première correction dans l’ordre du texte quand deux se chevauchent', () => {
        const texte = 'Je pense que les enfant sont là.';
        const longue = correction('les enfant', 'les enfants', 'accord');
        const courte = correction('enfant', 'enfants', 'accord');

        // L'ordre du modèle ne décide rien : c'est la position dans le texte qui tranche.
        expect(localise(texte, longue, courte).map((localisee) => localisee.from)).toEqual(['les enfant']);
        expect(localise(texte, courte, longue).map((localisee) => localisee.from)).toEqual(['les enfant']);
    });

    it('garde la tranche la plus longue quand deux corrections démarrent au même endroit', () => {
        const texte = 'Je pense que les enfant sont là.';
        const localisees = localise(texte, correction('les', 'des'), correction('les enfant', 'les enfants', 'accord'));

        expect(localisees.map((localisee) => localisee.from)).toEqual(['les enfant']);
    });

    it('garde deux corrections voisines tant qu’elles ne se recouvrent pas', () => {
        const texte = 'Je envois les clé demain.';
        const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'), correction('clé', 'clés', 'accord'));

        expect(localisees.map((localisee) => localisee.from)).toEqual(['envois', 'clé']);
    });

    it('écarte un from vide ou fait d’espaces', () => {
        const texte = 'Je vous envoie les clés.';

        expect(localise(texte, correction('', 'les'))).toEqual([]);
        expect(localise(texte, correction('   ', ' '))).toEqual([]);
    });

    it('écarte une correction qui ne change rien', () => {
        // Une puce qui laisse le texte identique : le gérant cliquerait et chercherait ce qu'il a raté.
        expect(localise('Je vous envoie les clés.', correction('clés', 'clés', 'accord'))).toEqual([]);
    });

    it('écarte un from plus long que la limite, mais garde celui qui tient dedans', () => {
        const troplongue = `${'très '.repeat(24)}bien`;
        const acceptable = `${'très '.repeat(23)}bien`;

        expect(troplongue.length).toBeGreaterThan(MAX_CORRECTION_LENGTH);
        expect(acceptable.length).toBeLessThanOrEqual(MAX_CORRECTION_LENGTH);

        expect(localise(`C’est ${troplongue}.`, correction(troplongue, 'très bien'))).toEqual([]);
        expect(localise(`C’est ${acceptable}.`, correction(acceptable, 'très bien'))).toHaveLength(1);
    });

    it('rend la liste triée par start croissant, quel que soit l’ordre du modèle', () => {
        const texte = 'Je envois les clé demain, merci beaucoups.';
        const localisees = localise(
            texte,
            correction('beaucoups', 'beaucoup', 'orthographe'),
            correction('clé', 'clés', 'accord'),
            correction('envois', 'envoie', 'conjugaison'),
        );

        expect(localisees.map((localisee) => localisee.from)).toEqual(['envois', 'clé', 'beaucoups']);
        expect(localisees.map((localisee) => localisee.start)).toEqual([...localisees.map((localisee) => localisee.start)].sort((a, b) => a - b));
    });

    it('rend une liste vide sur un texte vide ou sans correction', () => {
        expect(locateCorrections('', [correction('clé', 'clés', 'accord')])).toEqual([]);
        expect(locateCorrections('Je vous envoie les clés.', [])).toEqual([]);
    });
});

describe('applyCorrections', () => {
    const texte = 'Je envois les clé demain.';
    const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'), correction('clé', 'clés', 'accord'));

    it('n’applique que les corrections cochées', () => {
        expect(applyCorrections(texte, localisees, new Set([0]))).toBe('Je envoie les clé demain.');
        expect(applyCorrections(texte, localisees, new Set([1]))).toBe('Je envois les clés demain.');
        expect(applyCorrections(texte, localisees, new Set([0, 1]))).toBe('Je envoie les clés demain.');
    });

    it('ne change rien sur un ensemble vide, et reste idempotent', () => {
        expect(applyCorrections(texte, localisees, new Set())).toBe(texte);
        expect(applyCorrections(applyCorrections(texte, localisees, new Set()), localisees, new Set())).toBe(texte);
    });

    it('ne décale pas la correction suivante quand la première rallonge le texte', () => {
        const message = 'Les clé sont dans le tiroir et les serviette aussi.';
        const corrections = localise(message, correction('clé', 'clés', 'accord'), correction('serviette', 'serviettes', 'accord'));

        expect(applyCorrections(message, corrections, new Set([0, 1]))).toBe('Les clés sont dans le tiroir et les serviettes aussi.');
    });

    it('ne décale pas la correction suivante quand la première raccourcit le texte', () => {
        const message = 'Je leur envoient les clé.';
        const corrections = localise(message, correction('envoient', 'envoie', 'conjugaison'), correction('clé', 'clés', 'accord'));

        expect(applyCorrections(message, corrections, new Set([0, 1]))).toBe('Je leur envoie les clés.');
    });

    it('ignore un index qui ne désigne aucune correction', () => {
        expect(applyCorrections(texte, localisees, new Set([0, 7]))).toBe('Je envoie les clé demain.');
    });

    it('ne touche ni au texte ni à la liste reçue', () => {
        const avant = localisees.map((localisee) => ({ ...localisee }));

        applyCorrections(texte, localisees, new Set([0, 1]));

        expect(localisees).toEqual(avant);
        expect(texte).toBe('Je envois les clé demain.');
    });
});

describe('mergeWithSpelling', () => {
    const texte = 'Je envois les clé demain.';
    const localisees = localise(texte, correction('envois', 'envoie', 'conjugaison'), correction('clé', 'clés', 'accord'));

    it('écarte la correction qui retombe sur un mot déjà signalé hors ligne', () => {
        const signalement = { start: texte.indexOf('clé'), end: texte.indexOf('clé') + 'clé'.length };

        expect(mergeWithSpelling(localisees, [signalement]).map((localisee) => localisee.from)).toEqual(['envois']);
    });

    it('écarte aussi un recouvrement partiel', () => {
        const signalement = { start: texte.indexOf('clé') - 1, end: texte.indexOf('clé') + 1 };

        expect(mergeWithSpelling(localisees, [signalement]).map((localisee) => localisee.from)).toEqual(['envois']);
    });

    it('garde tout quand la couche hors ligne n’a rien signalé', () => {
        expect(mergeWithSpelling(localisees, [])).toEqual(localisees);
    });

    it('garde un signalement collé mais disjoint', () => {
        // Bord à bord, `end` exclu : le mot d'à côté n'est pas le même mot.
        const signalement = { start: texte.indexOf('clé') - 4, end: texte.indexOf('clé') };

        expect(mergeWithSpelling(localisees, [signalement])).toEqual(localisees);
    });

    it('ne touche pas à la liste reçue', () => {
        const avant = localisees.map((localisee) => ({ ...localisee }));

        mergeWithSpelling(localisees, [{ start: 0, end: 30 }]);

        expect(localisees).toEqual(avant);
    });
});

describe('summarize', () => {
    it('rend les quatre natures, à zéro, sur une liste vide', () => {
        expect(summarize([])).toEqual({ orthographe: 0, conjugaison: 0, accord: 0, grammaire: 0 });
    });

    it('compte les corrections par nature', () => {
        const texte = 'Je envois les clé et les serviette, merci beaucoups.';
        const localisees = localise(
            texte,
            correction('envois', 'envoie', 'conjugaison'),
            correction('clé', 'clés', 'accord'),
            correction('serviette', 'serviettes', 'accord'),
            correction('beaucoups', 'beaucoup', 'orthographe'),
        );

        expect(summarize(localisees)).toEqual({ orthographe: 1, conjugaison: 1, accord: 2, grammaire: 0 });
    });

    it('ignore une nature que le contrat ne prévoit pas', () => {
        // `kind` vient du réseau : le type TypeScript ne le contraint pas à l'exécution.
        const inventee: LocatedCorrection = { from: 'clé', to: 'clés', kind: 'style' as GrammarKind, reason: 'ton', start: 0, end: 3 };

        expect(summarize([inventee])).toEqual({ orthographe: 0, conjugaison: 0, accord: 0, grammaire: 0 });
    });
});
