/*
|--------------------------------------------------------------------------
| Couche typographie : des règles déterministes, sans dictionnaire
|--------------------------------------------------------------------------
|
| Ce sont les seules corrections que le correcteur APPLIQUE d'office (la couche
| dictionnaire, elle, ne fait que suggérer). Deux conséquences sur le contenu
| des listes ci-dessous :
|
|  1. Aucune règle ambiguë. On ne touche JAMAIS à « a » vs « à » ni « ou » vs
|     « où » isolés : le message part à un voyageur, une correction fausse coûte
|     plus cher que dix fautes laissées.
|  2. Cette couche tourne AUSSI sur les messages qui ne sont pas en français
|     (cf. `index.ts` : quand la langue n'est pas reconnue, on ne garde qu'elle).
|     Donc aucun mot des tables d'accents ne doit être un mot anglais courant :
|     « reservation », « hotel », « telephone », « depart », « entree » en sont
|     volontairement ABSENTS pour cette raison.
*/

import { splitElision, tokenize, type Token } from './tokenize';

export type TypographyRule =
    | 'espaces-multiples'
    | 'espace-parasite-avant-ponctuation'
    | 'espace-avant-double-ponctuation'
    | 'espace-apres-ponctuation'
    | 'mot-double'
    | 'majuscule-debut-de-phrase'
    | 'accent-oublie'
    | 'faute-frequente';

export interface Correction {
    /** Index de début dans le texte analysé. `start === end` = insertion pure. */
    start: number;
    /** Index de fin (exclu) dans le texte analysé. */
    end: number;
    /** Texte remplacé (`''` pour une insertion). */
    from: string;
    /** Texte de remplacement (`''` pour une suppression). */
    to: string;
    rule: TypographyRule;
}

export interface ProtectedRange {
    start: number;
    end: number;
}

/**
 * Zones intouchables : on n'y insère ni espace ni majuscule, on n'y corrige aucun mot.
 *
 * POURQUOI : sans elles, la règle « espace avant les deux-points » transforme
 * « https://thelandlord.tn » en « https ://thelandlord.tn », et la règle « espace après
 * le point » casse « booking.com » en « booking. com ». Les gabarits de messages de la
 * messagerie contiennent tous au moins une URL, un e-mail ou une heure.
 *
 * La liste des extensions est volontairement courte (celles que le métier utilise) :
 * plus elle est longue, plus on protège par erreur des fins de phrase du genre « Merci.de ».
 */
const PROTECTED_PATTERNS: readonly RegExp[] = [
    /[\w.+-]+@[\w-]+\.[\w.-]+/giu, // e-mails
    /https?:\/\/\S+/giu, // URLs avec schéma
    /www\.\S+/giu, // URLs sans schéma
    /[\w-]+\.(?:com|fr|tn|net|org|io)\b/giu, // domaines nus : booking.com, thelandlord.tn
    /\b\d{1,2}\s?[:h]\s?\d{2}\b/giu, // heures : 14:30, 14h30, 14 h 30
    /\b\d+[.,]\d+\b/gu, // décimaux : 3,5 — 12.50
    /&#?\w{1,8};/gu, // entités HTML : &nbsp; &#233;
];

/**
 * Abréviations dont le point ne termine PAS une phrase.
 * Sans elles, « etc. et surtout » deviendrait « etc. Et surtout ».
 */
const ABBREVIATIONS: ReadonlySet<string> = new Set([
    'm',
    'mm',
    'mme',
    'mmes',
    'mlle',
    'dr',
    'pr',
    'me',
    'st',
    'ste',
    'etc',
    'ex',
    'cf',
    'av',
    'bd',
    'rte',
    'tel',
    'tél',
    'ref',
    'réf',
    'art',
    'fig',
    'vol',
    'ch',
    'appt',
    'env',
    'min',
    'max',
    'nb',
    'ps',
]);

