using NATS.Server.Raft;

namespace NATS.Server.Raft.Tests.Raft;

/// <summary>
/// Tests for configurable log compaction policies (Gap 8.5).
///
/// Covers <see cref="CompactionPolicy"/> enum values, <see cref="CompactionOptions"/> defaults
/// and threshold logic, and the integration between <see cref="RaftNode.CompactLogAsync"/>
/// and the policy engine.
///
/// Go reference: raft.go compactLog / WAL compact threshold checks.
/// </summary>
public class RaftCompactionPolicyTests
{
    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Creates a leader node with <paramref name="entryCount"/> proposed entries all applied.
    /// Each entry command is "cmd-N" so UTF-8 command size is predictable (~6 bytes each).
    /// </summary>
    private static async Task<RaftNode> CreateLeaderWithEntriesAsync(int entryCount,
        CompactionOptions? compactionOptions = null)
    {
        var node = new RaftNode("leader", compactionOptions: compactionOptions);
        node.ConfigureCluster([node]);
        node.StartElection(1);

        for (var i = 1; i <= entryCount; i++)
            await node.ProposeAsync($"cmd-{i}", default);

        return node;
    }

    /// <summary>
    /// Creates a log entry at an explicit index with an optional custom timestamp.
    /// Used for ByAge tests where we need to control entry timestamps.
    /// </summary>
    private static RaftLogEntry MakeEntry(long index, int term, string command, DateTime? timestamp = null)
    {
        var entry = new RaftLogEntry(index, term, command);
        if (timestamp.HasValue)
            entry = entry with { Timestamp = timestamp.Value };
        return entry;
    }

    // ---------------------------------------------------------------------------
    // CompactionOptions default values
    // ---------------------------------------------------------------------------

    // Go reference: raft.go compactLog threshold defaults.
    [Fact]
    public void CompactionOptions_default_values()
    {
        var opts = new CompactionOptions();

        opts.Policy.ShouldBe(CompactionPolicy.None);
        opts.MaxEntries.ShouldBe(10_000);
        opts.MaxSizeBytes.ShouldBe(100L * 1024 * 1024);
        opts.MaxAge.ShouldBe(TimeSpan.FromHours(1));
    }

    // ---------------------------------------------------------------------------
    // None policy — no-op
    // ---------------------------------------------------------------------------

    // Go reference: raft.go compactLog — no compaction when policy is None.
    [Fact]
    public async Task None_policy_does_not_compact()
    {
        var opts = new CompactionOptions { Policy = CompactionPolicy.None };
        var node = await CreateLeaderWithEntriesAsync(50, opts);

        node.Log.Entries.Count.ShouldBe(50);
        await node.CompactLogAsync(default);

        // With None policy, nothing should be removed.
        node.Log.Entries.Count.ShouldBe(50);
    }

    // ---------------------------------------------------------------------------
    // ByCount policy
    // ---------------------------------------------------------------------------

    // Go reference: raft.go compactLog — keep at most MaxEntries.
    [Fact]
    public async Task ByCount_compacts_when_log_exceeds_max_entries()
    {
        const int total = 100;
        const int keep = 50;

        var opts = new CompactionOptions
        {
            Policy   = CompactionPolicy.ByCount,
            MaxEntries = keep,
        };
        var node = await CreateLeaderWithEntriesAsync(total, opts);

        // Before compaction: all 100 entries are present.
        node.Log.Entries.Count.ShouldBe(total);

        await node.CompactLogAsync(default);

        // After compaction: only the newest `keep` entries remain.
        node.Log.Entries.Count.ShouldBe(keep);

        // The oldest entry's index should be total - keep + 1
        node.Log.Entries[0].Index.ShouldBe(total - keep + 1);
    }

