using LiteDB;

namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;

/// <summary>
///     Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> decision #148 and Phase 6.1
///     Stream D.1. Each published generation writes one <b>read-only</b> LiteDB file under
///     <c>&lt;cache-root&gt;/&lt;clusterId&gt;/&lt;generationId&gt;.db</c>. A per-cluster
///     <c>CURRENT</c> text file holds the currently-active generation id; it is updated
///     atomically (temp file + <see cref="File.Replace(string, string, string?)"/>) only after
///     the sealed file is fully written.
/// </summary>
/// <remarks>
///     <para>Mixed-generation reads are impossible: any read opens the single file pointed to
///     by <c>CURRENT</c>, which is a coherent snapshot. Corruption of the CURRENT file or the
///     sealed file surfaces as <see cref="GenerationCacheUnavailableException"/> — the reader
///     fails closed rather than silently falling back to an older generation. Recovery path
///     is to re-fetch from the central DB (and the Phase 6.1 Stream C <c>UsingStaleConfig</c>
///     flag goes true until that succeeds).</para>
///
///     <para>This cache is the read-path fallback when the central DB is unreachable. The
///     write path (draft edits, publish) bypasses the cache and fails hard on DB outage per
///     Stream D.2 — inconsistent writes are worse than a temporary inability to edit.</para>
/// </remarks>
public sealed class GenerationSealedCache
{
    private const string CollectionName = "generation";
    private const string CurrentPointerFileName = "CURRENT";
    private readonly string _cacheRoot;

    /// <summary>Root directory for all clusters' sealed caches.</summary>
    public string CacheRoot => _cacheRoot;