/**
 * Mots dont la répétition est légitime : « nous nous sommes installés », « si si »,
 * « très très bien ». Sans cette liste, la règle du mot doublé mutile des phrases correctes.
 */
const LEGITIMATE_DOUBLES: ReadonlySet<string> = new Set([
    'nous',
    'vous',
    'si',
    'très',
    'tres',
    'non',
    'oui',
    'ah',
    'oh',
    'eh',
    'hé',
    'ha',
    'hi',
    'ho',
    'tout',
    'chut',
]);

/**
 * Accents oubliés — table FERMÉE et non ambiguë : chaque clé n'existe en français que
 * dans sa version accentuée, et n'est pas un mot anglais courant (cf. l'en-tête du fichier).
 */
const MISSING_ACCENTS: ReadonlyMap<string, string> = new Map([
    ['tres', 'très'],
    ['deja', 'déjà'],
    ['etre', 'être'],
    ['meme', 'même'],
    ['memes', 'mêmes'],
    ['apres', 'après'],
    ['voila', 'voilà'],
    ['ca', 'ça'],
    ['ete', 'été'],
    ['etes', 'êtes'],
    ['etais', 'étais'],
    ['etait', 'était'],
    ['etaient', 'étaient'],
    ['desole', 'désolé'],
    ['desolee', 'désolée'],
    ['bientot', 'bientôt'],
    ['plait', 'plaît'],
    ['probleme', 'problème'],
    ['problemes', 'problèmes'],
    ['sejour', 'séjour'],
    ['sejours', 'séjours'],
    ['arrivee', 'arrivée'],
    ['arrivees', 'arrivées'],
    ['journee', 'journée'],
    ['journees', 'journées'],
    ['soiree', 'soirée'],
    ['soirees', 'soirées'],
    ['annee', 'année'],
    ['annees', 'années'],
    ['cle', 'clé'],
    ['cles', 'clés'],
    ['etage', 'étage'],
    ['etages', 'étages'],
    ['numero', 'numéro'],
    ['numeros', 'numéros'],
    ['securite', 'sécurité'],
    ['recu', 'reçu'],
    ['apercu', 'aperçu'],
    ['facon', 'façon'],
    ['garcon', 'garçon'],
]);

/**
 * Fautes fréquentes, mot à mot. Aucune de ces graphies n'est correcte, ni en français ni en anglais.
 *
 * Les agglutinations de « jusqu' » sont ici, et non dans la reconstruction d'élision du
 * dictionnaire : leur reste (« a », « au », « aux ») est un mot-outil trop court pour que la
 * reconstruction soit sûre, alors que la graphie collée, elle, n'a qu'une lecture possible.
 * Non ambiguës, donc APPLIQUÉES d'office comme le reste de cette couche.
 */
const COMMON_MISSPELLINGS: ReadonlyMap<string, string> = new Map([
    ['addresse', 'adresse'],
    ['addresses', 'adresses'],
    ['acceuil', 'accueil'],
    ['acceuillir', 'accueillir'],
    ['parmis', 'parmi'],
    ['malgres', 'malgré'],
    ['aujourdhui', "aujourd'hui"],
    ['quelquun', "quelqu'un"],
    // Élisions fréquentes recollées D'OFFICE : aucune n'est un mot du dictionnaire (mesuré),
    // aucune n'est ambiguë, et les traiter ici plutôt qu'en suggestion sert deux buts. D'abord
    // le gérant n'a rien à cliquer sur les fautes les plus courantes. Ensuite et surtout, la
    // reconstruction d'élision de la couche dictionnaire refuse désormais TOUT mot capitalisé,
    // pour ne plus inventer « S'abri » sur le prénom « Sabri » : sans cette table, « Jai bien
    // reçu » en tête de phrase ne serait plus corrigé du tout. La casse initiale est reportée
    // par `restoreCapitalization`, donc « jai » et « Jai » sont couverts par la seule clé.
    //
    // NE PAS y ajouter « sil » ni « dune » : ce sont de VRAIS mots (une ocre naturelle, une
    // dune de sable), les corriger d'office réécrirait du français juste. « sil » reste une
    // simple suggestion, via la table des mots suspects.
    ['jai', "j'ai"],
    ['jaime', "j'aime"],
    ['jespere', "j'espère"],
    ['jaimerais', "j'aimerais"],
    ['cest', "c'est"],
    ['cetait', "c'était"],
    ['quil', "qu'il"],
    ['quon', "qu'on"],
    ['nest', "n'est"],
    ['nhesitez', "n'hésitez"],
    ['dun', "d'un"],
    ['dabord', "d'abord"],
    ['daccord', "d'accord"],
    ['lorsquil', "lorsqu'il"],
    ['puisquil', "puisqu'il"],
    ['jusqua', "jusqu'à"],
    ['jusquau', "jusqu'au"],
    ['jusquaux', "jusqu'aux"],
    ['apelle', 'appelle'],
    ['apeller', 'appeler'],
    ['connaitre', 'connaître'],
    ['disponnible', 'disponible'],
    ['disponnibles', 'disponibles'],
    ['confortabe', 'confortable'],
]);

