using System.Net.Http.Json;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using NSec.Cryptography;
using PSLauncher.Core.Configuration;
using PSLauncher.Models;

namespace PSLauncher.Core.Licensing;

public sealed class LicenseService : ILicenseService
{
    /// <summary>
    /// Durée de validité du cache offline. 100 jours = ~3 mois + marge pour un user
    /// qui se connecte en moyenne tous les 3 mois. Au-delà, le launcher exige une
    /// nouvelle validation online (la license elle-même peut être valide sur le serveur,
    /// c'est juste qu'on ne peut plus le vérifier sans réseau).
    /// </summary>
    private const int CachedValidityDays = 100;

    /// <summary>
    /// Tolérance pour les corrections d'horloge légitimes (NTP sync, daylight saving,
    /// reboot après débranchement long, etc.). Au-delà, on suspecte une manipulation.
    /// </summary>
    private static readonly TimeSpan ClockRollbackGrace = TimeSpan.FromHours(1);

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
    };

    private readonly HttpClient _http;
    private readonly Func<string> _serverBaseUrlProvider;
    private readonly IConfigStore _configStore;
    private readonly LocalConfig _config;
    private readonly ILogger<LicenseService> _logger;
    private readonly string? _serverPublicKeyHex;

    public LicenseService(
        HttpClient http,
        Func<string> serverBaseUrlProvider,
        IConfigStore configStore,
        LocalConfig config,
        ILogger<LicenseService> logger)
    {
        _http = http;
        _serverBaseUrlProvider = serverBaseUrlProvider;
        _configStore = configStore;
        _config = config;
        _logger = logger;
        _serverPublicKeyHex = TryReadEmbeddedPublicKey();
    }

    public bool HasLicense() => !string.IsNullOrEmpty(_config.License.EncryptedKey);

    public string GetMachineId()
    {
        // Combine MachineGuid + nom utilisateur, hash SHA-256 → ID stable, sans leak du GUID brut
        string raw;
        try
        {
            using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography");
            raw = (key?.GetValue("MachineGuid") as string) ?? Environment.MachineName;
        }
        catch
        {
            raw = Environment.MachineName;
        }
        var input = $"{raw}|{Environment.UserName}";
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(hash).ToLowerInvariant();
    }

    public async Task<LicenseValidationResponse> ValidateAsync(string licenseKey, CancellationToken ct)
    {
        var url = TrimSlash(_serverBaseUrlProvider()) + "/license/validate";
        var req = new LicenseValidationRequest
        {
            LicenseKey = licenseKey,
            MachineId = GetMachineId(),
            MachineLabel = $"{Environment.MachineName} / {Environment.UserName}",
            LauncherVersion = GetLauncherVersion(),
        };

        _logger.LogInformation("Validating license at {Url} (key {KeyHint}…)", url, licenseKey.Length >= 8 ? licenseKey[..8] : licenseKey);

        using var httpReq = new HttpRequestMessage(HttpMethod.Post, url);
        httpReq.Content = JsonContent.Create(req);
        using var resp = await _http.SendAsync(httpReq, ct).ConfigureAwait(false);

        var bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
        LicenseValidationResponse? parsed;
        try
        {
            parsed = JsonSerializer.Deserialize<LicenseValidationResponse>(bodyText, JsonOptions);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException($"Réponse serveur invalide : {ex.Message}\n{bodyText}", ex);
        }
        if (parsed is null)
            throw new InvalidOperationException("Réponse serveur vide");

        // Statuts d'erreur applicatifs renvoyés en 4xx avec un body décrivant l'erreur
        if (!resp.IsSuccessStatusCode && string.IsNullOrEmpty(parsed.Status))
        {
            parsed.Status = "invalid";
            parsed.Message ??= $"HTTP {(int)resp.StatusCode}";
        }

        // Vérification de la signature serveur (si on a la clé publique embarquée et une signature)
        if (!string.IsNullOrEmpty(_serverPublicKeyHex) && !string.IsNullOrEmpty(parsed.Signature))
        {
            if (!VerifySignature(parsed, parsed.Signature, _serverPublicKeyHex!))
            {
                _logger.LogError("License response signature INVALID — possible MITM");
                throw new InvalidOperationException("Réponse serveur non authentifiée (signature invalide)");
            }
            _logger.LogDebug("License response signature OK");
        }

        return parsed;
    }

    /// <summary>
    /// Retourne la license cachée avec deux protections :
    ///
    ///   1. ANTI-ROLLBACK : si l'horloge système est en retard de plus d'1h par
    ///      rapport au plus récent <c>LastSeenUtc</c> jamais vu par le launcher,
    ///      on suspecte une manipulation (recul de date PC pour gagner du crédit
    ///      offline) → cache invalidé, forçage d'une re-validation online.
    ///
    ///   2. COMPTEUR MONOTONE : <c>LastSeenUtc</c> ne peut que progresser. Mis à
    ///      jour à <c>max(stored, now)</c> à chaque démarrage et persisté immédiatement.
    ///      Donc même si l'utilisateur recule son horloge APRÈS un démarrage, la
    ///      valeur stockée reste la plus récente jamais vue.
    ///
    /// Retourne null si :
    /// - pas de license stockée
    /// - rollback détecté (horloge manipulée)
    /// - cache trop ancien (> CachedValidityDays jours depuis LastValidationAt)
    /// </summary>
    public LicenseValidationResponse? GetCached()
    {
        if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
        if (_config.License.LastValidationAt is null) return null;

        var now = DateTime.UtcNow;
        var lastValidation = _config.License.LastValidationAt.Value;
        // Si LastSeenUtc absent (1ère exécution avec cette nouvelle version), on initialise
        // sur LastValidationAt — référence sûre car signée par le serveur.
        var lastSeen = _config.License.LastSeenUtc ?? lastValidation;

        // 1. Détection rollback : horloge actuelle significativement en retard
        if (now < lastSeen - ClockRollbackGrace)
        {
            _logger.LogWarning(
                "Clock rollback detected (now={Now}, lastSeen={LastSeen}, delta={Delta}). " +
                "Cache invalidated — re-validation requise dès que online.",
                now, lastSeen, lastSeen - now);
            return null;
        }

        // 2. Mise à jour monotone : on ne descend jamais
        var newLastSeen = lastSeen > now ? lastSeen : now;
        if (_config.License.LastSeenUtc != newLastSeen)
        {
            _config.License.LastSeenUtc = newLastSeen;
            try { _configStore.Save(_config); }
            catch (Exception ex) { _logger.LogDebug(ex, "Failed to persist LastSeenUtc update"); }
        }

        // 3. Âge du cache calculé via le compteur monotone (résistant au rollback).
        //    Si l'utilisateur a fait avancer l'horloge artificiellement, ça consomme
        //    son crédit offline plus vite — pas grave, il ré-valide quand vraiment online.
        var ageDays = (newLastSeen - lastValidation).TotalDays;
        if (ageDays > CachedValidityDays)
        {
            _logger.LogInformation(
                "Offline license cache expired (age {Age:N1} days > max {Max} days). " +
                "Re-validation requise dès que online.",
                ageDays, CachedValidityDays);
            return null;
        }

        // Statut : on prend la valeur cachée par le serveur (révoquée, valide, etc.)
        // mais avec auto-override "expired" si la date d'entitlement est dépassée.
        // Ça gère le passage automatique valide → expirée même si le user reste
        // offline pendant des semaines.
        var cachedStatus = _config.License.CachedStatus ?? "valid";
        if (cachedStatus == "valid"
            && _config.License.CachedEntitlementUntil is { } entUntil
            && entUntil < newLastSeen)
        {
            cachedStatus = "expired";
        }

        return new LicenseValidationResponse
        {
            Status = cachedStatus,
            OwnerName = _config.License.CachedOwnerName,
            DownloadEntitlementUntil = _config.License.CachedEntitlementUntil,
            ServerTime = lastValidation,
        };
    }

    public void SaveCached(string licenseKey, LicenseValidationResponse response)
    {
        var encrypted = ProtectKey(licenseKey);
        _config.License.EncryptedKey = encrypted;
        // ServerTime fait partie du payload signé Ed25519 par le serveur, donc non
        // manipulable par l'utilisateur. Si absent (cas dégradé), on retombe sur
        // l'horloge locale.
        _config.License.LastValidationAt = response.ServerTime ?? DateTime.UtcNow;
        _config.License.LastSeenUtc       = DateTime.UtcNow;
        _config.License.CachedOwnerName   = response.OwnerName;
        _config.License.CachedEntitlementUntil = response.DownloadEntitlementUntil;
        _config.License.CachedStatus     = response.Status;
        _configStore.Save(_config);
    }

    public void Clear()
    {
        _config.License = new LicenseConfig();
        _configStore.Save(_config);
    }

    public bool CanDownloadVersion(LicenseValidationResponse? license, VersionManifest version)
    {
        if (license is null) return false;
        return license.CanDownload(version);
    }

    public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
    {
        var key = GetDecryptedKey();
        if (string.IsNullOrEmpty(key)) return null;

        // On utilise ?key=… plutôt que le header Authorization parce que Apache OVH
        // (et beaucoup de mutualisés FastCGI) strippe ce header avant qu'il atteigne
        // PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
        var encodedKey = Uri.EscapeDataString(key);
        var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
        try
        {
            using var req = new HttpRequestMessage(HttpMethod.Get, url);
            using var resp = await _http.SendAsync(req, ct).ConfigureAwait(false);
            if (!resp.IsSuccessStatusCode)
            {
                _logger.LogInformation("Signed URL endpoint returned {Status} — falling back to public URL", (int)resp.StatusCode);
                return null;
            }
            var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
            using var doc = JsonDocument.Parse(body);
            return doc.RootElement.TryGetProperty("url", out var u) ? u.GetString() : null;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Signed URL fetch failed, falling back to public URL");
            return null;
        }
    }

    public string? GetDecryptedKey()
    {
        if (string.IsNullOrEmpty(_config.License.EncryptedKey)) return null;
        try { return UnprotectKey(_config.License.EncryptedKey); }
        catch (Exception ex) { _logger.LogWarning(ex, "Failed to decrypt cached license key"); return null; }
    }

    // ----- DPAPI -----

    private static string ProtectKey(string clearKey)
    {
        var data = Encoding.UTF8.GetBytes(clearKey);
        var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
        return Convert.ToBase64String(protectedBytes);
    }

    private static string UnprotectKey(string base64)
    {
        var protectedBytes = Convert.FromBase64String(base64);
        var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
        return Encoding.UTF8.GetString(data);
    }

    // ----- Signature -----

    /// <summary>
    /// Encode canonical du payload en JSON sans le champ 'signature', exactement comme côté PHP
    /// (Crypto::canonicalJson : JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).
    /// </summary>
    public static byte[] CanonicalBytesFor(LicenseValidationResponse response)
    {
        // On reconstruit un dictionnaire ordonné comme côté PHP, sans signature
        var dict = new Dictionary<string, object?>
        {
            ["status"]                     = response.Status,
            ["licenseId"]                  = response.LicenseId,
            ["ownerName"]                  = response.OwnerName,
            ["issuedAt"]                   = FormatDateAtom(response.IssuedAt),
            ["downloadEntitlementUntil"]   = FormatDateAtom(response.DownloadEntitlementUntil),
            ["maxMachines"]                = response.MaxMachines,
            ["serverTime"]                 = FormatDateAtom(response.ServerTime),
        };
        var opts = new JsonSerializerOptions
        {
            Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            DefaultIgnoreCondition = JsonIgnoreCondition.Never,
        };
        return JsonSerializer.SerializeToUtf8Bytes(dict, opts);
    }

    /// <summary>
    /// Format figé "yyyy-MM-ddTHH:mm:ssZ" en UTC, identique à ce que produit côté serveur
    /// PHP `(new DateTimeImmutable($s, new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')`.
    /// </summary>
    private static string? FormatDateAtom(DateTime? d)
    {
        if (d is null) return null;
        return d.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss") + "Z";
    }

    public static bool VerifySignature(LicenseValidationResponse response, string base64Signature, string publicKeyHex)
    {
        try
        {
            var alg = SignatureAlgorithm.Ed25519;
            var pkBytes = Convert.FromHexString(publicKeyHex);
            var pk = PublicKey.Import(alg, pkBytes, KeyBlobFormat.RawPublicKey);
            var sig = Convert.FromBase64String(base64Signature);
            var payload = CanonicalBytesFor(response);
            return alg.Verify(pk, payload, sig);
        }
        catch
        {
            return false;
        }
    }

    // ----- Public key embarquée -----

    private static string? TryReadEmbeddedPublicKey()
    {
        var asm = Assembly.GetExecutingAssembly();
        foreach (var name in asm.GetManifestResourceNames())
        {
            if (name.EndsWith("server-pubkey.txt", StringComparison.OrdinalIgnoreCase))
            {
                using var s = asm.GetManifestResourceStream(name);
                if (s is null) continue;
                using var sr = new StreamReader(s);
                var hex = sr.ReadToEnd().Trim();
                if (hex.StartsWith("#") || string.IsNullOrEmpty(hex)) return null; // placeholder/ commenté
                return hex;
            }
        }
        return null;
    }

    private static string GetLauncherVersion()
    {
        var asm = Assembly.GetExecutingAssembly();
        return asm.GetName().Version?.ToString() ?? "0.0.0";
    }

    private static string TrimSlash(string s) => s.TrimEnd('/');
}