    // Go reference: raft.go compactLog — do not compact below applied index (safety guard).
    [Fact]
    public async Task ByCount_does_not_compact_below_applied_index()
    {
        // Build a node but manually set a lower applied index so the safety guard kicks in.
        const int total = 100;
        const int keep = 50;

        var opts = new CompactionOptions
        {
            Policy    = CompactionPolicy.ByCount,
            MaxEntries = keep,
        };

        var node = await CreateLeaderWithEntriesAsync(total, opts);

        // Artificially lower the applied index so the safety guard prevents full compaction.
        // With applied = 30 the policy wants to compact to index 50 but the guard limits it to 30.
        node.AppliedIndex = 30;

        await node.CompactLogAsync(default);

        // Only entries up to index 30 may be removed.
        // BaseIndex should be 30, entries with index > 30 remain.
        node.Log.BaseIndex.ShouldBe(30);
        node.Log.Entries[0].Index.ShouldBe(31);
    }

    // Go reference: raft.go compactLog — no-op when count is within threshold.
    [Fact]
    public async Task ByCount_does_not_compact_when_within_threshold()
    {
        const int total = 20;
        const int maxEntries = 50; // more than total — nothing to compact

        var opts = new CompactionOptions
        {
            Policy    = CompactionPolicy.ByCount,
            MaxEntries = maxEntries,
        };
        var node = await CreateLeaderWithEntriesAsync(total, opts);

        await node.CompactLogAsync(default);

        node.Log.Entries.Count.ShouldBe(total);
    }

    // ---------------------------------------------------------------------------
    // BySize policy
    // ---------------------------------------------------------------------------

    // Go reference: raft.go compactLog — compact oldest entries until under size threshold.
    [Fact]
    public void BySize_compacts_when_total_size_exceeds_threshold()
    {
        // Construct a log manually so we control sizes precisely.
        // Each entry: index(8) + term(4) + command(N bytes).
        // Use a 100-byte command to make size maths easy.
        var log = new RaftLog();
        var largeCommand = new string('x', 100); // 100 UTF-8 bytes

        // Append 10 entries — each ~112 bytes → total ~1,120 bytes.
        for (var i = 1; i <= 10; i++)
            log.Append(term: 1, command: largeCommand);

        // Set threshold at ~600 bytes — roughly half the total — so the oldest ~5 entries
        // should be compacted.
        var opts = new CompactionOptions
        {
            Policy       = CompactionPolicy.BySize,
            MaxSizeBytes = 600,
        };

        // appliedIndex = last entry index (all applied)
        var appliedIndex = log.Entries[^1].Index;
        var cutoff = opts.ComputeCompactionIndex(log, appliedIndex);

        // Should return a positive index, meaning some entries must be compacted.
        cutoff.ShouldBeGreaterThan(0);
        cutoff.ShouldBeLessThanOrEqualTo(appliedIndex);

        log.Compact(cutoff);

        // After compaction fewer entries should remain.
        log.Entries.Count.ShouldBeLessThan(10);
        log.Entries.Count.ShouldBeGreaterThan(0);
    }

    // Go reference: raft.go compactLog — no-op when total size is under threshold.
    [Fact]
    public void BySize_does_not_compact_when_under_threshold()
    {
        var log = new RaftLog();
        for (var i = 1; i <= 5; i++)
            log.Append(term: 1, command: "tiny");

        // Threshold large enough to fit everything.
        var opts = new CompactionOptions
        {
            Policy       = CompactionPolicy.BySize,
            MaxSizeBytes = 10_000,
        };

        var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index);