/**
 * Locutions corrigées en bloc. Elles passent AVANT les règles mot à mot (cf. `RULE_ORDER`)
 * pour que « a bientot » devienne « à bientôt » d'un coup, et non « a bientôt ».
 *
 * C'est ici — et NULLE PART ailleurs — qu'on se permet de toucher à « a » : isolé il est
 * ambigu (« il a »), mais « a cote », « a bientot » ou « tout a fait » ne le sont pas.
 */
const PHRASES: ReadonlyMap<string, string> = new Map([
    ["s'il vous plait", "s'il vous plaît"],
    ['sil vous plait', "s'il vous plaît"],
    ["s'il te plait", "s'il te plaît"],
    ['rendez vous', 'rendez-vous'],
    ['week end', 'week-end'],
    ['week ends', 'week-ends'],
    ['a bientot', 'à bientôt'],
    ['a cote', 'à côté'],
    ['a partir', 'à partir'],
    ['a demain', 'à demain'],
    ['tout a fait', 'tout à fait'],
    ['ci joint', 'ci-joint'],
    ['ci jointe', 'ci-jointe'],
    ['check in', 'check-in'],
    ['check out', 'check-out'],
]);

/** Longueur maximale d'une locution, en mots — évite de tester des fenêtres inutiles. */
const MAX_PHRASE_WORDS = Math.max(...[...PHRASES.keys()].map((phrase) => phrase.split(' ').length));

/**
 * Au-delà de 6 passes, c'est une oscillation et non une convergence : on s'arrête.
 * Mesuré sur les cas de test : les messages réels se stabilisent en 1 passe, 2 quand une
 * insertion d'espace débloque une majuscule (« bonjour.comment » → « Bonjour. Comment »).
 */
export const MAX_TYPOGRAPHY_PASSES = 6;

/** Ordre de priorité : en cas de chevauchement, la règle la plus haute gagne, l'autre repasse au tour suivant. */
const RULE_ORDER: readonly ((text: string, protectedRanges: readonly ProtectedRange[]) => Correction[])[] = [
    detectPhrases,
    detectDoubledWords,
    detectMisspellings,
    detectMissingAccents,
    detectStraySpaceBeforePunctuation,
    detectMultipleSpaces,
    detectSpaceBeforeDoublePunctuation,
    detectSpaceAfterPunctuation,
    detectSentenceCapital,
];

/**
 * Règles qui n'ont de sens QUE sur du français, et qui abîment un message écrit
 * dans une autre langue.
 *
 * Mesuré : « Could you confirm your arrival time? » ressortait en « … time ? »,
 * parce que l'espace avant le point d'interrogation est une règle FRANÇAISE.
 * Les gérants écrivent aussi en anglais aux voyageurs Airbnb — cette espace est
 * une faute visible, ajoutée par un correcteur censé en retirer.
 *
 * Les accents et les fautes fréquentes sont français par nature : « hotel » ne
 * doit pas devenir « hôtel » dans une phrase anglaise. Le reste — espaces en
 * double, espace parasite, espace après la ponctuation, mot doublé, majuscule en
 * début de phrase — vaut dans toutes les langues à alphabet latin.
 */
