/*
|--------------------------------------------------------------------------
| Couche grammaticale — localisation et fusion des corrections du serveur
|--------------------------------------------------------------------------
|
| Le modèle ne rend JAMAIS un texte réécrit : il rend une liste de corrections
| ponctuelles `{ from, to, kind, reason }`. Ce module décide OÙ chacune s'applique,
| et c'est précisément ce qui rend l'ensemble sûr : le message ne peut changer qu'aux
| endroits que le gérant a cochés. Une IA à qui l'on demande un texte corrigé rallonge
| les politesses, change le tutoiement et « améliore » le ton — ici, elle ne le peut
| pas, puisque rien de ce qu'elle écrit en dehors d'un `to` n'atteint le composeur.
|
| POURQUOI les positions se recalculent ici plutôt que de se demander au modèle : un
| modèle de langage compte les caractères à l'estime. Un offset faux d'un seul cran
| découpe un mot au milieu et renvoie « l'appartemeent » au voyageur. Une recherche de
| sous-chaîne, elle, ne se trompe pas : soit le texte contient `from` en position de mot
| entier, soit la correction est écartée sans bruit.
|
| Le reste du dossier tourne dans le navigateur (typographie appliquée d'office,
| orthographe hors ligne suggérée). Cette couche-ci est la seule qui passe par le réseau,
| donc la seule qui puisse halluciner : elle est traitée de bout en bout comme une source
| NON FIABLE — tout ce qui ne se vérifie pas contre le texte est jeté.
*/

/** Les quatre natures du contrat serveur, en français, et rien d'autre. */
export type GrammarKind = 'orthographe' | 'conjugaison' | 'accord' | 'grammaire';

/** Une correction telle qu'elle arrive de `POST /guest-communication/discussions/{id}/grammar`. */
export interface GrammarCorrection {
    /** La sous-chaîne fautive, exactement telle qu'elle apparaît dans le message envoyé. */
    from: string;
    /** Son remplacement. */
    to: string;
    kind: GrammarKind;
    /** Explication courte, en français, affichée sous la puce. */
    reason: string;
    /**
     * Ce que le modèle dit de sa propre certitude, et ce qui décide si la correction
     * s'applique sans clic. Le serveur garantit la valeur : absente, vide ou
     * fantaisiste, elle est ramenée à `unsure` — et un `from` capitalisé est forcé à
     * `unsure` quoi qu'en dise le modèle, pour qu'un texte modifié sans relecture ne
     * puisse jamais renommer une personne.
     *
     * Optionnelle au typage : une réponse d'un serveur plus ancien la perdrait, et
     * `grammarCertainty` retombe alors sur « à vérifier » — un clic de plus, jamais
     * une modification silencieuse.
     */
    confidence?: 'sure' | 'unsure';
}

/**
 * Une correction dont on a prouvé la place dans le texte.
 *
 * Invariant garanti par `locateCorrections` : `text.slice(start, end) === from`. C'est lui
 * qui autorise `applyCorrections` à découper le message sans le relire.
 */
export interface LocatedCorrection extends GrammarCorrection {
    start: number;
    end: number;
}

/**
 * Longueur maximale d'un `from`.
 *
 * Le modèle est censé rendre la PLUS PETITE sous-chaîne fautive — « envois » → « envoie »,
 * « les clé » → « les clés ». Un `from` d'une phrase entière veut dire qu'il a reformulé au
 * lieu de corriger : la puce deviendrait un « remplacer tout ce paragraphe » impossible à
 * relire d'un coup d'œil, exactement ce que ce module existe pour empêcher.
 */
export const MAX_CORRECTION_LENGTH = 120;

/** Ordre d'affichage des natures dans l'en-tête de section du dialogue. */
export const GRAMMAR_KINDS: readonly GrammarKind[] = ['orthographe', 'conjugaison', 'accord', 'grammaire'];

/**
 * Caractères qui composent un mot, et caractères qui peuvent se trouver À L'INTÉRIEUR d'un mot.
 *
 * Ces deux tables recopient celles de `tokenize.ts`, qui les garde privées. POURQUOI ne pas
 * tokeniser le texte et comparer des jetons : un `from` peut couvrir PLUSIEURS mots
 * (« les clé » → « les clés », « je envois » → « j'envoie »), donc ne correspond à aucun jeton
 * unique. On garde la recherche de sous-chaîne, et l'on ne teste que ses deux bords.
 *
 * Les deux apostrophes comptent : les claviers physiques produisent « ' » (U+0027), iOS et les
 * traitements de texte produisent « ’ » (U+2019), et les deux arrivent dans la même conversation.
 */
