@page "/series/{Id:int}"
@page "/series/{Id:int}/edition"
@page "/series/{Id:int}/ordre"
@inject ServiceLivresApi Api
@inject NavigationManager Navigation
@inject EtatReseau Reseau
@implements IDisposable

<PageTitle>MaBibli — @(_serie?.Titre ?? "série")</PageTitle>

@if (_serie is null)
{
    <p class="message-discret" role="status">
        @(_chargement ? "Chargement…" : "Cette série n'est pas dans les données enregistrées sur cet appareil.")
    </p>
}
else
{
    @if (!ModeEdition && _parente is { } parente)
    {
        @if (!ModeEdition)
        {
        <p class="message-discret">
            Fait partie de <a href="@($"series/{parente.Id}")">@parente.Titre</a>.
        </p>
        }
    }

    <h1 class="titre-page">@_serie.Titre</h1>

    <p class="message-discret">
        @if (_serie.Elements.Count == 0)
        {
            <text>Aucun tome recensé. Ajoutez ceux que vous possédez, et ceux qui vous manquent.</text>
        }
        else
        {
            var total = Total(_serie);
            <text>@total.Possedes sur @total.Total tomes dans la bibliothèque.</text>
        }
    </p>

    @if (ModeOrdre)
    {
        <p class="message-discret">
            Rangez les tomes dans l'ordre où vous voulez les <strong>lire</strong> — ce n'est
            pas toujours l'ordre de publication : une préquelle se lit avant le livre paru dix
            ans plus tôt. Rien d'autre ne se modifie depuis cet écran.
        </p>
    }

    @if (_erreur is not null)
    {
        <p class="message-erreur" role="alert">@_erreur</p>
    }

    @* La bascule vers les envies ne change rien à l'écran : sans un mot, on ne saurait pas
       qu'elle a eu lieu, et on cliquerait deux fois. *@
    @if (_message is not null)
    {
        <p class="message-avertissement" role="status">@_message</p>
    }

    @* L'arbre est rendu récursivement ; le chemin protège le rendu si une base ancienne contient
       malgré tout une boucle. *@
    @if (!ModeEdition && _filles.Count > 0)
    {
        <h2 class="titre-section">Séries de ce cycle</h2>
        <ul class="liste-sous-series arbre">
            @foreach (var fille in _filles)
            {
                @* ⚠️ Les flèches n'apparaissent QUE dans l'écran d'ordre. En consultation, elles
                   se déclenchaient en faisant défiler du pouce, et l'ordre de lecture d'une saga
                   changeait sans qu'on l'ait voulu — même raison que pour la liste d'envies,
                   dont le réordonnancement vit sur son propre écran. *@
                <ArbreSerie Serie="fille" Toutes="_toutes"
                            Totaux="_totaux" Replies="_replies"
                            Chemin="@CheminRacine"
                            ActionsActives="@(ModeOrdre && Reseau.EnLigne)" MotifBlocage="@MotifBlocage"
                            OnDeplacer="DeplacerSousSerieAsync" />
            }
        </ul>
    }

    @if (!ModeEdition && _serie.Elements.Count > 0)
    {
        <h2 class="titre-section">Ordre de lecture</h2>

        @*
            ⚠️ L'ordre est STOCKÉ, jamais déduit d'une année de publication : une préquelle se
            lit avant le livre paru dix ans plus tôt. Les flèches sont le seul moyen de le
            changer — le glisser-déposer HTML5 ne fonctionne pas au doigt, et c'est sur
            téléphone qu'on consulte une saga en librairie.
        *@
        <ul class="liste-tomes">
            @foreach (var (element, index) in _serie.Elements.Select((e, i) => (e, i)))
            {
                <li class="carte-tome @(element.Possede ? null : "tome-manquant")" @key="element.Id">
                    <div class="rang-actions">
                        @if (ModeOrdre)
                        {
                            <button type="button" class="bouton bouton-compact"
                                    disabled="@(_enCours || !Reseau.EnLigne || index == 0)"
                                    title="@MotifBlocage"
                                    aria-label="@($"Monter « {element.Titre} »")"
                                    @onclick="() => DeplacerAsync(index, index - 1)">▲</button>
                        }

                        <span class="rang-numero" aria-hidden="true">@(index + 1)</span>

                        @if (ModeOrdre)
                        {
                            <button type="button" class="bouton bouton-compact"
                                    disabled="@(_enCours || !Reseau.EnLigne || index == _serie.Elements.Count - 1)"
                                    title="@MotifBlocage"
                                    aria-label="@($"Descendre « {element.Titre} »")"
                                    @onclick="() => DeplacerAsync(index, index + 1)">▼</button>
                        }
                    </div>

                    <div class="carte-corps">
                        @if (element.LivreId is { } livreId && _livresCatalogue.FirstOrDefault(l => l.Id == livreId) is { } livre)
                        {
                            <CarteLivre Livre="livre" />
                        }
                        else if (element.LivreId is { } idRattache)
                        {
                            <a class="carte-titre" href="@($"livres/{idRattache}")">@element.Titre</a>
                        }
                        else
                        {
                            <p class="carte-titre">@element.Titre</p>
                        }

                        @if ((element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
                            && !string.IsNullOrWhiteSpace(element.Auteurs))
                        {
                            <p class="carte-auteur">@element.Auteurs</p>
                        }

                        @if (element.LivreId is null || !_livresCatalogue.Any(l => l.Id == element.LivreId))
                        {
                        <p class="carte-details">
                            @if (!element.Possede)
                            {
                                @* Dire ce qui manque, et non laisser deviner par l'absence de lien. *@
                                <span class="etiquette etiquette-manquant">Pas dans la bibliothèque</span>
                            }

                            @if (element.PreteA is { } emprunteur)
                            {
                                <span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
                            }
                        </p>
                        }

                        @if (!ModeOrdre)
                        {
                        <div class="actions-tome">
                            @if (!element.Possede)
                            {
                                @* Le tome manquant est COMMUN au foyer, l'envie qu'il devient est
                                   PERSONNELLE : deux membres peuvent vouloir le même tome. *@
                                <button type="button" class="bouton bouton-compact"
                                        disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                                        @onclick="() => SouhaiterAsync(element)">
                                    Ajouter à mes envies
                                </button>

                                <button type="button" class="bouton bouton-compact"
                                        disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                                        @onclick="() => OuvrirRattachement(element.Id)">
                                    Je l'ai
                                </button>

                                <button type="button" class="bouton bouton-compact"
                                        disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                                        @onclick="() => OuvrirLookup(element)">
                                    Ajouter par ISBN
                                </button>
                            }
                            else
                            {
                                <button type="button" class="bouton bouton-compact"
                                        disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                                        @onclick="() => RattacherAsync(element.Id, null)">
                                    Détacher
                                </button>
                            }

                            <button type="button" class="bouton bouton-compact bouton-danger"
                                    disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                                    @onclick="() => RetirerAsync(element)">
                                Retirer
                            </button>
                        </div>
                        }

                        @if (_rattachement == element.Id)
                        {
                            <div class="recherche-livre">
                                <input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
                                       value="@_recherche" @oninput="ChercherAsync"
                                       aria-label="Chercher un livre à rattacher" />

                                @foreach (var resultat in _resultats)
                                {
                                    <button type="button" class="bouton bouton-discret"
                                            @key="resultat.Id"
                                            @onclick="() => RattacherAsync(element.Id, resultat.Id)">
                                        @resultat.Titre@(resultat.AuteursAffiches is { } a ? $" — {a}" : "")
                                    </button>
                                }
                            </div>
                        }

                        @if (_lookupElement?.Id == element.Id)
                        {
                            <div class="recherche-livre">
                                <input class="champ-saisie" type="text" inputmode="numeric"
                                       placeholder="ISBN du tome…"
                                       @bind="_lookupIsbn" @bind:event="oninput"
                                       @onkeydown="SurToucheLookup" />
                                <button type="button" class="bouton bouton-principal"
                                        disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_lookupIsbn))"
                                        title="@MotifBlocage" @onclick="ChercherLookupAsync">
                                    @(_lookupEnCours ? "Recherche…" : "Préremplir")
                                </button>

                                @*
                                    J1 : le scanner s'ouvre ICI, dans la place visée, et non sur
                                    l'écran d'ajout.

                                    ⚠️ C'est ce qui répond à l'exigence « le retour doit ramener
                                    sur la série ET sur la place visée » : il n'y a pas de
                                    retour, on n'a jamais quitté la place. Passer par /ajout
                                    obligeait à revenir rattacher à la main — donc à risquer de
                                    rattacher le mauvais tome.
                                *@
                                <button type="button" class="bouton bouton-discret"
                                        disabled="@(_enCours || _lookupScan || !Reseau.EnLigne)"
                                        title="@MotifBlocage" @onclick="OuvrirScanTome">
                                    Scanner
                                </button>

                                @if (_lookupScan)
                                {
                                    <ScannerCodeBarres OnCodeDetecte="SurCodeTomeAsync"
                                                       OnAnnuler="() => _lookupScan = false" />
                                }

                                @* L1 : la cascade BnF puis OpenLibrary prend une seconde ou deux. *@
                                @if (_lookupEnCours)
                                {
                                    <Patience Message="Interrogation de la BnF, puis d'OpenLibrary…" />
                                }
                                @if (_lookupCandidats.Count > 0)
                                {
                                    @foreach (var candidat in _lookupCandidats)
                                    {
                                        <div class="carte-candidat">
                                            <div class="carte-corps">
                                                <p class="carte-titre">@candidat.Titre</p>
                                                <p class="carte-auteur">@candidat.Auteur</p>
                                                <p class="carte-details">
                                                    @(candidat.Editeur ?? "éditeur inconnu")
                                                    · @(candidat.Annee ?? "année inconnue")
                                                </p>
                                                <button type="button" class="bouton bouton-principal"
                                                        disabled="@_enCours"
                                                        @onclick="() => AjouterCommeLivreAsync(candidat)">
                                                    Ajouter comme livre
                                                </button>
                                                <button type="button" class="bouton bouton-discret"
                                                        disabled="@_enCours"
                                                        @onclick="() => AjouterCommeEnvieAsync(candidat)">
                                                    Ajouter comme envie
                                                </button>
                                            </div>
                                        </div>
                                    }
                                }
                            </div>
                        }
                    </div>
                </li>
            }
        </ul>
    }

    @if (ModeConsultation)
    {
    <h2 class="titre-section">Ajouter un tome</h2>

    @*
        Deux voies, parce qu'une saga se recense d'un coup — souvent avant d'en posséder la
        moitié — puis se remplit au fil des achats. Un tome saisi sans livre N'EST PAS un
        brouillon : c'est ce qui rend le trou visible.
    *@
    <div class="barre-recherche">
        <input class="champ-saisie" type="text" placeholder="Titre d'un tome…"
               @bind="_nouveauTome" @bind:event="oninput" @onkeydown="SurToucheTome"
               aria-label="Titre du tome à ajouter" autocomplete="off" />
    </div>

    <div class="actions-formulaire">
        <button type="button" class="bouton bouton-principal"
                disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_nouveauTome))"
                title="@MotifBlocage"
                @onclick="() => AjouterTomeAsync(null)">
            Ajouter ce tome
        </button>

        <button type="button" class="bouton bouton-discret"
                disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                @onclick="() => OuvrirRattachement(AjoutDepuisCatalogue)">
            Depuis le catalogue
        </button>

        @*
            Lot P — le chemin inverse de « place vide → envie ».

            ⚠️ Ce geste n'ÉPARGNE QU'UNE RESAISIE : il crée une place ordinaire, LivreId à
            NULL, titre repris de l'envie. Rien ne relie les deux en base, et c'est délibéré —
            les séries sont COMMUNES au foyer, la liste d'envies est PERSONNELLE, et son sens
            même est de préparer un cadeau sans que l'autre le voie venir. Une clé étrangère
            vers l'envie afficherait « tome 3 souhaité par untel » à tout le monde.
        *@
        <button type="button" class="bouton bouton-discret"
                disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                @onclick="OuvrirEnviesAsync">
            Depuis mes envies
        </button>
    </div>

    @if (_enviesOuvertes)
    {
        <div class="recherche-livre">
            @if (_enviesChargement)
            {
                <Patience Message="Lecture de votre liste d'envies…" />
            }
            else if (_envies.Count == 0)
            {
                <p class="message-discret">
                    Votre liste d'envies est vide — ou ne contient rien qui ne soit déjà un tome
                    de cette série.
                </p>
            }
            else
            {
                <p class="message-discret">
                    Le tome est ajouté comme <strong>manquant</strong>, avec le titre de l'envie.
                    L'envie <strong>reste dans votre liste</strong> : rien n'a été acheté, et
                    personne d'autre ne verra qu'elle est à vous.
                </p>

                @foreach (var envie in _envies)
                {
                    <button type="button" class="bouton bouton-discret" @key="envie.Id"
                            disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                            @onclick="() => AjouterDepuisEnvieAsync(envie)">
                        @envie.Titre@(string.IsNullOrWhiteSpace(envie.Auteur) ? "" : $" — {envie.Auteur}")
                    </button>
                }
            }
        </div>
    }

    @if (_rattachement == AjoutDepuisCatalogue)
    {
        <div class="recherche-livre">
            <input class="champ-saisie" type="search" placeholder="Chercher dans le catalogue…"
                   value="@_recherche" @oninput="ChercherAsync"
                   aria-label="Chercher un livre à ajouter" />

            @foreach (var livre in _resultats)
            {
                <button type="button" class="bouton bouton-discret" @key="livre.Id"
                        @onclick="() => AjouterTomeAsync(livre.Id)">
                    @livre.Titre@(livre.AuteursAffiches is { } a ? $" — {a}" : "")
                </button>
            }
        </div>
    }
    }

    @if (ModeEdition)
    {
    <h2 class="titre-section">La série</h2>

    <label class="champ">
        <span class="champ-libelle">Nom</span>
        <input class="champ-saisie" type="text" @bind="_titre" @bind:event="oninput" />
    </label>

    <label class="champ">
        <span class="champ-libelle">
            Rangée dans<span class="champ-aide"> (un cycle contient des séries)</span>
        </span>
        <select class="champ-saisie" @bind="_parenteId">
            <option value="0">Aucune — série de premier niveau</option>
            @foreach (var candidate in Candidates)
            {
                <option value="@candidate.Id">@candidate.Titre</option>
            }
        </select>
    </label>

    <div class="actions-formulaire">
        <button type="button" class="bouton bouton-principal"
                disabled="@(_enCours || !Reseau.EnLigne || string.IsNullOrWhiteSpace(_titre))"
                title="@MotifBlocage"
                @onclick="EnregistrerAsync">
            Enregistrer
        </button>

        <button type="button" class="bouton bouton-discret bouton-danger"
                disabled="@(_enCours || !Reseau.EnLigne)" title="@MotifBlocage"
                @onclick="SupprimerAsync">
            @(_confirmeSuppression ? "Confirmer la suppression" : "Supprimer la série")
        </button>
    </div>

    @if (_confirmeSuppression)
    {
        <p class="message-avertissement" role="status">
            Les livres restent au catalogue ; seules les places disparaissent.
            @if (_filles.Count > 0)
            {
                <text> Les @_filles.Count série(s) de ce cycle deviendront des séries de premier niveau.</text>
            }
        </p>
    }
    }
    @if (ModeOrdre)
    {
    <nav class="actions-flottantes">
        <a class="bouton bouton-principal" href="@($"series/{Id}")">Terminé</a>
    </nav>
    }
    else if (ModeConsultation)
    {
    <nav class="actions-flottantes">
        @if (Reseau.EnLigne)
        {
            @* Deux écrans distincts, et ce n'est pas une subtilité : « Modifier » touche à la
               fiche (nom, cycle, suppression), « Changer l'ordre » à la place des tomes. Les
               mêmes flèches offertes en consultation se déclenchaient au défilement. *@
            @if (_serie.Elements.Count > 1 || _filles.Count > 1)
            {
                <a class="bouton bouton-discret" href="@($"series/{Id}/ordre")">Changer l'ordre</a>
            }
            <a class="bouton bouton-principal" href="@($"series/{Id}/edition")">Modifier</a>
        }
        else
        {
            <button type="button" class="bouton bouton-principal" disabled
                    title="@EtatReseau.MotifHorsLigne">Modifier</button>
        }
    </nav>
    }
}

