using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PSLauncher.Models;
using SysProcess = System.Diagnostics.Process;

namespace PSLauncher.Core.SteamVr;

/// <summary>
/// Implémentation du merge SteamVR. Voir <see cref="ISteamVrSettingsDeployer"/>.
/// </summary>
public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
{
    /// <summary>Sous-dossier dans le ZIP PROSERVE où chercher le fichier source.</summary>
    private const string SourceFolderName = "_steamvr";
    /// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
    private const string SettingsFileName = "steamvr.vrsettings";

    /// <summary>
    /// Délai d'attente entre le kill des processus et l'ouverture du fichier.
    /// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
    /// au shutdown ⇒ on attend que le process se termine vraiment avant de toucher
    /// au fichier. 1 s suffit en pratique mais 2 s pour avoir une marge.
    /// </summary>
    private static readonly TimeSpan PostKillSettleDelay = TimeSpan.FromSeconds(2);

    private readonly Func<SteamVrConfig> _configProvider;
    private readonly ILogger<SteamVrSettingsDeployer> _logger;

    public SteamVrSettingsDeployer(
        Func<SteamVrConfig> configProvider,
        ILogger<SteamVrSettingsDeployer> logger)
    {
        _configProvider = configProvider;
        _logger = logger;
    }

    public async Task<SteamVrMergeCheckResult> CheckMergeNeededAsync(
        string installFolder,
        CancellationToken ct)
    {
        var config = _configProvider();

        // 1) Fichier source présent ?
        var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
        if (!File.Exists(sourcePath))
        {
            return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.NoSourceFile, null, 0);
        }

        // 2) Option activée ?
        if (!config.AutoMerge)
        {
            return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.DisabledByConfig, null, 0);
        }

        // 3) Cible localisée ?
        var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
            ? config.SettingsFilePathOverride!
            : FindSteamVrSettingsPath();
        if (targetPath is null)
        {
            return new SteamVrMergeCheckResult(SteamVrMergeCheckOutcome.SteamNotFound, null, 0);
        }

        // 4) Parse source. Si invalide, le vrai merge échouera de toute façon.
        JsonObject sourceObj;
        try
        {
            var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
            sourceObj = JsonNode.Parse(sourceText) as JsonObject
                        ?? throw new InvalidDataException("source root is not a JSON object");
        }
        catch (Exception ex)
        {
            return new SteamVrMergeCheckResult(
                SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
                $"Source JSON invalide : {ex.Message}");
        }

        // 5) Si la cible n'existe pas, tout est à ajouter — merge nécessaire.
        if (!File.Exists(targetPath))
        {
            _logger.LogInformation(
                "SteamVR target file does not exist yet ({Path}) — merge needed (will create from scratch)",
                targetPath);
            return new SteamVrMergeCheckResult(
                SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
        }

        // 6) Parse cible. Si invalide, on signale mais le vrai merge serait
        // contraint d'écrire (= overwrite) une cible illisible.
        JsonObject targetObj;
        try
        {
            var targetText = await File.ReadAllTextAsync(targetPath, ct);
            if (string.IsNullOrWhiteSpace(targetText))
            {
                // Fichier vide ⇒ merge ajouterait tout le source.
                return new SteamVrMergeCheckResult(
                    SteamVrMergeCheckOutcome.Needed, targetPath, sourceObj.Count);
            }
            targetObj = JsonNode.Parse(targetText) as JsonObject
                        ?? throw new InvalidDataException("target root is not a JSON object");
        }
        catch (Exception ex)
        {
            return new SteamVrMergeCheckResult(
                SteamVrMergeCheckOutcome.InvalidJson, targetPath, 0,
                $"Cible JSON invalide : {ex.Message}");
        }

        // 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou
        // modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés
        // d'un bloc qui change ne sont pas comptées individuellement.
        int rootKeysChanging = 0;
        foreach (var (key, srcValue) in sourceObj)
        {
            if (!targetObj.ContainsKey(key))
            {
                rootKeysChanging++;
                continue;
            }
            var tgtValue = targetObj[key];
            if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
            {
                if (WouldDeepMergeChange(tgtChild, srcChild))
                    rootKeysChanging++;
            }
            else if (!JsonNodesEqual(tgtValue, srcValue))
            {
                rootKeysChanging++;
            }
        }

        var outcome = rootKeysChanging > 0
            ? SteamVrMergeCheckOutcome.Needed
            : SteamVrMergeCheckOutcome.UpToDate;
        _logger.LogInformation(
            "SteamVR merge check: outcome={Outcome}, rootKeysChanging={Count}, target={Path}",
            outcome, rootKeysChanging, targetPath);
        return new SteamVrMergeCheckResult(outcome, targetPath, rootKeysChanging);
    }

    public async Task<SteamVrMergeResult> MergeAsync(
        string installFolder,
        IReadOnlyList<string>? processesToKill,
        CancellationToken ct)
    {
        var config = _configProvider();

        // 1. Cherche le fichier source dans le ZIP extrait. Pas de fichier ⇒
        // pas de merge ⇒ silent skip (toutes les versions PROSERVE ne shippent
        // pas forcément un _steamvr/).
        var sourcePath = Path.Combine(installFolder, SourceFolderName, SettingsFileName);
        if (!File.Exists(sourcePath))
        {
            _logger.LogDebug("No _steamvr/{File} in {Folder}, skipping SteamVR merge",
                SettingsFileName, installFolder);
            return new SteamVrMergeResult(
                SteamVrMergeStatus.SkippedNoSourceFile, 0, 0, null, 0);
        }

        // 2. Opt-out par config (peut être désactivé sur des machines de dev).
        if (!config.AutoMerge)
        {
            _logger.LogInformation("SteamVR merge disabled by config (AutoMerge=false)");
            return new SteamVrMergeResult(
                SteamVrMergeStatus.SkippedDisabledByConfig, 0, 0, null, 0);
        }

        // 3. Résout le path du fichier cible (override config OU registre OU fallback).
        var targetPath = !string.IsNullOrWhiteSpace(config.SettingsFilePathOverride)
            ? config.SettingsFilePathOverride!
            : FindSteamVrSettingsPath();
        if (targetPath is null)
        {
            _logger.LogWarning(
                "Steam install not found via registry + fallback, cannot merge SteamVR settings");
            return new SteamVrMergeResult(
                SteamVrMergeStatus.SteamNotFound, 0, 0, null, 0,
                "Steam introuvable (registre HKCU\\Software\\Valve\\Steam + HKLM\\Wow6432Node + %ProgramFiles(x86)%\\Steam)");
        }

        try
        {
            // 4. Tue SteamVR + Vive Business Streaming. Vive en PREMIER car il
            // relance auto SteamVR sinon. On parse + write avec un délai de
            // règlement APRES, parce que les processus flushent leurs settings
            // au shutdown.
            //
            // Le caller (MainViewModel) construit la liste depuis les health
            // checks de type Process (= les programmes que l'opérateur surveille
            // déjà). Si rien n'est passé, on retombe sur la liste par défaut
            // de la config — utile pour les setups sans health checks configurés.
            var killList = (processesToKill is not null && processesToKill.Count > 0)
                ? processesToKill
                : config.ProcessesToKill;
            var killed = await KillProcessesAsync(killList, ct);
            if (killed > 0)
            {
                _logger.LogInformation(
                    "Killed {Count} SteamVR/VBS process(es), waiting {Delay}ms for file release",
                    killed, (int)PostKillSettleDelay.TotalMilliseconds);
                await Task.Delay(PostKillSettleDelay, ct);
            }

            // 5. Parse le JSON source. Si le fichier est invalide, on log et on
            // abandonne — pas la peine de risquer un overwrite avec une cible
            // potentiellement valide.
            JsonObject sourceObj;
            try
            {
                var sourceText = await File.ReadAllTextAsync(sourcePath, ct);
                sourceObj = JsonNode.Parse(sourceText) as JsonObject
                            ?? throw new InvalidDataException("source root is not a JSON object");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Source SteamVR settings file is not valid JSON: {Path}", sourcePath);
                return new SteamVrMergeResult(
                    SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
                    $"Source JSON invalide : {ex.Message}");
            }

            // 6. Parse le JSON cible (si absent ou vide → on part d'un objet
            // vide, on créera le fichier from scratch). Si le fichier existe
            // mais est invalide, on abandonne (mieux vaut un fichier intact
            // que de l'écraser).
            JsonObject targetObj;
            if (File.Exists(targetPath))
            {
                try
                {
                    var targetText = await File.ReadAllTextAsync(targetPath, ct);
                    if (string.IsNullOrWhiteSpace(targetText))
                    {
                        targetObj = new JsonObject();
                    }
                    else
                    {
                        targetObj = JsonNode.Parse(targetText) as JsonObject
                                    ?? throw new InvalidDataException("target root is not a JSON object");
                    }
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Target SteamVR settings file is not valid JSON: {Path}", targetPath);
                    return new SteamVrMergeResult(
                        SteamVrMergeStatus.InvalidJson, 0, 0, targetPath, killed,
                        $"Cible JSON invalide : {ex.Message}");
                }
            }
            else
            {
                _logger.LogInformation(
                    "Target SteamVR settings file does not exist yet, creating: {Path}",
                    targetPath);
                Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
                targetObj = new JsonObject();
            }

            // 7. Deep merge récursif :
            //   • Clé absente côté cible → ajoute (copie complète du source)
            //   • Clé présente des DEUX côtés ET les deux valeurs sont des objets
            //     JSON → recurse (préserve les sous-clés cible non touchées par le source)
            //   • Sinon (types différents, ou tableau, ou primitive) → le source gagne
            //     (full replace de la valeur)
            //
            // Cas concret du bloc "trackers" : si le fichier user contient déjà
            //   "trackers" : { "/devices/oculus/.../X" : "TrackerRole_...",
            //                  "/devices/htc/TKR_LEFT" : "ancienne valeur" }
            // et que le source apporte
            //   "trackers" : { "/devices/htc_business_streaming/TKR_LEFT" : "...",
            //                  "/devices/htc_business_streaming/TKR_RIGHT" : "..." }
            // → résultat fusionné = les 3 clés oculus + 2 htc_business_streaming
            //   (oculus préservée, ancienne htc/TKR_LEFT préservée puisque clé
            //   différente, htc_business_streaming/* ajoutées).
            //
            // Les compteurs top-level reflètent uniquement les clés RACINE
            // (remplacées ou ajoutées) parce que c'est le niveau visible dans
            // les logs et le seul niveau que l'opérateur a besoin de monitorer.
            int replaced = 0, added = 0;
            foreach (var (key, srcValue) in sourceObj.ToList())
            {
                if (!targetObj.ContainsKey(key))
                {
                    targetObj[key] = srcValue?.DeepClone();
                    added++;
                    continue;
                }

                // Clé présente des deux côtés. Si les deux valeurs sont des
                // objets JSON, on deep-merge ; sinon le source remplace.
                if (srcValue is JsonObject srcChild
                    && targetObj[key] is JsonObject tgtChild)
                {
                    DeepMergeInto(tgtChild, srcChild);
                }
                else
                {
                    targetObj[key] = srcValue?.DeepClone();
                }
                replaced++;
            }

            // 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
            // indenté à 3 espaces dans ses propres écritures — on respecte le
            // style pour minimiser le diff visuel pour un opérateur qui ouvre
            // le fichier en éditeur.
            var json = targetObj.ToJsonString(new JsonSerializerOptions
            {
                WriteIndented = true,
            });
            var tmpPath = targetPath + ".pslauncher-tmp";
            await File.WriteAllTextAsync(tmpPath, json, ct);
            // File.Move(overwrite=true) est atomique sur NTFS (.NET 5+)
            File.Move(tmpPath, targetPath, overwrite: true);

            _logger.LogInformation(
                "SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
                replaced, added, targetPath);
            return new SteamVrMergeResult(
                SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
        }
        catch (OperationCanceledException)
        {
            throw;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "SteamVR merge failed unexpectedly");
            return new SteamVrMergeResult(
                SteamVrMergeStatus.Failed, 0, 0, targetPath, 0,
                ex.Message);
        }
    }

    /// <summary>
    /// Path canonique de Steam x86 quand il est installé à l'emplacement
    /// standard (90 % des cas). Exposé pour que la UI Settings puisse afficher
    /// la valeur attendue en placeholder du TextBox d'override.
    /// </summary>
    public static string CanonicalSettingsPath { get; } = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
        "Steam", "config", "steamvr.vrsettings");

    /// <summary>
    /// Résout le path vers <c>steamvr.vrsettings</c>. Stratégie : on essaie les
    /// 3 sources dans l'ordre, et on retourne la PREMIÈRE qui pointe vers un
    /// dossier Steam qui existe vraiment sur disque. Le fichier lui-même peut
    /// ne pas exister encore — on le créera au write.
    /// <list type="number">
    ///   <item>HKCU\Software\Valve\Steam → SteamPath (valeur typique : <c>C:/Program Files (x86)/Steam</c>)</item>
    ///   <item>HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath (typique : <c>C:\Program Files (x86)\Steam</c>)</item>
    ///   <item>Fallback hardcodé : <c>%ProgramFiles(x86)%\Steam\config\steamvr.vrsettings</c></item>
    /// </list>
    /// Retourne null si AUCUNE des 3 sources ne pointe vers un dossier Steam
    /// existant — Steam n'est probablement pas installé sur cette machine.
    /// </summary>
    private string? FindSteamVrSettingsPath()
    {
        // Énumère les candidats dans l'ordre de priorité. Pour chacun on
        // vérifie que la racine Steam (= grand-parent du fichier final) existe
        // physiquement avant d'accepter — sinon un opérateur qui a désinstallé
        // Steam puis ré-installé ailleurs avait des fantômes du registre qui
        // nous renvoyaient vers un dossier inexistant.
        var candidates = new (string Path, string Source)[]
        {
            (TryRegistryPath(Registry.CurrentUser, @"Software\Valve\Steam", "SteamPath"),
                "HKCU\\Software\\Valve\\Steam"),
            (TryRegistryPath(Registry.LocalMachine, @"SOFTWARE\WOW6432Node\Valve\Steam", "InstallPath"),
                "HKLM\\Wow6432Node\\Valve\\Steam"),
            (CanonicalSettingsPath, "fallback %ProgramFiles(x86)%\\Steam"),
        };

        foreach (var (candidate, source) in candidates)
        {
            if (string.IsNullOrWhiteSpace(candidate)) continue;
            // grand-parent = .../Steam (parent = .../Steam/config)
            var steamRoot = Path.GetDirectoryName(Path.GetDirectoryName(candidate));
            if (steamRoot is null || !Directory.Exists(steamRoot))
            {
                _logger.LogDebug(
                    "Skipping SteamVR candidate from {Source} (Steam root {Root} doesn't exist)",
                    source, steamRoot);
                continue;
            }
            _logger.LogInformation(
                "Resolved SteamVR settings target via {Source}: {Path} (file {Exists})",
                source, candidate, File.Exists(candidate) ? "exists" : "will be created");
            return candidate;
        }

        return null;
    }

    /// <summary>
    /// Lit une string sous une clé registre + sous-clé nommée, normalise les
    /// slashes Unix → Windows, puis compose le path vers <c>config\{SettingsFileName}</c>.
    /// Retourne string.Empty si la clé n'existe pas ou si la valeur est vide.
    /// Wrappé en try/catch silencieux : un manque de droits sur HKLM n'est pas
    /// fatal — on tombera sur le candidat suivant.
    /// </summary>
    private string TryRegistryPath(RegistryKey root, string subKey, string valueName)
    {
        try
        {
            using var key = root.OpenSubKey(subKey);
            if (key?.GetValue(valueName) is string raw && !string.IsNullOrWhiteSpace(raw))
            {
                var normalized = raw.Replace('/', '\\').TrimEnd('\\');
                return Path.Combine(normalized, "config", SettingsFileName);
            }
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Registry read failed for {Root}\\{SubKey}!{Value}",
                root.Name, subKey, valueName);
        }
        return string.Empty;
    }

    /// <summary>
    /// Tue tous les processus dont le nom (sans .exe) correspond à un des
    /// patterns de <paramref name="processNames"/> (case-insensitive). Best-effort :
    /// si on ne peut pas killer un process (AccessDenied, déjà parti), on log
    /// et on continue. Retourne le nombre total de process effectivement tués.
    ///
    /// L'ordre des entrées dans la liste compte : Vive Business Streaming
    /// doit être en TÊTE car il relance SteamVR sinon.
    /// </summary>
    /// <summary>
    /// Fusionne récursivement <paramref name="source"/> dans <paramref name="target"/>.
    /// Règles par clé :
    ///   • absente côté cible → ajout (clone profond de la valeur source)
    ///   • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse
    ///   • sinon → la valeur source écrase celle de cible
    ///
    /// Les arrays JSON sont traités comme des "primitives" (= replace complet,
    /// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans
    /// ses settings de toute façon, c'est juste pour avoir un comportement
    /// prévisible si jamais.
    /// </summary>
    /// <summary>
    /// Dry-run récursif : applique mentalement le même algo que
    /// <see cref="DeepMergeInto"/> et retourne true dès qu'au moins UNE clé du
    /// source serait ajoutée ou modifiée côté target. Short-circuit pour ne
    /// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff.
    /// </summary>
    private static bool WouldDeepMergeChange(JsonObject target, JsonObject source)
    {
        foreach (var (key, srcValue) in source)
        {
            if (!target.ContainsKey(key)) return true;
            var tgtValue = target[key];

            if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
            {
                if (WouldDeepMergeChange(tgtChild, srcChild)) return true;
            }
            else if (!JsonNodesEqual(tgtValue, srcValue))
            {
                return true;
            }
        }
        return false;
    }

    /// <summary>
    /// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
    /// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
    /// on l'implémente à la main. Pour les primitives, on compare via la
    /// serialization JSON par défaut — ToJsonString produit un format canonique
    /// pour chaque type (number, string, bool, null), donc deux valeurs
    /// logiquement égales produiront la même chaîne.
    /// </summary>
    private static bool JsonNodesEqual(JsonNode? a, JsonNode? b)
    {
        if (a is null && b is null) return true;
        if (a is null || b is null) return false;

        if (a is JsonObject aObj && b is JsonObject bObj)
        {
            if (aObj.Count != bObj.Count) return false;
            foreach (var (key, aVal) in aObj)
            {
                if (!bObj.TryGetPropertyValue(key, out var bVal)) return false;
                if (!JsonNodesEqual(aVal, bVal)) return false;
            }
            return true;
        }

        if (a is JsonArray aArr && b is JsonArray bArr)
        {
            if (aArr.Count != bArr.Count) return false;
            for (int i = 0; i < aArr.Count; i++)
            {
                if (!JsonNodesEqual(aArr[i], bArr[i])) return false;
            }
            return true;
        }

        if (a is JsonValue && b is JsonValue)
        {
            // Primitives : la sérialisation canonique gère uniformément les
            // numbers (int/long/double), strings, bools, null.
            return string.Equals(a.ToJsonString(), b.ToJsonString(), StringComparison.Ordinal);
        }

        // Types incompatibles (ex: object vs primitive)
        return false;
    }

    private static void DeepMergeInto(JsonObject target, JsonObject source)
    {
        foreach (var (key, srcValue) in source.ToList())
        {
            if (!target.ContainsKey(key))
            {
                target[key] = srcValue?.DeepClone();
                continue;
            }

            if (srcValue is JsonObject srcChild
                && target[key] is JsonObject tgtChild)
            {
                DeepMergeInto(tgtChild, srcChild);
            }
            else
            {
                target[key] = srcValue?.DeepClone();
            }
        }
    }

    private async Task<int> KillProcessesAsync(
        IReadOnlyList<string> processNames,
        CancellationToken ct)
    {
        int total = 0;
        foreach (var name in processNames)
        {
            ct.ThrowIfCancellationRequested();
            var trimmed = name?.Trim();
            if (string.IsNullOrEmpty(trimmed)) continue;
            // GetProcessesByName attend le nom SANS le .exe ; un opérateur peut
            // l'avoir saisi avec → strip défensif.
            if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
                trimmed = trimmed[..^4];

            SysProcess[] procs;
            try { procs = SysProcess.GetProcessesByName(trimmed); }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "GetProcessesByName({Name}) failed", trimmed);
                continue;
            }

            foreach (var p in procs)
            {
                try
                {
                    if (p.HasExited) { p.Dispose(); continue; }
                    _logger.LogInformation("Killing process {Name} (PID {Pid})", p.ProcessName, p.Id);
                    p.Kill(entireProcessTree: true);
                    // Wait un peu pour que le process release ses handles
                    try { await p.WaitForExitAsync(ct); }
                    catch (OperationCanceledException) { throw; }
                    catch { /* race avec Kill possible, ok */ }
                    total++;
                }
                catch (OperationCanceledException) { throw; }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex,
                        "Could not kill process {Name} (PID {Pid}) — continuing",
                        p.ProcessName, p.Id);
                }
                finally
                {
                    try { p.Dispose(); } catch { }
                }
            }
        }
        return total;
    }
}