const WORD_CHARACTER = /[\p{L}\p{N}]/u;
const INNER_JOINER = /['’‘ʼ-]/;

function isWordCharacter(character: string | undefined): boolean {
    return character !== undefined && character !== '' && WORD_CHARACTER.test(character);
}

/**
 * `true` si le caractère d'index `index` est un joncteur INTERNE à un mot, c'est-à-dire
 * s'il est encadré de deux lettres : l'apostrophe de « l'hôtel », le trait d'union de
 * « rendez-vous ». Même règle que `tokenize`, qui en fait un seul jeton.
 *
 * L'apostrophe finale de « les gars' » ou le tiret de liste de « - draps fournis » n'en sont
 * pas : rien ne les suit, ils ferment donc bien le mot.
 */
function isInnerJoiner(text: string, index: number): boolean {
    const character = text[index];

    return character !== undefined && INNER_JOINER.test(character) && isWordCharacter(text[index - 1]) && isWordCharacter(text[index + 1]);
}

/**
 * `true` si la tranche `[start, end)` commence et finit sur une borne de mot.
 *
 * POURQUOI cette garde est le cœur du module : sans elle, une correction « envois » → « envoie »
 * frappe le « envois » de « renvois » et écrit « renvoie » — une faute créée par le correcteur,
 * dans un mot que le gérant avait bien orthographié.
 */
function sitsOnWordBoundaries(text: string, start: number, end: number): boolean {
    const opensCleanly = !isWordCharacter(text[start - 1]) && !isInnerJoiner(text, start - 1);
    const closesCleanly = !isWordCharacter(text[end]) && !isInnerJoiner(text, end);

    return opensCleanly && closesCleanly;
}

/**
 * Toutes les positions où `needle` apparaît en MOT ENTIER dans `text`.
 *
 * Recherche par `indexOf` et non par expression régulière : `from` vient du modèle et contient
 * ce qu'il veut — un point, une parenthèse, un `+`. Une regex construite à la volée en ferait
 * des métacaractères, et il n'y a aucune raison d'échapper une chaîne quand une comparaison
 * littérale fait le même travail.
 */
function findWholeWordOccurrences(text: string, needle: string): number[] {
    const positions: number[] = [];
    let cursor = text.indexOf(needle);

    while (cursor !== -1) {
        if (sitsOnWordBoundaries(text, cursor, cursor + needle.length)) {
            positions.push(cursor);
        }

        cursor = text.indexOf(needle, cursor + 1);
    }

    return positions;
}

/**
 * Corrections que l'on refuse d'examiner, quelle que soit la suite.
 *
 * Un `from` vide ou fait d'espaces ne désigne aucun mot : l'espacement est du ressort de la
 * couche typographique, déjà appliquée d'office avant l'appel au serveur. Un `from === to` est
 * une puce qui ne change rien — cliquer dessus n'aurait aucun effet visible, et le gérant
 * chercherait ce qu'il a raté.
 */
function isUsable(correction: GrammarCorrection): boolean {
    return correction.from.trim() !== '' && correction.from !== correction.to && correction.from.length <= MAX_CORRECTION_LENGTH;
}

/** `true` si les deux plages `[start, end)` se recouvrent, ne serait-ce que d'un caractère. */
function overlaps(left: { start: number; end: number }, right: { start: number; end: number }): boolean {
    return left.start < right.end && right.start < left.end;
}

/**
 * Place chaque correction du modèle dans le texte réellement soumis.
 *
 * ```ts
 * const localisees = locateCorrections(message, reponse.corrections);
 * // → une puce par occurrence, triée dans l'ordre de lecture du message
 * ```
 *
 * Les règles, toutes défensives :
 *   - `from` introuvable en mot entier ⇒ correction ÉCARTÉE en silence. C'est le cas du modèle
 *     qui a halluciné un mot, et celui du gérant qui a continué de taper pendant l'aller-retour
 *     réseau : dans les deux cas, la seule réponse sûre est de ne rien proposer.
 *   - `from` présent plusieurs fois ⇒ UNE correction localisée PAR occurrence. Le modèle ne dit
 *     pas laquelle il visait, et rien ne prouve que les autres soient fautives : c'est au gérant
 *     de cocher, occurrence par occurrence.
 *   - deux corrections qui se chevauchent ⇒ la PREMIÈRE dans l'ordre du texte gagne. Les
 *     appliquer toutes les deux ferait s'écraser leurs tranches, et le résultat dépendrait de
 *     l'ordre des clics.
 *
 * Le tri final est par `start` croissant : le dialogue affiche les puces dans l'ordre où le
 * gérant relit son message, pas dans l'ordre où le modèle y a pensé.
 */
export function locateCorrections(text: string, corrections: readonly GrammarCorrection[]): LocatedCorrection[] {
    const candidates: LocatedCorrection[] = [];

    for (const correction of corrections) {
        if (!isUsable(correction)) {
            continue;
        }

        for (const start of findWholeWordOccurrences(text, correction.from)) {
            candidates.push({ ...correction, start, end: start + correction.from.length });
        }
    }

    // À position égale, la tranche la PLUS LONGUE passe devant : « les enfant » → « les enfants »
    // porte l'accord qui explique la faute, là où « enfant » seul la montre sans la justifier.
    // `sort` est stable, donc à tranche égale l'ordre du modèle est conservé.
    candidates.sort((left, right) => left.start - right.start || right.end - left.end);

    const kept: LocatedCorrection[] = [];

    for (const candidate of candidates) {
        // Il suffit de comparer à la DERNIÈRE retenue : `kept` est trié et sans chevauchement,
        // et les candidats arrivent par `start` croissant — toute retenue antérieure finit donc
        // avant le début de la dernière, et a fortiori avant celui du candidat.
        const last = kept[kept.length - 1];

        if (last !== undefined && overlaps(last, candidate)) {
            continue;
        }

        kept.push(candidate);
    }

    return kept;
}

/**
 * Applique au texte les seules corrections dont l'INDEX figure dans `chosen`.
 *
 * Fonction pure : ni `text` ni `corrections` ne sont touchés, le message corrigé est rendu.
 *
 * POURQUOI l'on part de la FIN du texte : remplacer « envois » (6 caractères) par « envoie »
 * (6 caractères) ne bouge rien, mais « clé » → « clés » décale d'un cran tout ce qui suit. En
 * descendant, chaque correction encore à appliquer se trouve AVANT celle qu'on vient d'écrire :
 * ses `start`/`end`, calculés sur le texte d'origine, gardent leur sens jusqu'au bout.
 *
 * Le tri se refait ici plutôt que de se supposer : `chosen` désigne des indices du tableau reçu,
 * et l'appelant reste libre de filtrer ou de réordonner sa liste avant de la passer.
 */
export function applyCorrections(text: string, corrections: readonly LocatedCorrection[], chosen: ReadonlySet<number>): string {
    const selected = corrections.filter((_, index) => chosen.has(index)).sort((left, right) => right.start - left.start);

    let result = text;

    for (const correction of selected) {
        result = result.slice(0, correction.start) + correction.to + result.slice(correction.end);
    }

    return result;
}

/**
 * Retire les corrections grammaticales qui retombent sur un mot déjà signalé par la couche
 * d'orthographe hors ligne (les `ProofreadIssue` de `index.ts`, dont seuls `start` et `end`
 * nous intéressent ici).
 *
 * POURQUOI l'orthographe hors ligne GAGNE : elle est gratuite, instantanée et déterministe —
 * le dictionnaire donne la même réponse à chaque frappe, sans appel réseau ni jeton consommé.
 * La couche grammaticale, elle, coûte un aller-retour et peut se tromper. À faute identique,
 * autant garder la source la moins chère et la plus stable.
 *
 * Et surtout : deux puces pour le même mot est un piège à clics. Le gérant coche les deux,
 * la seconde s'applique sur un texte que la première a déjà déplacé, et le mot ressort massacré.
 */
export function mergeWithSpelling(
    located: readonly LocatedCorrection[],
    spellingIssues: readonly { start: number; end: number }[],
): LocatedCorrection[] {
    return located.filter((correction) => !spellingIssues.some((issue) => overlaps(correction, issue)));
}

/**
 * Compte les corrections par nature, pour l'en-tête de section du dialogue
 * (« 2 accords, 1 conjugaison »).
 *
 * Les quatre natures sont toujours présentes, à zéro s'il n'y en a pas : l'affichage boucle
 * sur un objet de forme constante au lieu de tester l'existence de chaque clé.
 */
export function summarize(corrections: readonly LocatedCorrection[]): Record<GrammarKind, number> {
    const counts: Record<GrammarKind, number> = { orthographe: 0, conjugaison: 0, accord: 0, grammaire: 0 };

    for (const correction of corrections) {
        // Le `kind` vient du réseau : le type TypeScript ne le contraint pas à l'exécution, et
        // une nature inventée par le modèle ajouterait une clé fantôme au total affiché.
        if (GRAMMAR_KINDS.includes(correction.kind)) {
            counts[correction.kind] += 1;
        }
    }

    return counts;
}