    /// <summary>Initializes a new instance of the GenerationSealedCache class.</summary>
    /// <param name="cacheRoot">The root directory for the cache.</param>
    public GenerationSealedCache(string cacheRoot)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(cacheRoot);
        _cacheRoot = cacheRoot;
        Directory.CreateDirectory(_cacheRoot);
    }

    /// <summary>
    ///     Seal a generation: write the snapshot to <c>&lt;cluster&gt;/&lt;generationId&gt;.db</c>,
    ///     mark the file read-only, then atomically publish the <c>CURRENT</c> pointer. Existing
    ///     sealed files for prior generations are preserved (prune separately).
    /// </summary>
    /// <param name="snapshot">The generation snapshot to seal.</param>
    /// <param name="ct">The cancellation token.</param>
    /// <returns>A task representing the asynchronous operation.</returns>
    public async Task SealAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(snapshot);
        ct.ThrowIfCancellationRequested();

        var clusterDir = Path.Combine(_cacheRoot, snapshot.ClusterId);
        Directory.CreateDirectory(clusterDir);
        var sealedPath = Path.Combine(clusterDir, $"{snapshot.GenerationId}.db");

        if (File.Exists(sealedPath))
        {
            // Already sealed — idempotent. Treat as no-op + update pointer in case an earlier
            // seal succeeded but the pointer update failed (crash recovery).
            WritePointerAtomically(clusterDir, snapshot.GenerationId);
            return;
        }

        var tmpPath = sealedPath + ".tmp";
        try
        {
            using (var db = new LiteDatabase(new ConnectionString { Filename = tmpPath, Upgrade = false }))
            {
                var col = db.GetCollection<GenerationSnapshot>(CollectionName);
                col.Insert(snapshot);
            }

            File.Move(tmpPath, sealedPath);
            File.SetAttributes(sealedPath, File.GetAttributes(sealedPath) | FileAttributes.ReadOnly);
            WritePointerAtomically(clusterDir, snapshot.GenerationId);
        }
        catch
        {
            try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* best-effort */ }
            throw;
        }

        await Task.CompletedTask;
    }

    /// <summary>
    ///     Read the current sealed snapshot for <paramref name="clusterId"/>. Throws
    ///     <see cref="GenerationCacheUnavailableException"/> when the pointer is missing
    ///     (first-boot-no-snapshot case) or when the sealed file is corrupt. Never silently
    ///     falls back to a prior generation.
    /// </summary>
    /// <param name="clusterId">The cluster ID to read the snapshot for.</param>
    /// <param name="ct">The cancellation token.</param>
    /// <returns>A task representing the asynchronous operation containing the generation snapshot.</returns>
    public Task<GenerationSnapshot> ReadCurrentAsync(string clusterId, CancellationToken ct = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
        ct.ThrowIfCancellationRequested();

        var clusterDir = Path.Combine(_cacheRoot, clusterId);
        var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
        if (!File.Exists(pointerPath))
            throw new GenerationCacheUnavailableException(
                $"No sealed generation for cluster '{clusterId}' at '{clusterDir}'. First-boot case: the central DB must be reachable at least once before cache fallback is possible.");

        long generationId;
        try
        {
            var text = File.ReadAllText(pointerPath).Trim();
            generationId = long.Parse(text, System.Globalization.CultureInfo.InvariantCulture);
        }
        catch (Exception ex)
        {
            throw new GenerationCacheUnavailableException(
                $"CURRENT pointer at '{pointerPath}' is corrupt or unreadable.", ex);
        }

        var sealedPath = Path.Combine(clusterDir, $"{generationId}.db");
        if (!File.Exists(sealedPath))
            throw new GenerationCacheUnavailableException(
                $"CURRENT points at generation {generationId} but '{sealedPath}' is missing — fails closed rather than serving an older generation.");

        try
        {
            using var db = new LiteDatabase(new ConnectionString { Filename = sealedPath, ReadOnly = true });
            var col = db.GetCollection<GenerationSnapshot>(CollectionName);
            var snapshot = col.FindAll().FirstOrDefault()
                ?? throw new GenerationCacheUnavailableException(
                    $"Sealed file '{sealedPath}' contains no snapshot row — file is corrupt.");
            return Task.FromResult(snapshot);
        }
        catch (GenerationCacheUnavailableException) { throw; }
        catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
                                      or NotSupportedException or FormatException)
        {
            throw new GenerationCacheUnavailableException(
                $"Sealed file '{sealedPath}' is corrupt or unreadable — fails closed rather than falling back to an older generation.", ex);
        }
    }

    /// <summary>Return the generation id the <c>CURRENT</c> pointer points at, or null if no pointer exists.</summary>
    /// <param name="clusterId">The cluster ID to get the current generation ID for.</param>
    /// <returns>The generation ID, or null if no pointer exists.</returns>
    public long? TryGetCurrentGenerationId(string clusterId)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
        var pointerPath = Path.Combine(_cacheRoot, clusterId, CurrentPointerFileName);
        if (!File.Exists(pointerPath)) return null;
        try
        {
            return long.Parse(File.ReadAllText(pointerPath).Trim(), System.Globalization.CultureInfo.InvariantCulture);
        }
        catch
        {
            return null;
        }
    }

    private static void WritePointerAtomically(string clusterDir, long generationId)
    {
        var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
        var tmpPath = pointerPath + ".tmp";
        File.WriteAllText(tmpPath, generationId.ToString(System.Globalization.CultureInfo.InvariantCulture));
        if (File.Exists(pointerPath))
            File.Replace(tmpPath, pointerPath, destinationBackupFileName: null);
        else
            File.Move(tmpPath, pointerPath);
    }
}

/// <summary>Sealed cache is unreachable — caller must fail closed.</summary>
public sealed class GenerationCacheUnavailableException : Exception
{
    /// <summary>Initializes a new instance of the GenerationCacheUnavailableException class.</summary>
    /// <param name="message">The error message.</param>
    public GenerationCacheUnavailableException(string message) : base(message) { }
    /// <summary>Initializes a new instance of the GenerationCacheUnavailableException class with an inner exception.</summary>
    /// <param name="message">The error message.</param>
    /// <param name="inner">The inner exception.</param>
    public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { }
}
