using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace CodeRemoteManager.SetupApp;

public sealed class ConfigurationStore
{
    private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
    private static readonly UTF8Encoding Utf8WithoutBom = new(encoderShouldEmitUTF8Identifier: false);
    private readonly Func<string, CancellationToken, Task> validateAsync;
    private readonly Action<string, string, string> replaceFile;

    public ConfigurationStore(
        Func<string, CancellationToken, Task> validateAsync,
        Action<string, string, string>? replaceFile = null)
    {
        this.validateAsync = validateAsync;
        this.replaceFile = replaceFile ?? ((source, destination, backup) => File.Replace(source, destination, backup));
    }

    public async Task SaveAsync(
        string configPath,
        JsonObject candidate,
        CancellationToken cancellationToken = default)
    {
        var directory = Path.GetDirectoryName(configPath)
            ?? throw new ConfigurationMigrationException("configuration_write_failed");
        Directory.CreateDirectory(directory);
        var pendingPath = $"{configPath}.pending";
        var rollbackPendingPath = $"{configPath}.rollback.pending";
        var transactionPath = $"{configPath}.transaction";
        string? backupPath = null;
        string? candidateHash = null;
        var replacementCompleted = false;
        var transactionStarted = false;

        try
        {
            await RecoverAsync(configPath, cancellationToken);
            var originalHash = File.Exists(configPath)
                ? await HashFileAsync(configPath, cancellationToken)
                : null;
            var candidateContent = candidate.ToJsonString(JsonOptions) + Environment.NewLine;
            candidateHash = Sha256(candidateContent);
            await WriteDurablyAsync(pendingPath, candidateContent, cancellationToken);
            await validateAsync(pendingPath, cancellationToken);

            if (File.Exists(configPath))
            {
                var backupDirectory = Path.Combine(directory, "config-backups");
                Directory.CreateDirectory(backupDirectory);
                backupPath = Path.Combine(
                    backupDirectory,
                    $"config.local.{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}.{Guid.NewGuid():N}.json");
            }

            await WriteTransactionAsync(
                transactionPath,
                originalHash is null ? "create" : "replace",
                candidateHash,
                originalHash,
                backupPath is null ? null : Path.GetFileName(backupPath),
                cancellationToken);
            transactionStarted = true;

            if (originalHash is not null && backupPath is not null)
            {
                if (!File.Exists(configPath) || await HashFileAsync(configPath, cancellationToken) != originalHash)
                    throw new ConfigurationMigrationException("configuration_changed");
                replaceFile(pendingPath, configPath, backupPath);
                replacementCompleted = true;
            }
            else
            {
                File.Move(pendingPath, configPath);
                replacementCompleted = true;
            }

            await validateAsync(configPath, cancellationToken);
            if (backupPath is not null) PruneSuccessfulBackups(Path.GetDirectoryName(backupPath)!);
            File.Delete(transactionPath);
            transactionStarted = false;
        }
        catch
        {
            if (replacementCompleted && backupPath is not null && File.Exists(backupPath))
            {
                try
                {
                    if (File.Exists(rollbackPendingPath)) File.Delete(rollbackPendingPath);
                    await WriteDurablyAsync(
                        rollbackPendingPath,
                        await File.ReadAllTextAsync(backupPath, cancellationToken),
                        cancellationToken);
                    await validateAsync(rollbackPendingPath, cancellationToken);
                    var failedPath = Path.Combine(
                        Path.GetDirectoryName(backupPath)!,
                        $"failed-config.{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}.{Guid.NewGuid():N}.json");
                    File.Replace(rollbackPendingPath, configPath, failedPath);
                    await validateAsync(configPath, cancellationToken);
                    File.Delete(transactionPath);
                    transactionStarted = false;
                }
                catch
                {
                    throw new ConfigurationMigrationException("configuration_rollback_failed");
                }
            }
            else if (replacementCompleted && backupPath is null)
            {
                try
                {
                    if (candidateHash is null || !File.Exists(configPath) ||
                        await HashFileAsync(configPath, cancellationToken) != candidateHash)
                        throw new InvalidOperationException();
                    QuarantineFailedConfiguration(configPath, directory);
                    File.Delete(transactionPath);
                    transactionStarted = false;
                }
                catch
                {
                    throw new ConfigurationMigrationException("configuration_rollback_failed");
                }
            }
            else if (!replacementCompleted && transactionStarted)
            {
                File.Delete(transactionPath);
                transactionStarted = false;
            }
            throw new ConfigurationMigrationException("configuration_write_failed");
        }
        finally
        {
            if (File.Exists(pendingPath)) File.Delete(pendingPath);
            if (File.Exists(rollbackPendingPath)) File.Delete(rollbackPendingPath);
        }
    }