        cutoff.ShouldBe(-1); // no compaction
    }

    // ---------------------------------------------------------------------------
    // ByAge policy
    // ---------------------------------------------------------------------------

    // Go reference: raft.go compactLog — compact entries older than MaxAge.
    [Fact]
    public void ByAge_compacts_old_entries()
    {
        // Build a log manually with some old and some fresh entries.
        var log = new RaftLog();
        var old = DateTime.UtcNow - TimeSpan.FromHours(2);
        var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(1);

        // Entries 1–5: old (2 hours ago)
        for (var i = 1; i <= 5; i++)
            log.AppendWithTimestamp(term: 1, command: $"old-{i}", timestamp: old);

        // Entries 6–10: fresh (1 minute ago)
        for (var i = 6; i <= 10; i++)
            log.AppendWithTimestamp(term: 1, command: $"fresh-{i}", timestamp: fresh);

        var opts = new CompactionOptions
        {
            Policy = CompactionPolicy.ByAge,
            MaxAge = TimeSpan.FromHours(1), // default; entries older than 1h are eligible
        };

        var appliedIndex = log.Entries[^1].Index;
        var cutoff = opts.ComputeCompactionIndex(log, appliedIndex);

        // Should compact through index 5 (the last old entry).
        cutoff.ShouldBe(5);
        log.Compact(cutoff);

        log.Entries.Count.ShouldBe(5); // only the fresh entries remain
        log.Entries[0].Index.ShouldBe(6);
    }

    // Go reference: raft.go compactLog — no-op when all entries are still fresh.
    [Fact]
    public void ByAge_does_not_compact_fresh_entries()
    {
        var log = new RaftLog();
        var fresh = DateTime.UtcNow - TimeSpan.FromMinutes(5);

        for (var i = 1; i <= 10; i++)
            log.AppendWithTimestamp(term: 1, command: $"cmd-{i}", timestamp: fresh);

        var opts = new CompactionOptions
        {
            Policy = CompactionPolicy.ByAge,
            MaxAge = TimeSpan.FromHours(1),
        };

        var cutoff = opts.ComputeCompactionIndex(log, log.Entries[^1].Index);

        cutoff.ShouldBe(-1); // nothing old enough
    }

    // ---------------------------------------------------------------------------
    // CompactLogAsync integration tests
    // ---------------------------------------------------------------------------

    // Go reference: raft.go CompactLog — safety guard: never exceed applied index.
    [Fact]
    public async Task CompactLogAsync_with_policy_respects_applied_index()
    {
        // Node with 50 entries. Policy wants to compact to index 40, but applied is only 25.
        const int total = 50;
        var opts = new CompactionOptions
        {
            Policy    = CompactionPolicy.ByCount,
            MaxEntries = 10, // wants to compact to index 40
        };

        var node = await CreateLeaderWithEntriesAsync(total, opts);

        // Override applied index to a lower value than the policy cutoff.
        node.AppliedIndex = 25;

        await node.CompactLogAsync(default);

        // Compaction must not go past applied index = 25.
        node.Log.BaseIndex.ShouldBeLessThanOrEqualTo(25);

        // All entries above index 25 must still be present.
        node.Log.Entries.ShouldAllBe(e => e.Index > 25);
    }

    // Go reference: raft.go CompactLog — when no policy is set, compacts to applied index.
    [Fact]
    public async Task CompactLogAsync_without_policy_compacts_to_applied_index()
    {
        var node = await CreateLeaderWithEntriesAsync(30);
        // No CompactionOptions set.

        await node.CompactLogAsync(default);

        // All entries compacted to the applied index.
        node.Log.Entries.Count.ShouldBe(0);
        node.Log.BaseIndex.ShouldBe(node.AppliedIndex);
    }

    // Go reference: raft.go CompactLog — per-call options override node-level options.
    [Fact]
    public async Task CompactLogAsync_call_level_options_override_node_level_options()
    {
        // Node has None policy (no auto-compaction).
        var nodeOpts = new CompactionOptions { Policy = CompactionPolicy.None };
        var node = await CreateLeaderWithEntriesAsync(50, nodeOpts);

        // But the call-level override uses ByCount with MaxEntries = 25.
        var callOpts = new CompactionOptions
        {
            Policy    = CompactionPolicy.ByCount,
            MaxEntries = 25,
        };

        await node.CompactLogAsync(default, callOpts);

        // Call-level option must win: 25 entries kept.
        node.Log.Entries.Count.ShouldBe(25);
    }
}