const FRENCH_ONLY_RULES: ReadonlySet<(text: string, protectedRanges: readonly ProtectedRange[]) => Correction[]> = new Set([
    detectPhrases,
    detectMisspellings,
    detectMissingAccents,
    detectSpaceBeforeDoublePunctuation,
]);

// ---------------------------------------------------------------------------
// Zones protégées
// ---------------------------------------------------------------------------

/** Plages (URLs, e-mails, heures, décimaux, entités HTML) que les règles doivent laisser intactes. */
export function findProtectedRanges(text: string): ProtectedRange[] {
    const ranges: ProtectedRange[] = [];

    for (const pattern of PROTECTED_PATTERNS) {
        pattern.lastIndex = 0;

        let match = pattern.exec(text);

        while (match !== null) {
            ranges.push({ start: match.index, end: match.index + match[0].length });
            match = pattern.exec(text);
        }
    }

    return ranges.sort((left, right) => left.start - right.start);
}

/**
 * `true` si `[start, end)` touche une zone protégée.
 * Une insertion pure (`start === end`) ne compte que si elle tombe À L'INTÉRIEUR de la zone :
 * insérer une espace juste avant une URL reste permis.
 */
export function isProtected(ranges: readonly ProtectedRange[], start: number, end: number): boolean {
    if (start === end) {
        return ranges.some((range) => start > range.start && start < range.end);
    }

    return ranges.some((range) => start < range.end && range.start < end);
}

// ---------------------------------------------------------------------------
// Règles
// ---------------------------------------------------------------------------

/** « deux  espaces » → « deux espaces ». Ne touche ni aux retours à la ligne ni à l'indentation d'une liste. */
function detectMultipleSpaces(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const pattern = /[ \t]{2,}/gu;
    let match = pattern.exec(text);

    while (match !== null) {
        const start = match.index;
        const end = start + match[0].length;
        // Un bloc d'espaces en début de ligne est une indentation volontaire (liste, signature).
        const isIndentation = start === 0 || text[start - 1] === '\n';

        if (!isIndentation && !isProtected(protectedRanges, start, end)) {
            corrections.push({ start, end, from: match[0], to: ' ', rule: 'espaces-multiples' });
        }

        match = pattern.exec(text);
    }

    return corrections;
}

/** « Bonjour , » → « Bonjour, ». Seuls la virgule et le point refusent l'espace qui les précède. */
function detectStraySpaceBeforePunctuation(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const pattern = /[ \t]+(?=[,.])/gu;
    let match = pattern.exec(text);

    while (match !== null) {
        const start = match.index;
        const end = start + match[0].length;
        const before = text[start - 1] ?? '';

        // Si l'espace ouvre la ligne, il n'y a aucun mot à recoller à la ponctuation.
        if (before !== '' && before !== '\n' && !isProtected(protectedRanges, start, end + 1)) {
            corrections.push({ start, end, from: match[0], to: '', rule: 'espace-parasite-avant-ponctuation' });
        }

        match = pattern.exec(text);
    }

    return corrections;
}

/**
 * Typographie française : « ? », « ! », « : » et « ; » réclament une espace AVANT.
 * Insertion pure (`start === end`) pour ne jamais entrer en conflit avec la règle qui ajoute
 * l'espace APRÈS la même ponctuation.
 */
