/*
|--------------------------------------------------------------------------
| Correcteur orthographique de la messagerie — orchestrateur
|--------------------------------------------------------------------------
|
| Deux couches, deux traitements volontairement différents :
|
|   1. TYPOGRAPHIE (`typography.ts`) — déterministe, sans dictionnaire, donc APPLIQUÉE
|      d'office dans `corrected`.
|   2. ORTHOGRAPHE (`dictionary.ts`) — statistique, donc SUGGÉRÉE seulement. Aucun mot
|      n'est remplacé sans l'accord du gérant : un dictionnaire ne connaît ni le nom du
|      voyageur, ni le vocabulaire du métier.
|
| Tout tourne dans le navigateur : aucun appel réseau vers un tiers, aucun coût, aucune
| donnée voyageur qui sort. Le seul téléchargement est celui de l'asset statique du
| dictionnaire, servi par notre propre domaine.
*/

import {
    createFrenchDictionary,
    DEFAULT_SUGGESTION_COUNT,
    ELISION_REBUILD_PREFIXES,
    findSuspectWord,
    FREQUENT_SHORT_ELISIONS,
    loadFrenchDictionary,
    resetFrenchDictionary,
    SHORT_FUNCTION_WORDS,
    SUSPECT_WORDS,
    type FrenchDictionary,
    type Speller,
    type SuggestContext,
} from './dictionary';
import {
    grammarCertainty,
    isAccentRestoration,
    isCasingOnly,
    isElisionRebuild,
    partition,
    spellingCertainty,
    type Certitude,
} from './confidence';
import { diffWords, type DiffSegment, type DiffSegmentType } from './diff';
import {
    applyCorrections,
    GRAMMAR_KINDS,
    locateCorrections,
    MAX_CORRECTION_LENGTH,
    mergeWithSpelling,
    summarize,
    type GrammarCorrection,
    type GrammarKind,
    type LocatedCorrection,
} from './grammar';
import { hasLatinLetter, startsSentence, tokenize, wordTokens, type Token } from './tokenize';
import { applyTypography, findProtectedRanges, isProtected, type Correction, type TypographyRule } from './typography';
import { createWhitelist, MIN_CHECKED_LENGTH, type Whitelist } from './whitelist';

// Façade : l'appelant n'a qu'un seul chemin d'import à connaître.
export {
    applyCorrections,
    applyTypography,
    createFrenchDictionary,
    createWhitelist,
    DEFAULT_SUGGESTION_COUNT,
    diffWords,
    ELISION_REBUILD_PREFIXES,
    findSuspectWord,
    FREQUENT_SHORT_ELISIONS,
    GRAMMAR_KINDS,
    grammarCertainty,
    isAccentRestoration,
    isCasingOnly,
    isElisionRebuild,
    hasLatinLetter,
    loadFrenchDictionary,
    locateCorrections,
    MAX_CORRECTION_LENGTH,
    mergeWithSpelling,
    MIN_CHECKED_LENGTH,
    partition,
    resetFrenchDictionary,
    SHORT_FUNCTION_WORDS,
    spellingCertainty,
    startsSentence,
    summarize,
    SUSPECT_WORDS,
    tokenize,
    wordTokens,
    type Certitude,
    type Correction,
    type DiffSegment,
    type DiffSegmentType,
    type FrenchDictionary,
    type GrammarCorrection,
    type GrammarKind,
    type LocatedCorrection,
    type Speller,
    type SuggestContext,
    type Token,
    type TypographyRule,
    type Whitelist,
};

/**
 * Au-delà de 40 % de mots inconnus, on considère que le message n'est pas en français.
 * Mesuré sur le dictionnaire « Classique » : un message français normal, prénoms et noms
 * de logement compris, plafonne autour de 10-15 % ; un message anglais dépasse 70 %.
 */
export const UNKNOWN_WORD_RATIO_LIMIT = 0.4;

/**
 * En dessous de 6 mots testables, le ratio n'est que du bruit : sur deux mots, un seul
 * prénom inconnu suffirait à faire basculer le message à 50 % et à désactiver le correcteur.
 */
export const MIN_WORDS_FOR_LANGUAGE_GUESS = 6;

export type ProofreadIssueKind = 'typographie' | 'orthographe';

export interface ProofreadIssue {
    /** `typographie` = déjà appliquée dans `corrected` ; `orthographe` = simple suggestion. */
    kind: ProofreadIssueKind;
    /**
     * Règle typographique déclenchée, ou, pour la couche dictionnaire : `mot-inconnu` (le
     * dictionnaire refuse le mot) et `mot-suspect` (il l'accepte, mais c'est une faute ici —
     * cf. `SUSPECT_WORDS`).
     */
    rule: TypographyRule | 'mot-inconnu' | 'mot-suspect';
    /**
     * Position dans le texte analysé. Pour `orthographe`, les offsets portent sur `corrected`
     * (c'est ce texte-là qui est affiché et envoyé) ; pour `typographie`, sur le texte tel
     * qu'il était au moment où la règle a joué — cf. le commentaire de `applyTypography`.
     */
    start: number;
    end: number;
    /** Texte d'origine. */
    from: string;
    /** Remplacement appliqué (typographie) ou première suggestion (orthographe, `''` si aucune). */
    to: string;
    /** Suggestions du dictionnaire, vide pour la typographie. */
    suggestions: string[];
    /** `true` si la correction est déjà dans `corrected`. */
    applied: boolean;
}

