namespace MaBibli.Shared.Textes;
///
/// Compare des libellés qui contiennent des nombres, en lisant les nombres comme des nombres.
///
///
/// Le tri ordinaire range les tomes 1, 10, 11, 2, 9 — parce que le caractère 1
/// précède le 2. C'est correct alphabétiquement, et absurde pour une saga. Ce
/// comparateur découpe le libellé en blocs de chiffres et blocs de texte, et compare chaque
/// bloc de chiffres comme un entier.
///
/// ⚠️ Aucune collation culturelle, comme partout ailleurs dans le projet
/// (GroupementCatalogue, FiltreLivresLocal) : la comparaison des blocs de texte
/// est ordinale, sur leur forme normalisée. C'est ce qui garantit que deux appareils
/// classent une série dans le même ordre, quelle que soit la langue du système.
///
///
/// Ordre retenu, tranché avec l'utilisateur le 2026-08-22 :
///
///
/// - le libellé vide — ce dont on ignore le numéro ;
/// - ce qui ne commence pas par un chiffre (« Hors-série », « Intégrale 1-3 »),
/// entre eux de a à z ;
/// - ce qui commence par un chiffre, de 0 à 9 en tri naturel.
///
///
/// ⚠️ Cet ordre diverge de celui des numéros de revue, où ce qui n'a pas de date
/// ferme la liste. C'est délibéré : devant un kiosque on cherche le dernier paru,
/// devant une étagère on cherche ce qu'il reste à compléter. Ne pas uniformiser les deux au
/// nom de la cohérence — ce serait remplacer deux règles justes par une qui se trompe une
/// fois sur deux.
///
///
public static class TriNaturel
{
/// Comparateur prêt à l'emploi pour OrderBy.
public static IComparer Comparateur { get; } = new ComparateurNaturel();
///
/// Compare deux libellés. null et la chaîne vide sont équivalents et passent en tête.
///
public static int Comparer(string? gauche, string? droite)
{
var a = NormalisationTexte.Normaliser(gauche);
var b = NormalisationTexte.Normaliser(droite);
// 1. Le vide d'abord — on ignore le numéro, et c'est ce qui demande une décision.
if (a.Length == 0 || b.Length == 0)
{
return a.Length == b.Length ? 0 : a.Length == 0 ? -1 : 1;
}
// 2. Puis ce qui ne commence pas par un chiffre. Un seul test sur le PREMIER caractère :
// « Intégrale 1-3 » est du texte, « 3.5 » est un nombre, et la frontière est là.
var aChiffre = char.IsAsciiDigit(a[0]);
var bChiffre = char.IsAsciiDigit(b[0]);
if (aChiffre != bChiffre)
{
return aChiffre ? 1 : -1;
}
return ComparerBlocs(a, b);
}
///
/// Parcourt les deux libellés en parallèle, bloc de chiffres contre bloc de chiffres,
/// bloc de texte contre bloc de texte.
///
private static int ComparerBlocs(string a, string b)
{
int i = 0, j = 0;
while (i < a.Length && j < b.Length)
{
if (char.IsAsciiDigit(a[i]) && char.IsAsciiDigit(b[j]))
{
var debutA = i;
var debutB = j;
while (i < a.Length && char.IsAsciiDigit(a[i])) i++;
while (j < b.Length && char.IsAsciiDigit(b[j])) j++;
// ⚠️ Comparaison par LONGUEUR puis par caractères, jamais par conversion en int :
// un numéro n'est pas borné par int.MaxValue, et « 000000000000000012 » ne doit
// pas faire échouer le tri. Les zéros de tête sont ignorés d'abord.
var chiffresA = a.AsSpan(debutA, i - debutA).TrimStart('0');
var chiffresB = b.AsSpan(debutB, j - debutB).TrimStart('0');
if (chiffresA.Length != chiffresB.Length)
{
return chiffresA.Length < chiffresB.Length ? -1 : 1;
}
var ecart = chiffresA.SequenceCompareTo(chiffresB);
if (ecart != 0)
{
return Math.Sign(ecart);
}
}
else
{
var ecart = a[i].CompareTo(b[j]);
if (ecart != 0)
{
return Math.Sign(ecart);
}
i++;
j++;
}
}
// Le plus court d'abord quand l'un est le préfixe de l'autre : « 3 » avant « 3 bis ».
return (a.Length - i).CompareTo(b.Length - j);
}
private sealed class ComparateurNaturel : IComparer
{
public int Compare(string? x, string? y) => Comparer(x, y);
}
}