function detectSpaceBeforeDoublePunctuation(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const pattern = /([\p{L}\p{N}])([?!:;])/gu;
    let match = pattern.exec(text);

    while (match !== null) {
        const punctuationIndex = match.index + 1;
        // Un chiffre devant « : » est une heure ou un score (« 14:30 », « 3:2 ») : on n'y touche pas.
        const isDigitBeforeColon = match[2] === ':' && /\p{N}/u.test(match[1]);

        if (!isDigitBeforeColon && !isProtected(protectedRanges, punctuationIndex, punctuationIndex)) {
            corrections.push({
                start: punctuationIndex,
                end: punctuationIndex,
                from: '',
                to: ' ',
                rule: 'espace-avant-double-ponctuation',
            });
        }

        match = pattern.exec(text);
    }

    return corrections;
}

/** « Bonjour,comment » → « Bonjour, comment ». Insertion pure, elle aussi. */
function detectSpaceAfterPunctuation(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const pattern = /[,.!?:;](?=\p{L})/gu;
    let match = pattern.exec(text);

    while (match !== null) {
        const punctuationIndex = match.index;
        const insertionIndex = punctuationIndex + 1;
        // Il faut un mot AVANT la ponctuation, sinon on est dans un motif technique (« :-) », « ...suite »).
        const hasWordBefore = /[\p{L}\p{N}]/u.test(text[punctuationIndex - 1] ?? '');

        if (hasWordBefore && !isProtected(protectedRanges, punctuationIndex, insertionIndex + 1)) {
            corrections.push({
                start: insertionIndex,
                end: insertionIndex,
                from: '',
                to: ' ',
                rule: 'espace-apres-ponctuation',
            });
        }

        match = pattern.exec(text);
    }

    return corrections;
}

/** « le le » → « le ». On supprime la SECONDE occurrence pour garder la capitale de la première. */
function detectDoubledWords(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const tokens = tokenize(text);

    for (let index = 0; index + 2 < tokens.length; index += 1) {
        const first = tokens[index];
        const separator = tokens[index + 1];
        const second = tokens[index + 2];

        if (!first.isWord || !second.isWord || separator.isWord) {
            continue;
        }

        // Uniquement des espaces entre les deux : un retour à la ligne ou une ponctuation
        // change le sens (« Merci. Merci ! »).
        if (!/^[ \t]+$/u.test(separator.text)) {
            continue;
        }

        const normalized = first.text.toLocaleLowerCase('fr');

        if (normalized !== second.text.toLocaleLowerCase('fr')) {
            continue;
        }

        if (LEGITIMATE_DOUBLES.has(normalized) || /\p{N}/u.test(normalized) || isProtected(protectedRanges, first.start, second.end)) {
            continue;
        }

        corrections.push({
            start: first.end,
            end: second.end,
            from: text.slice(first.end, second.end),
            to: '',
            rule: 'mot-double',
        });
    }

    return corrections;
}

/**
 * Majuscule en début de phrase : premier mot du message, puis premier mot après « . », « ! », « ? ».
 * On ne capitalise PAS après un simple retour à la ligne — les listes à puces des messages
 * d'arrivée (« - draps fournis ») n'ont pas à être retouchées.
 */
function detectSentenceCapital(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const pattern = /(^|[.!?…])([ \t\n]*)(\p{Ll})/gu;
    let match = pattern.exec(text);

    while (match !== null) {
        const letterIndex = match.index + match[1].length + match[2].length;
        const letter = match[3];
        const uppercased = letter.toLocaleUpperCase('fr');
        // « iPhone », « eSIM » : une deuxième lettre en capitale signale une marque, pas une phrase.
        const isCamelCaseBrand = /\p{Lu}/u.test(text[letterIndex + 1] ?? '');

        if (
            uppercased !== letter &&
            !isCamelCaseBrand &&
            !isProtected(protectedRanges, letterIndex, letterIndex + 1) &&
            !followsAbbreviation(text, match.index, match[1])
        ) {
            corrections.push({
                start: letterIndex,
                end: letterIndex + 1,
                from: letter,
                to: uppercased,
                rule: 'majuscule-debut-de-phrase',
            });
        }

        // On repart juste après la lettre : « a. b. c » doit voir les trois phrases.
        pattern.lastIndex = letterIndex + 1;
        match = pattern.exec(text);
    }

    return corrections;
}