@code {
    /// <summary>Marqueur de la recherche « ajouter depuis le catalogue », qui ne vise aucun élément.</summary>
    private const int AjoutDepuisCatalogue = -1;

    [Parameter] public int Id { get; set; }

    private IReadOnlyList<SerieDto> _toutes = [];
    private IReadOnlyList<LivreDto> _livresCatalogue = [];
    private SerieDto? _serie;
    private SerieDto? _parente;
    private List<SerieDto> _filles = [];
    private IReadOnlyDictionary<int, (int Possedes, int Total)> _totaux =
        new Dictionary<int, (int, int)>();
    /// <summary>Nœuds repliés à la main. Tout est déplié par défaut — voir ArbreSerie.</summary>
    private readonly HashSet<int> _replies = [];

    private bool _chargement = true;
    private bool _enCours;
    private string? _erreur;

    private string _titre = string.Empty;
    private int _parenteId;
    private bool _confirmeSuppression;

    private string _nouveauTome = string.Empty;

    private int? _rattachement;
    private string _recherche = string.Empty;
    private IReadOnlyList<LivreDto> _resultats = [];
    private ElementSerieDto? _lookupElement;
    private string _lookupIsbn = string.Empty;
    private IReadOnlyList<CandidatLivre> _lookupCandidats = [];
    private bool _lookupEnCours;

    /// <summary>Le scanner est ouvert sur la place visée par le lookup ISBN (lot J1).</summary>
    private bool _lookupScan;

    private bool _enviesOuvertes;
    private bool _enviesChargement;
    private IReadOnlyList<SouhaitDto> _envies = [];

    private string? MotifBlocage => Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne;
    private IReadOnlySet<int> CheminRacine => new HashSet<int> { Id };

    private bool ModeEdition => Suffixe("/edition");

    /// <summary>
    /// Écran dédié au réordonnancement, sur le modèle de <c>/souhaits/ordre</c>.
    /// </summary>
    /// <remarks>
    /// ⚠️ Le mode est dans l'ADRESSE, pas dans un booléen interne : c'est la convention du
    /// projet depuis la fiche livre, et elle fait du bouton « retour » du navigateur une
    /// sortie naturelle. Comme les trois routes partagent le même paramètre, le composant
    /// s'abonne à <c>LocationChanged</c> — sans quoi le routeur ne redessinerait rien.
    /// </remarks>
    private bool ModeOrdre => Suffixe("/ordre");

    /// <summary>Ni édition de la fiche, ni réordonnancement : l'écran qu'on ouvre pour regarder.</summary>
    private bool ModeConsultation => !ModeEdition && !ModeOrdre;

    private bool Suffixe(string fin) =>
        Navigation.ToAbsoluteUri(Navigation.Uri).AbsolutePath.TrimEnd('/')
            .EndsWith(fin, StringComparison.OrdinalIgnoreCase);

    /// <summary>
    /// Séries dans lesquelles celle-ci peut être rangée.
    /// </summary>
    /// <remarks>
    /// Ni elle-même, ni ses descendantes : l'arbre se refermerait sur lui-même. Le serveur le
    /// refuse aussi — cette liste évite seulement de proposer un choix qui sera rejeté.
    /// </remarks>
    private IEnumerable<SerieDto> Candidates =>
        _toutes.Where(s => s.Id != Id && !EstDescendante(s.Id));

    private bool EstDescendante(int candidate)
    {
        var courante = _toutes.FirstOrDefault(s => s.Id == candidate);
        var garde = _toutes.Count + 1;

        while (courante is not null && garde-- > 0)
        {
            if (courante.Id == Id)
            {
                return true;
            }

            courante = courante.SerieParenteId is { } parenteId
                ? _toutes.FirstOrDefault(s => s.Id == parenteId)
                : null;
        }

        return false;
    }

    protected override void OnInitialized()
    {
        Reseau.Change += SurChangementReseau;
        Navigation.LocationChanged += SurNavigation;
    }

    private void SurNavigation(object? _, LocationChangedEventArgs __) => InvokeAsync(StateHasChanged);

    private void SurChangementReseau() => _ = InvokeAsync(async () =>
    {
        await ChargerAsync();
        StateHasChanged();
    });

    public void Dispose()
    {
        Reseau.Change -= SurChangementReseau;
        Navigation.LocationChanged -= SurNavigation;
    }

    protected override Task OnParametersSetAsync() => ChargerAsync();

    private async Task ChargerAsync()
    {
        _chargement = true;
        _toutes = await Api.ListerSeriesAsync();
        _livresCatalogue = await Api.ListerAsync(new CritereLivres());
        _chargement = false;

        Rattacher();
    }

    /// <summary>Recale les vues dérivées après un chargement ou une écriture.</summary>
    private void Rattacher()
    {
        _serie = _toutes.FirstOrDefault(s => s.Id == Id);

        if (_serie is null)
        {
            return;
        }

        _parente = _serie.SerieParenteId is { } parenteId
            ? _toutes.FirstOrDefault(s => s.Id == parenteId)
            : null;

        _filles = _toutes.Where(s => s.SerieParenteId == Id).OrderBy(s => s.Position).ToList();
        _totaux = TotauxSeries.Calculer(_toutes);

        _titre = _serie.Titre;
        _parenteId = _serie.SerieParenteId ?? 0;
    }

    /// <summary>Remplace la série courante par celle que vient de rendre le serveur.</summary>
    private void Appliquer(SerieDto serie)
    {
        _toutes = _toutes.Select(s => s.Id == serie.Id ? serie : s).ToList();
        Rattacher();
    }

    private async Task SurToucheTome(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_nouveauTome) && Reseau.EnLigne)
        {
            await AjouterTomeAsync(null);
        }
    }

    private async Task AjouterTomeAsync(int? livreId)
    {
        await EcrireAsync(() => Api.AjouterElementSerieAsync(
            Id, new AjoutElementSerie { LivreId = livreId, Titre = livreId is null ? _nouveauTome : null }));

        if (_erreur is null)
        {
            _nouveauTome = string.Empty;
            FermerRecherche();
        }
    }

    private Task RattacherAsync(int elementId, int? livreId) =>
        EcrireAsync(async () =>
        {
            var resultat = await Api.RattacherLivreSerieAsync(elementId, livreId);
            FermerRecherche();
            return resultat;
        });

    private async Task RetirerAsync(ElementSerieDto element)
    {
        _enCours = true;
        _erreur = await Api.RetirerElementSerieAsync(element.Id);
        _enCours = false;

        if (_erreur is null)
        {
            await ChargerAsync();
        }
    }

    private async Task SouhaiterAsync(ElementSerieDto element)
    {
        _enCours = true;
        _erreur = null;

        var resultat = await Api.SouhaiterTomeAsync(element.Id);

        _enCours = false;
        _erreur = resultat.Erreur;

        if (resultat.EstOk)
        {
            _message = $"« {element.Titre} » est dans votre liste d'envies.";
        }
    }

    private (int Possedes, int Total) Total(SerieDto serie) =>
            _totaux.TryGetValue(serie.Id, out var total)
                ? total
                : (serie.NombrePossedes, serie.Elements.Count);

        private async Task DeplacerSousSerieAsync(DeplacementSerie mouvement)
        {
            var soeurs = _toutes
                .Where(s => s.SerieParenteId == mouvement.ParenteId)
                .OrderBy(s => s.Position)
                .ThenBy(s => s.Id)
                .ToList();
            var index = soeurs.FindIndex(s => s.Id == mouvement.SerieId);
            var cible = index + mouvement.Delta;
            if (index < 0 || cible < 0 || cible >= soeurs.Count)
            {
                return;
            }

            var ids = soeurs.Select(s => s.Id).ToList();
            (ids[index], ids[cible]) = (ids[cible], ids[index]);
            await EcrireAsync(() => Api.ReordonnerSousSeriesAsync(mouvement.ParenteId, ids));
            if (_erreur is null)
            {
                await ChargerAsync();
            }
        }

    private void OuvrirScanTome()
    {
        _erreur = null;
        _lookupCandidats = [];
        _lookupScan = true;
    }

    /// <summary>
    /// Enchaîne le lookup sur le code lu, sans quitter la place visée.
    /// </summary>
    /// <remarks>
    /// Le code reste dans le champ : un décodage erroné se corrige et se relance, comme sur
    /// l'écran d'ajout au catalogue.
    /// <para>
    /// ⚠️ Un code de périodique (<c>977</c>) est refusé plus bas par <c>ChercherLookupAsync</c> :
    /// <c>ElementSerie.LivreId</c> ne pointe que vers <c>Livre</c>, et les revues restent
    /// délibérément hors de ce flux.
    /// </para>
    /// </remarks>
    private async Task SurCodeTomeAsync(CodeScanne lecture)
    {
        _lookupScan = false;
        _lookupIsbn = lecture.Code;
        await ChercherLookupAsync();
    }

    /// <summary>
    /// Ouvre la liste des envies dans laquelle puiser un tome à recenser.
    /// </summary>
    /// <remarks>
    /// ⚠️ Les envies déjà présentes dans la série sont écartées de la liste : les proposer
    /// mènerait droit au doublon, la place étant identifiée par son seul titre.
    /// </remarks>
    private async Task OuvrirEnviesAsync()
    {
        _erreur = null;
        _message = null;
        _enviesOuvertes = true;
        _enviesChargement = true;

        var toutes = await Api.ListerSouhaitsAsync();

        var deja = new HashSet<string>(
            _serie?.Elements.Select(e => NormalisationTexte.Normaliser(e.Titre)) ?? [],
            StringComparer.Ordinal);

        _envies = toutes
            .Where(envie => !deja.Contains(NormalisationTexte.Normaliser(envie.Titre)))
            .ToList();

        _enviesChargement = false;
    }

    /// <summary>
    /// Recense un tome à partir d'une envie, <b>sans rien lier</b>.
    /// </summary>
    /// <remarks>
    /// La place créée est celle qu'on aurait saisie à la main : <c>LivreId</c> à <c>NULL</c>,
    /// titre repris de l'envie. L'envie <b>survit</b> — c'est la règle déjà tenue par « une
    /// envie déjà au catalogue est signalée, jamais supprimée », et à plus forte raison ici
    /// puisque rien n'a été acheté.
    /// <para>
    /// ⚠️ Ce qui doit rester invisible au foyer n'est pas l'existence du tome — on l'aurait
    /// saisie à la main de toute façon — mais le <b>lien</b> avec la liste de quelqu'un. Il n'y
    /// en a aucun en base.
    /// </para>
    /// </remarks>
    private async Task AjouterDepuisEnvieAsync(SouhaitDto envie)
    {
        await EcrireAsync(() => Api.AjouterElementSerieAsync(
            Id, new AjoutElementSerie { Titre = envie.Titre }));

        if (_erreur is null)
        {
            _enviesOuvertes = false;
            _envies = [];
            _message = $"« {envie.Titre} » est recensé comme tome manquant. "
                + "Il reste dans votre liste d'envies.";
        }
    }

    private void OuvrirLookup(ElementSerieDto element)
        {
            _lookupElement = element;
            _lookupIsbn = string.Empty;
            _lookupCandidats = [];
            _lookupScan = false;
            _erreur = null;
        }

    private async Task SurToucheLookup(KeyboardEventArgs e)
        {
            if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_lookupIsbn))
            {
                await ChercherLookupAsync();
            }
        }

    private async Task ChercherLookupAsync()
        {
            _lookupEnCours = true;
            _erreur = null;
            _lookupCandidats = [];
            var resultat = await Api.ChercherIsbnAsync(_lookupIsbn);
            _lookupEnCours = false;

            if (resultat is null)
            {
                _erreur = Reseau.EnLigne
                    ? "La recherche n'a rien pu ramener."
                    : EtatReseau.MotifHorsLigne;
                return;
            }

            if (resultat.Periodique is not null)
            {
                _erreur = "Les revues ne peuvent pas être rattachées à une série.";
                return;
            }

            _lookupCandidats = resultat.Candidats;
            if (_lookupCandidats.Count == 0)
            {
                _erreur = "Aucune notice ne correspond à cet ISBN.";
            }
        }

    private async Task AjouterCommeEnvieAsync(CandidatLivre candidat)
        {
            _enCours = true;
            _erreur = null;
            var resultat = await Api.AjouterSouhaitAsync(new EnregistrementSouhait
            {
                Titre = candidat.Titre,
                Auteur = candidat.Auteur,
                Editeur = candidat.Editeur,
                Annee = candidat.Annee,
                Isbn = candidat.IsbnInterroge,
                CoverUrl = candidat.CoverUrl,
            });
            _enCours = false;
            _erreur = resultat.Erreur;
            if (resultat.EstOk)
            {
                _message = $"« {candidat.Titre} » est dans votre liste d'envies.";
                _lookupElement = null;
            }
        }

    private async Task AjouterCommeLivreAsync(CandidatLivre candidat)
        {
            if (_lookupElement is null)
            {
                return;
            }

            _enCours = true;
            _erreur = null;
            var saisie = new EnregistrementLivre
            {
                Isbn = candidat.IsbnInterroge,
                Titre = candidat.Titre,
                Auteurs = candidat.Auteurs.Select(a => new AuteurSaisi(a)).ToList(),
                Editeur = candidat.Editeur,
                CoverUrl = candidat.CoverUrl,
                UrlNotice = candidat.UrlNotice,
            };
            var resultat = await Api.CreerAsync(saisie);
            _enCours = false;

            if (resultat.Doublons is not null)
            {
                _erreur = resultat.Doublons.Message;
                return;
            }

            if (!resultat.EstOk)
            {
                _erreur = resultat.Erreur;
                return;
            }

            await EcrireAsync(() => Api.RattacherLivreSerieAsync(_lookupElement.Id, resultat.Livre!.Id));
            if (_erreur is null)
            {
                _lookupElement = null;
        }
    }

    private string? _message;

    private async Task DeplacerAsync(int depuis, int vers)
    {
        if (_serie is null || vers < 0 || vers >= _serie.Elements.Count)
        {
            return;
        }

        // Réordonné localement d'abord : la flèche doit répondre tout de suite, l'aller-retour
        // réseau ne doit pas se voir. Un échec recharge l'ordre du serveur.
        var ids = _serie.Elements.Select(e => e.Id).ToList();
        (ids[depuis], ids[vers]) = (ids[vers], ids[depuis]);

        await EcrireAsync(() => Api.ReordonnerSerieAsync(Id, ids));
    }

    private async Task EnregistrerAsync()
    {
        await EcrireAsync(() => Api.ModifierSerieAsync(
            Id, new EnregistrementSerie { Titre = _titre, SerieParenteId = _parenteId == 0 ? null : _parenteId }));

        // Le rangement dans un cycle change la parenté des AUTRES séries affichées : on relit.
        if (_erreur is null)
        {
            await ChargerAsync();
        }
    }

    private async Task SupprimerAsync()
    {
        if (!_confirmeSuppression)
        {
            _confirmeSuppression = true;
            return;
        }

        _enCours = true;
        _erreur = await Api.SupprimerSerieAsync(Id);
        _enCours = false;

        if (_erreur is null)
        {
            Navigation.NavigateTo("series");
        }
    }

    private void OuvrirRattachement(int cible)
    {
        _rattachement = cible;
        _recherche = string.Empty;
        _resultats = [];
    }

    private void FermerRecherche()
    {
        _rattachement = null;
        _recherche = string.Empty;
        _resultats = [];
    }

    private async Task ChercherAsync(ChangeEventArgs e)
    {
        _recherche = e.Value?.ToString() ?? string.Empty;

        if (string.IsNullOrWhiteSpace(_recherche))
        {
            _resultats = [];
            return;
        }

        // La recherche passe par le service habituel : hors-ligne elle interroge l'instantané,
        // en ligne le serveur. Rien de particulier à prévoir ici.
        _resultats = (await Api.ListerAsync(new CritereLivres { Recherche = _recherche }))
            .Take(8)
            .ToList();
    }

    /// <summary>Exécute une écriture qui rend la série, en tenant l'état d'attente et l'erreur.</summary>
    private async Task EcrireAsync(Func<Task<ResultatApi<SerieDto>>> appel)
    {
        _enCours = true;
        _erreur = null;
        _message = null;

        var resultat = await appel();

        _enCours = false;

        if (!resultat.EstOk)
        {
            _erreur = resultat.Erreur;

            // L'ordre affiché a pu être modifié localement avant l'appel : on reprend celui du
            // serveur plutôt que de laisser croire à un enregistrement.
            await ChargerAsync();
            return;
        }

        Appliquer(resultat.Valeur!);
    }
}
