using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Polly.Timeout;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;

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

[Trait("Category", "Unit")]
public sealed class ResilientConfigReaderTests : IDisposable
{
    private readonly string _root = Path.Combine(Path.GetTempPath(), $"otopcua-reader-{Guid.NewGuid():N}");

    /// <summary>Disposes temporary test files.</summary>
    public void Dispose()
    {
        try
        {
            if (!Directory.Exists(_root)) return;
            foreach (var f in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
                File.SetAttributes(f, FileAttributes.Normal);
            Directory.Delete(_root, recursive: true);
        }
        catch { /* best-effort */ }
    }

    /// <summary>Verifies that successful central DB reads return value and mark fresh.</summary>
    [Fact]
    public async Task CentralDbSucceeds_ReturnsValue_MarksFresh()
    {
        var cache = new GenerationSealedCache(_root);
        var flag = new StaleConfigFlag { };
        flag.MarkStale(); // pre-existing stale state
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance);

        var result = await reader.ReadAsync(
            "cluster-a",
            _ => ValueTask.FromResult("fresh-from-db"),
            _ => "from-cache",
            CancellationToken.None);

        result.ShouldBe("fresh-from-db");
        flag.IsStale.ShouldBeFalse("successful central-DB read clears stale flag");
    }

    /// <summary>Verifies that exhausted retries fall back to cache and mark stale.</summary>
    [Fact]
    public async Task CentralDbFails_ExhaustsRetries_FallsBackToCache_MarksStale()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(new GenerationSnapshot
        {
            ClusterId = "cluster-a", GenerationId = 99, CachedAt = DateTime.UtcNow,
            PayloadJson = "{\"cached\":true}",
        });
        var flag = new StaleConfigFlag();
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromSeconds(10), retryCount: 2);
        var attempts = 0;

        var result = await reader.ReadAsync(
            "cluster-a",
            _ =>
            {
                attempts++;
                throw new InvalidOperationException("SQL dead");
#pragma warning disable CS0162
                return ValueTask.FromResult("never");
#pragma warning restore CS0162
            },
            snap => snap.PayloadJson,
            CancellationToken.None);

        attempts.ShouldBe(3, "1 initial + 2 retries = 3 attempts");
        result.ShouldBe("{\"cached\":true}");
        flag.IsStale.ShouldBeTrue("cache fallback flips stale flag true");
    }

    /// <summary>Verifies that DB failure with unavailable cache throws.</summary>
    [Fact]
    public async Task CentralDbFails_AndCacheAlsoUnavailable_Throws()
    {
        var cache = new GenerationSealedCache(_root);
        var flag = new StaleConfigFlag();
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromSeconds(10), retryCount: 0);

        await Should.ThrowAsync<GenerationCacheUnavailableException>(async () =>
        {
            await reader.ReadAsync<string>(
                "cluster-a",
                _ => throw new InvalidOperationException("SQL dead"),
                _ => "never",
                CancellationToken.None);
        });

        flag.IsStale.ShouldBeFalse("no snapshot ever served, so flag stays whatever it was");
    }

    /// <summary>Verifies that cancellation is not retried.</summary>
    [Fact]
    public async Task Cancellation_NotRetried()
    {
        var cache = new GenerationSealedCache(_root);
        var flag = new StaleConfigFlag();
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromSeconds(10), retryCount: 5);
        using var cts = new CancellationTokenSource();
        cts.Cancel();
        var attempts = 0;

        await Should.ThrowAsync<OperationCanceledException>(async () =>
        {
            await reader.ReadAsync<string>(
                "cluster-a",
                ct =>
                {
                    attempts++;
                    ct.ThrowIfCancellationRequested();
                    return ValueTask.FromResult("ok");
                },
                _ => "cache",
                cts.Token);
        });

        attempts.ShouldBeLessThanOrEqualTo(1);
    }

    // ------------------------------------------------------------------------------------
    // Configuration-006 — command-timeout TaskCanceledException and TimeoutRejectedException
    // must fall back to the sealed cache, not propagate as caller cancellation.
    // ------------------------------------------------------------------------------------

    /// <summary>Verifies that command timeout TaskCanceledException falls back to cache.</summary>
    [Fact]
    public async Task CommandTimeout_TaskCanceledException_FallsBackToCache()
    {
        // A SQL command-level timeout surfaces as a TaskCanceledException thrown by the
        // delegate itself (not triggered by the caller's CancellationToken). It must be
        // treated as a transient failure and trigger the cache fallback, not be mistaken
        // for genuine caller cancellation and propagated.
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(new GenerationSnapshot
        {
            ClusterId = "cluster-b", GenerationId = 7, CachedAt = DateTime.UtcNow,
            PayloadJson = "{\"from\":\"cache\"}",
        });
        var flag = new StaleConfigFlag();
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromSeconds(10), retryCount: 0);

        // Simulate a command-level timeout: TaskCanceledException with no linked token.
        var result = await reader.ReadAsync(
            "cluster-b",
            _ => throw new TaskCanceledException("SQL command timeout (no caller token)"),
            snap => snap.PayloadJson,
            CancellationToken.None); // caller token is NOT cancelled

        result.ShouldBe("{\"from\":\"cache\"}",
            "command-timeout TaskCanceledException must fall back to sealed cache");
        flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
    }

    /// <summary>Verifies that Polly timeout rejection falls back to cache.</summary>
    [Fact]
    public async Task PollyTimeout_TimeoutRejectedException_FallsBackToCache()
    {
        // When Polly's own timeout strategy fires it throws TimeoutRejectedException.
        // That should trigger the cache fallback just like any other transient error.
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(new GenerationSnapshot
        {
            ClusterId = "cluster-c", GenerationId = 8, CachedAt = DateTime.UtcNow,
            PayloadJson = "{\"from\":\"polly-timeout-cache\"}",
        });
        var flag = new StaleConfigFlag();
        // Set an extremely short Polly timeout so the async delay triggers it.
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromMilliseconds(10), retryCount: 0);

        var result = await reader.ReadAsync(
            "cluster-c",
            async ct =>
            {
                await Task.Delay(TimeSpan.FromSeconds(5), ct); // far exceeds 10 ms timeout
                return "never";
            },
            snap => snap.PayloadJson,
            CancellationToken.None);

        result.ShouldBe("{\"from\":\"polly-timeout-cache\"}",
            "Polly TimeoutRejectedException must fall back to sealed cache");
        flag.IsStale.ShouldBeTrue("cache fallback marks the stale flag");
    }

    // ------------------------------------------------------------------------------------
    // Configuration-010 — fallback warning log must scrub connection-string fragments and
    // must not include the full exception object (which carries the stack and any inner-
    // exception chain). Project rule: no credential or connection-string fragment in logs.
    // ------------------------------------------------------------------------------------

    /// <summary>Verifies that fallback warnings do not log exceptions or password fragments.</summary>
    [Fact]
    public async Task FallbackWarning_does_not_log_full_exception_object_or_password_fragment()
    {
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(new GenerationSnapshot
        {
            ClusterId = "cluster-e", GenerationId = 1, CachedAt = DateTime.UtcNow,
            PayloadJson = "{\"ok\":true}",
        });
        var flag = new StaleConfigFlag();
        var capturing = new CapturingLogger<ResilientConfigReader>();
        var reader = new ResilientConfigReader(cache, flag, capturing,
            timeout: TimeSpan.FromSeconds(10), retryCount: 0);

        // Simulated SqlException-style message carrying a connection-string fragment, the
        // kind of thing a poorly-wrapped delegate could surface.
        const string secretBearingMessage =
            "Login failed for user 'sa'. (Server=sql.example.com,1433;User Id=sa;Password=SuperSecret123!)";

        await reader.ReadAsync(
            "cluster-e",
            _ => throw new InvalidOperationException(secretBearingMessage),
            snap => snap.PayloadJson,
            CancellationToken.None);

        var warning = capturing.Records.ShouldHaveSingleItem();
        warning.LogLevel.ShouldBe(LogLevel.Warning);

        // The exception object passed as the first arg to LogWarning(ex, ...) drives the
        // formatter's stack-trace dump; capturing it lets us assert the scrubbing surface.
        warning.Exception.ShouldBeNull(
            "the warning must not attach the raw exception — it can carry connection-string fragments");

        // The rendered message must not echo password / user-id strings even if the caller
        // embedded them in the exception message.
        warning.RenderedMessage.ShouldNotContain("Password=", Case.Insensitive);
        warning.RenderedMessage.ShouldNotContain("SuperSecret123!");
        warning.RenderedMessage.ShouldNotContain("User Id=", Case.Insensitive);
    }

    /// <summary>Verifies that caller cancellation propagates rather than falling back.</summary>
    [Fact]
    public async Task CallerCancellation_Propagates_NotFallback()
    {
        // Explicit caller cancellation must NOT fall back to the sealed cache — the
        // caller said stop, so we must stop.
        var cache = new GenerationSealedCache(_root);
        await cache.SealAsync(new GenerationSnapshot
        {
            ClusterId = "cluster-d", GenerationId = 9, CachedAt = DateTime.UtcNow,
            PayloadJson = "{\"should\":\"not be returned\"}",
        });
        var flag = new StaleConfigFlag();
        var reader = new ResilientConfigReader(cache, flag, NullLogger<ResilientConfigReader>.Instance,
            timeout: TimeSpan.FromSeconds(10), retryCount: 0);
        using var cts = new CancellationTokenSource();
        cts.Cancel();

        await Should.ThrowAsync<OperationCanceledException>(async () =>
        {
            await reader.ReadAsync<string>(
                "cluster-d",
                ct =>
                {
                    ct.ThrowIfCancellationRequested();
                    return ValueTask.FromResult("ok");
                },
                _ => "cache-should-not-be-used",
                cts.Token);
        });

        flag.IsStale.ShouldBeFalse("no cache snapshot served on genuine cancellation");
    }
}