function followsAbbreviation(text: string, terminatorIndex: number, terminator: string): boolean {
    if (terminator !== '.') {
        return false;
    }

    const lastWord = /(\p{L}+)$/u.exec(text.slice(0, terminatorIndex));

    return lastWord !== null && ABBREVIATIONS.has(lastWord[1].toLocaleLowerCase('fr'));
}

function detectMissingAccents(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    return detectWordMap(text, protectedRanges, MISSING_ACCENTS, 'accent-oublie');
}

function detectMisspellings(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    return detectWordMap(text, protectedRanges, COMMON_MISSPELLINGS, 'faute-frequente');
}

function detectWordMap(
    text: string,
    protectedRanges: readonly ProtectedRange[],
    map: ReadonlyMap<string, string>,
    rule: TypographyRule,
): Correction[] {
    const corrections: Correction[] = [];

    for (const token of tokenize(text)) {
        if (!token.isWord || isAllCaps(token.text) || isProtected(protectedRanges, token.start, token.end)) {
            continue;
        }

        const restored = replaceFromMap(token.text, map);

        if (restored !== null && restored !== token.text) {
            corrections.push({ start: token.start, end: token.end, from: token.text, to: restored, rule });
        }
    }

    return corrections;
}

/**
 * Cherche le mot dans la table, élision comprise : « l'addresse » se corrige via l'entrée
 * « addresse », en conservant le « l' » tel qu'il a été tapé.
 */
function replaceFromMap(word: string, map: ReadonlyMap<string, string>): string | null {
    const direct = map.get(normalizeWord(word));

    if (direct !== undefined) {
        return restoreCapitalization(word, direct);
    }

    const elision = splitElision(word);

    if (elision === null) {
        return null;
    }

    const elided = map.get(normalizeWord(elision.rest));

    return elided === undefined ? null : elision.prefix + restoreCapitalization(elision.rest, elided);
}

function detectPhrases(text: string, protectedRanges: readonly ProtectedRange[]): Correction[] {
    const corrections: Correction[] = [];
    const words: Token[] = tokenize(text).filter((token) => token.isWord);
    let index = 0;

    while (index < words.length) {
        let consumed = 0;

        // Fenêtre décroissante : « tout a fait » doit l'emporter sur une locution de 2 mots.
        for (let length = Math.min(MAX_PHRASE_WORDS, words.length - index); length >= 2 && consumed === 0; length -= 1) {
            const first = words[index];
            const last = words[index + length - 1];
            const slice = text.slice(first.start, last.end);

            // Les mots de la locution ne doivent être séparés que par des espaces.
            if (!/^[\p{L}\p{N}'’-]+(?:[ \t]+[\p{L}\p{N}'’-]+)*$/u.test(slice)) {
                continue;
            }

            const replacement = PHRASES.get(normalizeWord(slice).replace(/[ \t]+/gu, ' '));

            if (replacement === undefined || isProtected(protectedRanges, first.start, last.end)) {
                continue;
            }

            const restored = restoreCapitalization(slice, replacement);

            if (restored !== slice) {
                corrections.push({ start: first.start, end: last.end, from: slice, to: restored, rule: 'faute-frequente' });
            }

            consumed = length;
        }

        index += consumed === 0 ? 1 : consumed;
    }

    return corrections;
}

// ---------------------------------------------------------------------------
// Application
// ---------------------------------------------------------------------------

/** Passe unique : détecte toutes les règles et écarte les corrections qui se chevauchent. */
export function detectTypography(text: string, french = true): Correction[] {
    const protectedRanges = findProtectedRanges(text);
    const kept: Correction[] = [];

    for (const detect of RULE_ORDER) {
        if (!french && FRENCH_ONLY_RULES.has(detect)) {
            continue;
        }

        for (const correction of detect(text, protectedRanges)) {
            if (!overlapsKept(kept, correction)) {
                kept.push(correction);
            }
        }
    }

    return kept.sort((left, right) => left.start - right.start || left.end - right.end);
}