export interface ProofreadOptions {
    /**
     * Dictionnaire déjà chargé, ou `null` pour ne PAS utiliser la couche orthographe.
     * Laissé à `undefined` (défaut), le dictionnaire français est chargé paresseusement.
     */
    dictionary?: FrenchDictionary | null;
    /** Mots à ne jamais signaler : nom du logement, nom du voyageur, ville de la réservation… */
    extraWords?: readonly string[];
    /** Nombre maximum de suggestions par mot inconnu (défaut : 3). */
    maxSuggestions?: number;
}

export interface ProofreadResult {
    /** Le message, couche typographie appliquée. Les suggestions d'orthographe n'y sont PAS. */
    corrected: string;
    issues: ProofreadIssue[];
    /** `true` si `corrected !== text`. Les suggestions d'orthographe seules ne le déclenchent pas. */
    hasChanges: boolean;
    /** `false` quand le message a été jugé non francophone : la couche orthographe a été sautée. */
    isFrench: boolean;
    /** `false` quand le dictionnaire n'a pas pu être chargé : seule la typographie a tourné. */
    dictionaryAvailable: boolean;
}

/**
 * Relit un message.
 *
 * ```ts
 * const resultat = await proofread(brouillon, { extraWords: [logement.name, client.full_name] });
 * if (resultat.hasChanges) {
 *     // afficher diffWords(brouillon, resultat.corrected) avant d'envoyer resultat.corrected
 * }
 * ```
 */
export async function proofread(text: string, options: ProofreadOptions = {}): Promise<ProofreadResult> {
    // Deux candidats, calculés depuis le texte D'ORIGINE : la version française
    // complète, et celle qui n'applique que les règles valables dans toutes les
    // langues. On tranche ensuite, et on n'en garde qu'UNE — rejouer depuis
    // l'origine évite d'empiler deux parcours de corrections aux offsets
    // incompatibles.
    //
    // Pourquoi ce détour : l'espace avant « ? » est une règle FRANÇAISE. Sans ce
    // partage, « Could you confirm your arrival time? » ressortait en
    // « … time ? » — une faute ajoutée par le correcteur, dans des messages que
    // les gérants écrivent tous les jours aux voyageurs Airbnb.
    const frenchPass = applyTypography(text, true);
    const neutralPass = applyTypography(text, false);

    const dictionary = options.dictionary === undefined ? await loadFrenchDictionary() : options.dictionary;
    const whitelist = createWhitelist(options.extraWords ?? []);

    // Le verdict de langue se prend sur la version FRANÇAISE, la plus favorable au
    // français : un message français mal accentué (« sejour », « arrivee ») y a ses
    // accents remis, donc ses mots redeviennent connus. Le juger sur le texte brut
    // ferait passer pour de l'anglais un message simplement tapé à la va-vite — et
    // lui refuserait précisément les accents dont il a besoin. Un message anglais,
    // lui, n'est pas affecté : la table des accents exclut les mots anglais courants.
    //
    // Et l'on ne compte comme étranger que ce que les couches françaises ne savent PAS
    // réparer — cf. `isForeignToProbe`.
    const isFrench =
        dictionary === null
            ? true // sans dictionnaire, on suppose le français : c'est la langue de l'équipe
            : (() => {
                  const probe = collectCheckableWords(frenchPass.corrected, whitelist);
                  const unknownProbe = probe.filter((token) => isForeignToProbe(dictionary, frenchPass.corrected, token));

                  return looksFrench(probe.length, unknownProbe.length);
              })();

    const { corrected, corrections } = isFrench ? frenchPass : neutralPass;

    const issues: ProofreadIssue[] = corrections.map((correction) => ({
        kind: 'typographie',
        rule: correction.rule,
        start: correction.start,
        end: correction.end,
        from: correction.from,
        to: correction.to,
        suggestions: [],
        applied: true,
    }));

    if (dictionary === null) {
        return { corrected, issues, hasChanges: corrected !== text, isFrench: true, dictionaryAvailable: false };
    }

    if (isFrench) {
        // Recalculé sur le texte RETENU, et non sur la sonde : les accents remis
        // changent les mots, et les offsets doivent porter sur `corrected`.
        const candidates = collectSpellableWords(corrected, whitelist);
        const maxSuggestions = Math.max(0, options.maxSuggestions ?? DEFAULT_SUGGESTION_COUNT);

        for (const token of candidates) {
            // Les mots suspects CONTOURNENT `check()` : le dictionnaire les accepte (« cava »
            // est le verbe caver), c'est le contexte d'une messagerie de location qui en fait
            // des fautes. La liste blanche garde le dernier mot : `collectCheckableWords` a
            // déjà retiré ses mots de `candidates`, un nom de logement « Sil » resterait donc
            // muet.
            const suspect = findSuspectWord(token.text);

            if (suspect !== null) {
                const suggestions = [suspect].slice(0, maxSuggestions);

                issues.push({
                    kind: 'orthographe',
                    rule: 'mot-suspect',
                    start: token.start,
                    end: token.end,
                    from: token.text,
                    to: suggestions[0] ?? '',
                    suggestions,
                    applied: false,
                });

                continue;
            }

            if (dictionary.check(token.text)) {
                continue;
            }

            // Le contexte de phrase vient du TEXTE : c'est lui qui sépare « Jai bien reçu »
            // en tête de message (élision oubliée) de « le chauffeur Sabri » (nom propre).
            const context: SuggestContext = { sentenceStart: startsSentence(corrected, token.start) };
            const suggestions = dictionary.suggest(token.text, maxSuggestions, context);

            issues.push({
                kind: 'orthographe',
                rule: 'mot-inconnu',
                start: token.start,
                end: token.end,
                from: token.text,
                to: suggestions[0] ?? '',
                suggestions,
                applied: false,
            });
        }
    }

    return { corrected, issues, hasChanges: corrected !== text, isFrench, dictionaryAvailable: true };
}