/// <summary>Represents a captured log record for testing.</summary>
internal sealed record LogRecord(LogLevel LogLevel, string RenderedMessage, Exception? Exception);

/// <summary>Captures log records for assertion in tests.</summary>
internal sealed class CapturingLogger<T> : ILogger<T>
{
    /// <summary>Gets the list of captured log records.</summary>
    public List<LogRecord> Records { get; } = new();

    /// <summary>Begins a scope (no-op for testing).</summary>
    /// <typeparam name="TState">The type of the scope state.</typeparam>
    /// <param name="state">The scope state.</param>
    /// <returns>A disposable scope handle.</returns>
    public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

    /// <summary>Returns true to enable all log levels.</summary>
    /// <param name="logLevel">The log level to check.</param>
    /// <returns>True to indicate the log level is enabled.</returns>
    public bool IsEnabled(LogLevel logLevel) => true;

    /// <summary>Logs a message by capturing it.</summary>
    /// <typeparam name="TState">The type of the log state.</typeparam>
    /// <param name="logLevel">The log level.</param>
    /// <param name="eventId">The event identifier.</param>
    /// <param name="state">The log state.</param>
    /// <param name="exception">The exception, if any.</param>
    /// <param name="formatter">Function to format the log message.</param>
    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        Records.Add(new LogRecord(logLevel, formatter(state, exception), exception));
    }

    /// <summary>No-op scope for testing.</summary>
    private sealed class NullScope : IDisposable
    {
        /// <summary>Gets the singleton instance.</summary>
        public static readonly NullScope Instance = new();

        /// <summary>Disposes the scope (no-op).</summary>
        public void Dispose() { }
    }
}

[Trait("Category", "Unit")]
public sealed class StaleConfigFlagTests
{
    /// <summary>Verifies that default state is fresh.</summary>
    [Fact]
    public void Default_IsFresh()
    {
        new StaleConfigFlag().IsStale.ShouldBeFalse();
    }

    /// <summary>Verifies that stale and fresh states toggle correctly.</summary>
    [Fact]
    public void MarkStale_ThenFresh_Toggles()
    {
        var flag = new StaleConfigFlag();
        flag.MarkStale();
        flag.IsStale.ShouldBeTrue();
        flag.MarkFresh();
        flag.IsStale.ShouldBeFalse();
    }

    /// <summary>Verifies that concurrent writes converge to the final state.</summary>
    [Fact]
    public void ConcurrentWrites_Converge()
    {
        var flag = new StaleConfigFlag();
        Parallel.For(0, 1000, i =>
        {
            if (i % 2 == 0) flag.MarkStale(); else flag.MarkFresh();
        });
        flag.MarkFresh();
        flag.IsStale.ShouldBeFalse();
    }
}