function overlapsKept(kept: readonly Correction[], candidate: Correction): boolean {
    return kept.some((existing) => {
        // Deux insertions pures au même point : on n'en garde qu'une.
        if (existing.start === existing.end && candidate.start === candidate.end) {
            return existing.start === candidate.start;
        }

        return candidate.start < existing.end && existing.start < candidate.end;
    });
}

/** Applique des corrections NON chevauchantes. Une insertion pure se place avant le caractère qu'elle précède. */
export function applyCorrections(text: string, corrections: readonly Correction[]): string {
    const ordered = [...corrections].sort((left, right) => left.start - right.start || left.end - right.end);
    let result = '';
    let cursor = 0;

    for (const correction of ordered) {
        if (correction.start < cursor) {
            continue; // garde-fou : une correction chevauchante corromprait le texte
        }

        result += text.slice(cursor, correction.start) + correction.to;
        cursor = correction.end;
    }

    return result + text.slice(cursor);
}

/**
 * Applique la typographie jusqu'à stabilisation.
 *
 * ⚠️ Les `start`/`end` renvoyés portent sur le texte TEL QU'IL ÉTAIT au moment où la règle
 * s'est déclenchée : ceux de la première passe correspondent au texte d'origine, ceux des
 * passes suivantes au texte déjà partiellement corrigé. L'affichage se fait par `diffWords`,
 * pas par ces offsets.
 *
 * `french` à `false` écarte les règles propres au français (cf. `FRENCH_ONLY_RULES`) et ne
 * laisse que celles qui valent dans toutes les langues. Le défaut reste `true` : la relecture
 * suppose le français, langue de l'équipe, tant qu'on n'a pas la preuve du contraire.
 */
export function applyTypography(text: string, french = true): { corrected: string; corrections: Correction[] } {
    const corrections: Correction[] = [];
    let corrected = text;

    for (let pass = 0; pass < MAX_TYPOGRAPHY_PASSES; pass += 1) {
        const found = detectTypography(corrected, french);

        if (found.length === 0) {
            break;
        }

        const next = applyCorrections(corrected, found);

        if (next === corrected) {
            break; // règle stérile : inutile de reboucler
        }

        corrections.push(...found);
        corrected = next;
    }

    return { corrected, corrections };
}

// ---------------------------------------------------------------------------
// Utilitaires de casse
// ---------------------------------------------------------------------------

/** Minuscules + apostrophes ramenées à « ' », pour interroger les tables ci-dessus. */
export function normalizeWord(word: string): string {
    return word.replace(/[’‘ʼ]/gu, "'").toLocaleLowerCase('fr');
}

/**
 * « CHAMBRE », « RIB » : au moins deux lettres, toutes capitales. Ces mots sont laissés tels quels.
 *
 * On exige que l'écriture CONNAISSE la casse : sans ce garde-fou, l'arabe — qui n'a ni
 * majuscules ni minuscules — serait pris en bloc pour une suite de sigles.
 */
export function isAllCaps(word: string): boolean {
    const letters = word.replace(/[^\p{L}]/gu, '');
    const upper = letters.toLocaleUpperCase('fr');
    const isCasedScript = upper !== letters.toLocaleLowerCase('fr');

    return letters.length >= 2 && isCasedScript && letters === upper;
}

/** Reporte la capitale initiale de l'original sur le remplacement (« Tres » → « Très »). */
export function restoreCapitalization(original: string, replacement: string): string {
    const first = original[0] ?? '';
    const isCapitalized = first !== '' && first === first.toLocaleUpperCase('fr') && first !== first.toLocaleLowerCase('fr');

    if (!isCapitalized || replacement === '') {
        return replacement;
    }

    return replacement[0].toLocaleUpperCase('fr') + replacement.slice(1);
}