/**
 * Mots que la SONDE DE LANGUE pèse : ni sigles, ni références, ni jetons courts, ni ce qui
 * vit à l'intérieur d'une URL, d'un e-mail ou d'une heure.
 *
 * Les mots d'une autre écriture (arabe, cyrillique) y RESTENT : c'est à eux qu'un message
 * arabe doit d'être classé « pas du français », ce qui tient les règles typographiques
 * françaises à l'écart. Ils sont écartés plus loin, à l'heure de signaler — cf.
 * `collectSpellableWords`.
 */
export function collectCheckableWords(text: string, whitelist: Whitelist): Token[] {
    const protectedRanges = findProtectedRanges(text);

    return tokenize(text).filter(
        (token) =>
            token.isWord &&
            token.text.length >= MIN_CHECKED_LENGTH &&
            !whitelist.isIgnored(token.text) &&
            !isProtected(protectedRanges, token.start, token.end),
    );
}

/**
 * Mots réellement soumis au dictionnaire français : ceux de `collectCheckableWords` qui
 * s'écrivent en alphabet latin.
 *
 * Mesuré : « شكرا لك على الرسالة » fait moins de 6 mots, passe donc sous le plancher de la
 * sonde de langue, et ressortait avec trois mots signalés et zéro suggestion — trois
 * soulignements rouges sur un message arabe parfaitement écrit.
 */
export function collectSpellableWords(text: string, whitelist: Whitelist): Token[] {
    return collectCheckableWords(text, whitelist).filter((token) => hasLatinLetter(token.text));
}

/**
 * `true` si le mot compte comme étranger dans la sonde de langue.
 *
 * Un mot inconnu que les couches françaises savent réparer — élision reconstruite, accent
 * restauré — est du français mal tapé, pas un mot étranger. Sans cette nuance, les élisions
 * oubliées gonflaient le ratio d'inconnus : mesuré, « Bonjour, jai bien recu votre message.
 * Jenvois ladresse de lappartement et lheure darrivee. Merci. » comptait 6 inconnus sur 12,
 * soit 0,50 pour un seuil à 0,40 — le message basculait à « pas du français » et la couche
 * orthographe s'éteignait ENTIÈREMENT. Plus le gérant oubliait d'apostrophes, moins il était
 * aidé, et `extraWords` ne faisait qu'aggraver en rétrécissant le dénominateur.
 *
 * La sonde ne change rien à ce qui est signalé ensuite : elle ne tranche que la langue.
 */
function isForeignToProbe(dictionary: FrenchDictionary, text: string, token: Token): boolean {
    if (dictionary.check(token.text)) {
        return false;
    }

    return !dictionary.isRepairableFrench(token.text, { sentenceStart: startsSentence(text, token.start) });
}

/** Détection de langue pauvre mais sûre : on ne coupe la couche orthographe que sur un signal net. */
export function looksFrench(checkedCount: number, unknownCount: number): boolean {
    if (checkedCount < MIN_WORDS_FOR_LANGUAGE_GUESS) {
        return true; // trop court pour trancher : on suppose le français, langue de l'équipe
    }

    return unknownCount / checkedCount <= UNKNOWN_WORD_RATIO_LIMIT;
}
