using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;

namespace MaBibli.Client.Services;

/// <summary>
/// Accès à l'API depuis le client Blazor, avec repli sur le cache hors-ligne.
/// </summary>
/// <remarks>
/// <b>Le piège que ce service existe pour éviter</b> (CLAUDE.md) : en Blazor WebAssembly le code
/// tourne dans le navigateur alors que SQLite vit sur le serveur. Le service worker met en cache
/// les <i>assets</i> — HTML, CSS, WASM — mais jamais les réponses de l'API. Sans le travail fait
/// ici, l'application démarre parfaitement hors-ligne… et affiche une bibliothèque vide.
/// <para>
/// La règle est simple et sans exception : <b>les lectures retombent sur le cache, les écritures
/// sont refusées</b>. Aucune file d'attente, aucune synchronisation en retour, aucun conflit à
/// résoudre — c'est la décision actée, et c'est ce qui rend le hors-ligne fiable plutôt que
/// « presque bon ».
/// </para>
/// </remarks>
public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, EtatReseau reseau)
{
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);

    /// <summary>
    /// Recharge tous les instantanés depuis le réseau.
    /// </summary>
    /// <remarks>
    /// Appelé au démarrage et à chaque retour du réseau : c'est ce qui garantit que la
    /// bibliothèque entière est disponible hors-ligne, et pas seulement les écrans déjà ouverts.
    /// </remarks>
    /// <returns>Vrai si les données ont pu être rafraîchies.</returns>
    public async Task<bool> SynchroniserAsync(CancellationToken ct = default)
    {
        if (!reseau.TenterLeReseau)
        {
            return false;
        }

        try
        {
            await ListerAsync(new CritereLivres(), ct);
            await ListerAuteursAsync(ct);
            await ListerPretsEnCoursAsync(ct);
            await ObtenirUtilisateurAsync(ct);
            return reseau.EnLigne;
        }
        catch (OperationCanceledException)
        {
            return false;
        }
        catch (Exception)
        {
            // Une synchronisation ratée n'est pas une panne de l'application : l'écran affichera
            // ce qu'il a, et la date de synchronisation dira qu'il n'est plus tout frais.
            return false;
        }
    }

    public async Task<IReadOnlyList<LivreDto>> ListerAsync(CritereLivres criteres, CancellationToken ct = default)
    {
        var parametres = new List<string>();

        if (!string.IsNullOrWhiteSpace(criteres.Recherche))
        {
            parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}");
        }

        if (criteres.Format is { } format)
        {
            parametres.Add($"format={format}");
        }

        if (criteres.Statut is { } statut)
        {
            parametres.Add($"statut={statut}");
        }

        if (criteres.AuteurId is { } auteurId)
        {
            parametres.Add($"auteurId={auteurId}");
        }

        var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty);

        var (ok, liste) = await EssayerAsync(() => http.GetFromJsonAsync<List<LivreDto>>(url, Json, ct));

        if (ok)
        {
            // Seule la liste NON filtrée fait un instantané valable : c'est elle qui permettra
            // de chercher hors-ligne autre chose que ce qui était affiché au moment de la coupure.
            if (parametres.Count == 0)
            {
                await MemoriserAsync(ClesCache.Catalogue, liste ?? []);
            }

            return liste ?? [];
        }

        var instantane = await LireCacheAsync<List<LivreDto>>(ClesCache.Catalogue);
        return instantane is null ? [] : FiltreLivresLocal.Appliquer(instantane.Donnees, criteres);
    }

    public async Task<LivreDto?> ObtenirAsync(int id, CancellationToken ct = default)
    {
        var (ok, livre) = await EssayerAsync(async () =>
        {
            var reponse = await http.GetAsync($"api/livres/{id}", ct);
            return reponse.StatusCode == HttpStatusCode.NotFound
                ? null
                : await LireAsync<LivreDto>(reponse, ct);
        });

        if (ok)
        {
            return livre;
        }

        // Hors-ligne, la fiche se relit dans l'instantané du catalogue : pas d'appel par livre à
        // mettre en cache, donc rien à maintenir en plus.
        var instantane = await LireCacheAsync<List<LivreDto>>(ClesCache.Catalogue);
        return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
    }

    public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
        await EcrireAsync<LivreDto>(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);

    public async Task<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
        await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);

    /// <summary>
    /// Change le statut de lecture <b>de l'utilisateur courant</b> ; <c>null</c> le remet à
    /// « non commencé ». Le serveur sait qui appelle : le client ne transmet jamais d'identité.
    /// </summary>
    public async Task<ResultatApi<LivreDto>> ChangerStatutAsync(
        int id, Statut? statut, CancellationToken ct = default) =>
        await EcrireAsync<LivreDto>(
            () => http.PatchAsJsonAsync(
                $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct),
            ct);

    public async Task<IReadOnlyList<AuteurDto>> ListerAuteursAsync(CancellationToken ct = default)
    {
        var (ok, auteurs) = await EssayerAsync(
            () => http.GetFromJsonAsync<List<AuteurDto>>("api/auteurs", Json, ct));

        if (ok)
        {
            await MemoriserAsync(ClesCache.Auteurs, auteurs ?? []);
            return auteurs ?? [];
        }

        var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
        return instantane?.Donnees ?? [];
    }

    public async Task<AuteurDto?> ObtenirAuteurAsync(int id, CancellationToken ct = default)
    {
        var (ok, auteur) = await EssayerAsync(async () =>
        {
            var reponse = await http.GetAsync($"api/auteurs/{id}", ct);
            return reponse.StatusCode == HttpStatusCode.NotFound
                ? null
                : await LireAsync<AuteurDto>(reponse, ct);
        });

        if (ok)
        {
            return auteur;
        }

        var instantane = await LireCacheAsync<List<AuteurDto>>(ClesCache.Auteurs);
        return instantane?.Donnees.FirstOrDefault(a => a.Id == id);
    }

    /// <summary>
    /// Rapprochements d'auteurs ambigus, que le serveur refuse d'appliquer seul.
    /// </summary>
    /// <remarks>
    /// Volontairement <b>pas</b> mis en cache : c'est une liste de décisions à prendre, or aucune
    /// décision ne peut être enregistrée hors-ligne. L'afficher sans pouvoir y répondre
    /// n'apporterait rien.
    /// </remarks>
    public async Task<IReadOnlyList<SuggestionRapprochement>> ListerRapprochementsAsync(
        CancellationToken ct = default)
    {
        var (ok, suggestions) = await EssayerAsync(
            () => http.GetFromJsonAsync<List<SuggestionRapprochement>>("api/auteurs/rapprochements", Json, ct));

        return ok ? suggestions ?? [] : [];
    }

    public Task<string?> AccepterRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
        DeciderAsync("accepter", sourceId, cibleId, ct);

    public Task<string?> RefuserRapprochementAsync(int sourceId, int cibleId, CancellationToken ct = default) =>
        DeciderAsync("refuser", sourceId, cibleId, ct);

    private async Task<string?> DeciderAsync(string action, int sourceId, int cibleId, CancellationToken ct)
    {
        if (!reseau.EnLigne)
        {
            return EtatReseau.MotifHorsLigne;
        }

        try
        {
            var reponse = await http.PostAsJsonAsync(
                $"api/auteurs/rapprochements/{action}",
                new DecisionRapprochement { SourceId = sourceId, CibleId = cibleId },
                Json,
                ct);

            reseau.SignalerSuccesReseau();
            return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
        }
        catch (HttpRequestException)
        {
            reseau.SignalerEchecReseau();
            return EtatReseau.MotifHorsLigne;
        }
    }

    /// <summary>Les livres actuellement dehors, du prêt le plus ancien au plus récent.</summary>
    public async Task<IReadOnlyList<PretDto>> ListerPretsEnCoursAsync(CancellationToken ct = default)
    {
        var (ok, prets) = await EssayerAsync(
            () => http.GetFromJsonAsync<List<PretDto>>("api/prets/en-cours", Json, ct));

        if (ok)
        {
            await MemoriserAsync(ClesCache.PretsEnCours, prets ?? []);
            return prets ?? [];
        }

        var instantane = await LireCacheAsync<List<PretDto>>(ClesCache.PretsEnCours);
        return instantane?.Donnees ?? [];
    }

    /// <summary>
    /// Historique complet des prêts d'un livre, du plus récent au plus ancien.
    /// </summary>
    /// <remarks>
    /// Liste vide = jamais prêté ; <c>null</c> = livre inconnu <b>ou</b> réseau absent. L'appelant
    /// doit consulter <see cref="EtatReseau.EnLigne"/> pour distinguer les deux : l'historique
    /// n'est pas mis en cache (une ligne par livre, pour une information rarement consultée),
    /// et afficher « jamais prêté » hors-ligne serait un mensonge.
    /// </remarks>
    public async Task<IReadOnlyList<PretDto>?> ListerPretsLivreAsync(int livreId, CancellationToken ct = default)
    {
        var (ok, prets) = await EssayerAsync(async () =>
        {
            var reponse = await http.GetAsync($"api/livres/{livreId}/prets", ct);
            return reponse.StatusCode == HttpStatusCode.NotFound
                ? null
                : await LireAsync<List<PretDto>>(reponse, ct);
        });

        return ok ? prets : null;
    }

    /// <summary>
    /// Prête un livre. <paramref name="datePret"/> est une date <b>locale</b>, convertie ici en
    /// UTC : tout ce que l'API stocke est en UTC, comme le reste des dates du projet.
    /// </summary>
    public async Task<ResultatApi<PretDto>> PreterAsync(
        int livreId, string emprunteur, DateTime? datePret = null, CancellationToken ct = default)
    {
        var saisie = new EnregistrementPret
        {
            Emprunteur = emprunteur,
            DatePret = EnUtc(datePret),
        };

        return await EcrireAsync<PretDto>(
            () => http.PostAsJsonAsync($"api/livres/{livreId}/prets", saisie, Json, ct), ct);
    }

    /// <summary>Clôt un prêt. Le prêt n'est pas supprimé : il devient l'historique du livre.</summary>
    public async Task<ResultatApi<PretDto>> RendreAsync(
        int pretId, DateTime? dateRetour = null, CancellationToken ct = default) =>
        await EcrireAsync<PretDto>(
            () => http.PostAsJsonAsync(
                $"api/prets/{pretId}/retour", new RetourPret { DateRetour = EnUtc(dateRetour) }, Json, ct),
            ct);

    /// <summary>
    /// Convertit une date issue d'un &lt;input type="date"&gt; en instant UTC.
    /// </summary>
    /// <remarks>
    /// Le navigateur produit une date sans fuseau (minuit, « Unspecified ») : l'envoyer telle
    /// quelle décalerait le prêt d'un jour pour la moitié du globe. On la déclare locale avant
    /// de la convertir, ce qui est bien ce que l'utilisateur voulait dire en la choisissant.
    /// </remarks>
    private static DateTime? EnUtc(DateTime? date) =>
        date is null ? null : DateTime.SpecifyKind(date.Value, DateTimeKind.Local).ToUniversalTime();

    public async Task<string?> SupprimerAsync(int id, CancellationToken ct = default)
    {
        if (!reseau.EnLigne)
        {
            return EtatReseau.MotifHorsLigne;
        }

        try
        {
            var reponse = await http.DeleteAsync($"api/livres/{id}", ct);
            reseau.SignalerSuccesReseau();
            return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
        }
        catch (HttpRequestException)
        {
            reseau.SignalerEchecReseau();
            return EtatReseau.MotifHorsLigne;
        }
    }

    /// <summary>
    /// Interroge la cascade BnF → OpenLibrary. <b>Exige le réseau</b> : rien n'est mis en cache,
    /// et hors-ligne l'appel n'est même pas tenté (<c>null</c> immédiat).
    /// </summary>
    public async Task<ResultatLookupIsbn?> ChercherIsbnAsync(string isbn, CancellationToken ct = default)
    {
        var (ok, resultat) = await EssayerAsync(async () =>
        {
            var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct);
            return reponse.IsSuccessStatusCode
                ? await LireAsync<ResultatLookupIsbn>(reponse, ct)
                : null;
        });

        return ok ? resultat : null;
    }

    public async Task<UtilisateurCourant> ObtenirUtilisateurAsync(CancellationToken ct = default)
    {
        var (ok, utilisateur) = await EssayerAsync(
            () => http.GetFromJsonAsync<UtilisateurCourant>("api/moi", Json, ct));

        if (ok && utilisateur is not null)
        {
            await MemoriserAsync(ClesCache.Utilisateur, utilisateur);
            return utilisateur;
        }

        // Hors-ligne, le bandeau garde le nom de la dernière session plutôt que de faire croire
        // à une déconnexion.
        var instantane = await LireCacheAsync<UtilisateurCourant>(ClesCache.Utilisateur);
        return instantane?.Donnees ?? UtilisateurCourant.Anonyme;
    }

    /// <summary>
    /// Exécute un appel de lecture. Renvoie <c>false</c> — sans exception — quand le réseau
    /// manque, ce qui est la bascule vers le cache. Une annulation demandée par l'appelant,
    /// elle, continue de remonter : c'est une frappe au clavier, pas une panne.
    /// </summary>
    private async Task<(bool Ok, T? Valeur)> EssayerAsync<T>(Func<Task<T?>> appel)
    {
        // TenterLeReseau et non EnLigne : quand la panne vient du serveur, le navigateur n'a
        // jamais quitté le réseau et n'émettra donc aucun événement « online ». C'est la lecture
        // suivante qui doit constater le retour, sans quoi l'application resterait sur le cache
        // jusqu'au rechargement de la page.
        if (!reseau.TenterLeReseau)
        {
            return (false, default);
        }

        try
        {
            var valeur = await appel();
            reseau.SignalerSuccesReseau();
            return (true, valeur);
        }
        catch (HttpRequestException)
        {
            reseau.SignalerEchecReseau();
            return (false, default);
        }
        catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
        {
            // Délai dépassé sans annulation de l'appelant : le serveur ne répond pas.
            reseau.SignalerEchecReseau();
            return (false, default);
        }
    }

    /// <summary>
    /// Exécute une écriture, ou la <b>refuse</b> hors-ligne avec un motif affichable.
    /// </summary>
    /// <remarks>
    /// L'interface doit désactiver ces actions bien avant d'arriver ici (CLAUDE.md : pas de
    /// bouton qui échoue silencieusement). Ce refus est la seconde ligne, pour le cas où le
    /// réseau tombe entre l'affichage et le clic.
    /// </remarks>
    private async Task<ResultatApi<T>> EcrireAsync<T>(
        Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
    {
        if (!reseau.EnLigne)
        {
            return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
        }

        try
        {
            var reponse = await appel();
            reseau.SignalerSuccesReseau();
            var resultat = await InterpreterAsync<T>(reponse, ct);

            if (resultat.EstOk)
            {
                // La base a changé : l'instantané doit suivre, sinon une coupure juste après une
                // modification montrerait un catalogue d'avant.
                _ = RafraichirEnFondAsync();
            }

            return resultat;
        }
        catch (HttpRequestException)
        {
            reseau.SignalerEchecReseau();
            return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
        }
    }

    /// <summary>Remet l'instantané à jour sans faire attendre l'utilisateur.</summary>
    private async Task RafraichirEnFondAsync()
    {
        try
        {
            await ListerAsync(new CritereLivres());
            await ListerPretsEnCoursAsync();
            await ListerAuteursAsync();
        }
        catch (Exception)
        {
            // Le cache restera daté de la synchronisation précédente : l'interface le dit.
        }
    }

    private async Task MemoriserAsync<T>(string cle, T valeur)
    {
        var date = DateTimeOffset.Now;
        await cache.EcrireAsync(cle, valeur, date);
        reseau.SignalerSynchro(date);
    }

    /// <summary>Relit un instantané et fait remonter sa date, celle que l'interface affiche.</summary>
    private async Task<Instantane<T>?> LireCacheAsync<T>(string cle)
    {
        var instantane = await cache.LireAsync<T>(cle);

        if (instantane is not null)
        {
            reseau.SignalerSynchro(instantane.Date);
        }

        return instantane;
    }

    private static async Task<T?> LireAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
    {
        reponse.EnsureSuccessStatusCode();
        return await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
    }

    private static async Task<ResultatApi<T>> InterpreterAsync<T>(HttpResponseMessage reponse, CancellationToken ct)
    {
        if (reponse.IsSuccessStatusCode)
        {
            var valeur = await reponse.Content.ReadFromJsonAsync<T>(Json, ct);
            return new ResultatApi<T>(valeur, null);
        }

        return new ResultatApi<T>(default, await MessageErreurAsync(reponse, ct));
    }

    /// <summary>Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur.</summary>
    private static async Task<string> MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct)
    {
        try
        {
            var corps = await reponse.Content.ReadFromJsonAsync<ErreurApi>(Json, ct);
            if (!string.IsNullOrWhiteSpace(corps?.Erreur))
            {
                return corps.Erreur;
            }
        }
        catch (Exception)
        {
            // Corps illisible ou vide : on retombe sur le code HTTP.
        }

        return reponse.StatusCode switch
        {
            HttpStatusCode.NotFound => "Ce livre n'existe plus.",
            _ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).",
        };
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Liste d'envies et bibliographie par auteur.
    // Bloc ajouté en fin de classe pour rester bien délimité.
    // ─────────────────────────────────────────────────────────────────────────

    /// <summary>
    /// La liste d'envies <b>de l'utilisateur courant</b>.
    /// </summary>
    /// <remarks>
    /// Comme pour le statut de lecture, le client ne transmet aucune identité : le serveur sait
    /// qui appelle. Il n'existe volontairement pas de moyen de demander la liste d'un autre.
    /// </remarks>
    public async Task<IReadOnlyList<SouhaitDto>> ListerSouhaitsAsync(CancellationToken ct = default) =>
        await http.GetFromJsonAsync<List<SouhaitDto>>("api/souhaits", Json, ct) ?? [];

    public async Task<ResultatApi<SouhaitDto>> AjouterSouhaitAsync(
        EnregistrementSouhait saisie, CancellationToken ct = default)
    {
        var reponse = await http.PostAsJsonAsync("api/souhaits", saisie, Json, ct);
        return await InterpreterAsync<SouhaitDto>(reponse, ct);
    }

    public async Task<string?> SupprimerSouhaitAsync(int id, CancellationToken ct = default)
    {
        var reponse = await http.DeleteAsync($"api/souhaits/{id}", ct);
        return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct);
    }

    /// <summary>
    /// Adresse de téléchargement d'un export.
    /// </summary>
    /// <remarks>
    /// Le fichier n'est pas récupéré par ce service : un simple lien suffit, le serveur pose un
    /// <c>Content-Disposition: attachment</c> et le navigateur enregistre. Passer par
    /// <see cref="HttpClient"/> obligerait à fabriquer une URL d'objet en JavaScript pour un
    /// résultat identique.
    /// <para>
    /// ⚠️ Le lien <b>doit</b> porter l'attribut <c>download</c> : sans lui, le routeur Blazor
    /// intercepte le clic comme une navigation interne et affiche sa page « introuvable » au
    /// lieu de laisser le navigateur télécharger.
    /// </para>
    /// </remarks>
    public static string UrlExportSouhaits(FormatExportSouhaits format) =>
        format == FormatExportSouhaits.Csv ? "api/souhaits/export.csv" : "api/souhaits/export.txt";

    /// <summary>
    /// Bibliographie d'un auteur d'après la BnF. <c>null</c> si l'auteur n'existe pas.
    /// </summary>
    public async Task<BibliographieDto?> ObtenirBibliographieAsync(
        int auteurId, CancellationToken ct = default)
    {
        var reponse = await http.GetAsync($"api/auteurs/{auteurId}/bibliographie", ct);
        return reponse.StatusCode == HttpStatusCode.NotFound
            ? null
            : await LireAsync<BibliographieDto>(reponse, ct);
    }

    private sealed record ErreurApi(string? Erreur);
}

/// <summary>Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable.</summary>
public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
{
    public bool EstOk => Erreur is null;
}