    public async Task RecoverAsync(string configPath, CancellationToken cancellationToken = default)
    {
        var pendingPath = $"{configPath}.pending";
        var transactionPath = $"{configPath}.transaction";
        if (!File.Exists(transactionPath))
        {
            if (File.Exists(configPath) && File.Exists(pendingPath)) File.Delete(pendingPath);
            if (!File.Exists(configPath) && File.Exists(pendingPath))
            {
                try
                {
                    await validateAsync(pendingPath, cancellationToken);
                    await WriteTransactionAsync(
                        transactionPath,
                        "create",
                        await HashFileAsync(pendingPath, cancellationToken),
                        null,
                        null,
                        cancellationToken);
                }
                catch
                {
                    QuarantineFailedConfiguration(pendingPath, Path.GetDirectoryName(configPath)!);
                    throw new ConfigurationMigrationException("configuration_recovery_failed");
                }
            }
            else
            {
                return;
            }
        }

        try
        {
            var journal = JsonNode.Parse(await File.ReadAllTextAsync(transactionPath, cancellationToken)) as JsonObject
                ?? throw new InvalidOperationException();
            if (journal["version"]?.GetValue<int>() != 1) throw new InvalidOperationException();
            var mode = journal["mode"]?.GetValue<string>();
            var candidateHash = journal["candidateHash"]?.GetValue<string>();
            var originalHash = journal["originalHash"]?.GetValue<string>();
            var backupFile = journal["backupFile"]?.GetValue<string>();
            if (mode is not ("create" or "replace") || string.IsNullOrEmpty(candidateHash))
                throw new InvalidOperationException();
            if (backupFile is not null && backupFile != Path.GetFileName(backupFile))
                throw new InvalidOperationException();

            if (File.Exists(configPath))
            {
                var currentHash = await HashFileAsync(configPath, cancellationToken);
                if (currentHash == candidateHash || (originalHash is not null && currentHash == originalHash))
                {
                    try
                    {
                        await validateAsync(configPath, cancellationToken);
                    }
                    catch when (mode == "create" && currentHash == candidateHash)
                    {
                        QuarantineFailedConfiguration(configPath, Path.GetDirectoryName(configPath)!);
                        CleanupTransaction(transactionPath, pendingPath);
                        throw new ConfigurationMigrationException("configuration_recovery_failed");
                    }
                    CleanupTransaction(transactionPath, pendingPath);
                    return;
                }
            }

            if (mode == "create" && !File.Exists(configPath))
            {
                if (!File.Exists(pendingPath) || await HashFileAsync(pendingPath, cancellationToken) != candidateHash)
                    throw new InvalidOperationException();
                await validateAsync(pendingPath, cancellationToken);
                File.Move(pendingPath, configPath);
                await validateAsync(configPath, cancellationToken);
                CleanupTransaction(transactionPath, pendingPath);
                return;
            }

            if (mode == "replace" && backupFile is not null)
            {
                var directory = Path.GetDirectoryName(configPath)!;
                var backupPath = Path.Combine(directory, "config-backups", backupFile);
                if (!File.Exists(backupPath) || originalHash is null ||
                    await HashFileAsync(backupPath, cancellationToken) != originalHash)
                    throw new InvalidOperationException();
                await validateAsync(backupPath, cancellationToken);
                var rollbackPath = $"{configPath}.rollback.pending";
                await WriteDurablyAsync(
                    rollbackPath,
                    await File.ReadAllTextAsync(backupPath, cancellationToken),
                    cancellationToken);
                await validateAsync(rollbackPath, cancellationToken);
                if (File.Exists(configPath))
                {
                    var failedPath = Path.Combine(
                        Path.GetDirectoryName(backupPath)!,
                        $"failed-config.{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}.{Guid.NewGuid():N}.json");
                    File.Replace(rollbackPath, configPath, failedPath);
                }
                else
                {
                    File.Move(rollbackPath, configPath);
                }
                await validateAsync(configPath, cancellationToken);
                CleanupTransaction(transactionPath, pendingPath);
                return;
            }

            throw new InvalidOperationException();
        }
        catch (ConfigurationMigrationException)
        {
            throw;
        }
        catch
        {
            throw new ConfigurationMigrationException("configuration_recovery_failed");
        }
    }

    private static async Task WriteDurablyAsync(
        string path,
        string content,
        CancellationToken cancellationToken)
    {
        var bytes = Utf8WithoutBom.GetBytes(content);
        await using var stream = new FileStream(
            path,
            FileMode.CreateNew,
            FileAccess.Write,
            FileShare.None,
            bufferSize: 4096,
            FileOptions.Asynchronous | FileOptions.WriteThrough);
        await stream.WriteAsync(bytes, cancellationToken);
        await stream.FlushAsync(cancellationToken);
        stream.Flush(flushToDisk: true);
    }

    private static async Task WriteTransactionAsync(
        string transactionPath,
        string mode,
        string candidateHash,
        string? originalHash,
        string? backupFile,
        CancellationToken cancellationToken)
    {
        var journal = new JsonObject
        {
            ["version"] = 1,
            ["mode"] = mode,
            ["candidateHash"] = candidateHash,
            ["originalHash"] = originalHash,
            ["backupFile"] = backupFile
        };
        await WriteDurablyAsync(
            transactionPath,
            journal.ToJsonString(JsonOptions) + Environment.NewLine,
            cancellationToken);
    }

    private static async Task<string> HashFileAsync(string path, CancellationToken cancellationToken) =>
        Sha256(await File.ReadAllTextAsync(path, cancellationToken));

    private static string Sha256(string content) => Convert.ToHexString(
        SHA256.HashData(Utf8WithoutBom.GetBytes(content))).ToLowerInvariant();

    private static void CleanupTransaction(string transactionPath, string pendingPath)
    {
        if (File.Exists(pendingPath)) File.Delete(pendingPath);
        File.Delete(transactionPath);
    }

    private static void QuarantineFailedConfiguration(string configPath, string directory)
    {
        var backupDirectory = Path.Combine(directory, "config-backups");
        Directory.CreateDirectory(backupDirectory);
        var failedPath = Path.Combine(
            backupDirectory,
            $"failed-config.{DateTimeOffset.UtcNow:yyyyMMddTHHmmssfff}.{Guid.NewGuid():N}.json");
        File.Move(configPath, failedPath);
    }

    private static void PruneSuccessfulBackups(string backupDirectory)
    {
        var obsolete = Directory.EnumerateFiles(backupDirectory, "config.local.*.json")
            .OrderByDescending(File.GetCreationTimeUtc)
            .ThenByDescending(Path.GetFileName, StringComparer.Ordinal)
            .Skip(3);
        foreach (var path in obsolete)
        {
            try { File.Delete(path); } catch { }
        }
    }
}
