using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Configuration;

/// <summary>
/// Bundle D (M5-T8) tests for hot-reloadable <see cref="AuditLogOptions"/>
/// binding. The first test pins the JSON-realistic binding shape end-to-end
/// (scalars, lists, per-target overrides) so accidental drift in the section
/// layout breaks the build. The second test exercises the live hot-reload
/// path: a <see cref="ScadaBridgeAuditRedactor"/> backed by a mutable
/// <see cref="IOptionsMonitor{TOptions}"/> must respond to config changes on
/// the very next event, with both cap-bytes and the regex-cache invalidation
/// flowing through without a restart.
/// </summary>
/// <remarks>
/// Distinct from <see cref="AuditLogOptionsTests"/> (M1-T9) which covered
/// section binding + validator failures via single-key in-memory config — those
/// tests exist; these add (a) end-to-end binding from a realistic JSON literal
/// and (b) the hot-reload behavioural contract the M5-T8 spec calls out.
/// </remarks>
public class AuditLogOptionsBindingTests
{
    [Fact]
    public void AuditLog_Section_Binds_AllFields()
    {
        const string json = """
            {
              "AuditLog": {
                "DefaultCapBytes": 4096,
                "ErrorCapBytes": 32768,
                "HeaderRedactList": ["Authorization", "Custom-Token"],
                "GlobalBodyRedactors": ["\"password\":\\s*\"[^\"]*\""],
                "PerTargetOverrides": {
                  "myconnection": {
                    "CapBytes": 16384,
                    "AdditionalBodyRedactors": [],
                    "RedactSqlParamsMatching": "@token|@secret"
                  }
                },
                "RetentionDays": 180,
                "InboundMaxBytes": 524288
              }
            }
            """;

        using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
        var configuration = new ConfigurationBuilder()
            .AddJsonStream(stream)
            .Build();
        var services = new ServiceCollection();
        services.AddAuditLog(configuration);
        using var provider = services.BuildServiceProvider();

        var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>().Value;

        // Scalars.
        Assert.Equal(4096, opts.DefaultCapBytes);
        Assert.Equal(32768, opts.ErrorCapBytes);
        Assert.Equal(180, opts.RetentionDays);
        Assert.Equal(524_288, opts.InboundMaxBytes);

        // HeaderRedactList: the Microsoft.Extensions.Configuration list binder
        // APPENDS to the default list, so we assert containment rather than
        // exact equality (see M1-T9 AuditLogOptionsTests for the rationale).
        Assert.Contains("Authorization", opts.HeaderRedactList);
        Assert.Contains("Custom-Token", opts.HeaderRedactList);

        // GlobalBodyRedactors: pattern arrived intact, regex-escape sequences
        // and all.
        Assert.Contains("\"password\":\\s*\"[^\"]*\"", opts.GlobalBodyRedactors);

        // PerTargetOverrides: keyed by connection name, each field bound.
        Assert.True(opts.PerTargetOverrides.ContainsKey("myconnection"));
        var ov = opts.PerTargetOverrides["myconnection"];
        Assert.Equal(16384, ov.CapBytes);
        // Microsoft.Extensions.Configuration JSON binder leaves an empty array
        // null on a nullable List<T>; either null or empty is acceptable as
        // "no additional redactors" — both result in zero patterns at use.
        Assert.True(ov.AdditionalBodyRedactors is null || ov.AdditionalBodyRedactors.Count == 0);
        Assert.Equal("@token|@secret", ov.RedactSqlParamsMatching);
    }

    [Fact]
    public void PurgeOptions_Bind_FromDocumentedSectionAndKeys()
    {
        // AuditLog-013: the design doc (Component-AuditLog.md §Configuration)
        // documents the purge tuning as the nested `AuditLog:Purge` section with
        // keys `IntervalHours` + `ChannelPurgeBatchSize`. This test pins that the
        // code binds from EXACTLY that shape — the section path the production
        // code uses (ServiceCollectionExtensions.PurgeSectionName) AND the
        // documented `ChannelPurgeBatchSize` key (mapped onto the
        // ChannelPurgeBatchSizeConfigured backing property via
        // [ConfigurationKeyName]). It would fail against the pre-fix code, where
        // the binder looked for `ChannelPurgeBatchSizeConfigured` and silently
        // ignored the documented key.
        const string json = """
            {
              "AuditLog": {
                "Purge": {
                  "IntervalHours": 6,
                  "ChannelPurgeBatchSize": 1000
                }
              }
            }
            """;

        using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
        var configuration = new ConfigurationBuilder()
            .AddJsonStream(stream)
            .Build();

        // Section path matches production (PurgeSectionName == "AuditLog:Purge").
        Assert.Equal("AuditLog:Purge", ServiceCollectionExtensions.PurgeSectionName);

        var services = new ServiceCollection();
        services.AddOptions<AuditLogPurgeOptions>()
            .Bind(configuration.GetSection(ServiceCollectionExtensions.PurgeSectionName));
        using var provider = services.BuildServiceProvider();

        var opts = provider.GetRequiredService<IOptions<AuditLogPurgeOptions>>().Value;

        // IntervalHours bound from the nested section (not the 24 h default).
        Assert.Equal(6, opts.IntervalHours);
        Assert.Equal(TimeSpan.FromHours(6), opts.Interval);

        // ChannelPurgeBatchSize bound via the documented key onto the backing
        // property (not the 5000 default).
        Assert.Equal(1000, opts.ChannelPurgeBatchSizeConfigured);
        Assert.Equal(1000, opts.ChannelPurgeBatchSize);
    }

    [Fact]
    public void Filter_Behavior_Updates_OnConfigReload()
    {
        // Start at the default cap (4096). A 5 KB body should be truncated;
        // PayloadTruncated flips to true.
        var initial = new AuditLogOptions { DefaultCapBytes = 4096 };
        var monitor = new TestOptionsMonitor<AuditLogOptions>(initial);
        var filter = new ScadaBridgeAuditRedactor(
            monitor,
            NullLogger<ScadaBridgeAuditRedactor>.Instance);

        var body = new string('x', 5 * 1024);
        var evt = ScadaBridgeAuditEventFactory.Create(
            eventId: Guid.NewGuid(),
            occurredAtUtc: DateTime.UtcNow,
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            requestSummary: body);

        var resultBefore = filter.Apply(evt);
        Assert.True(resultBefore.AsRow().PayloadTruncated, "5KB body at 4096 cap must be truncated");
        Assert.NotNull(resultBefore.AsRow().RequestSummary);
        Assert.True(Encoding.UTF8.GetByteCount(resultBefore.AsRow().RequestSummary!) <= 4096);

        // Reload: cap raised to 16384 — next event must NOT truncate. This is
        // the M5-T8 contract: the filter sees the new value on the very next
        // Apply, without process restart.
        monitor.Set(new AuditLogOptions { DefaultCapBytes = 16384 });

        var resultAfter = filter.Apply(evt);
        Assert.False(resultAfter.AsRow().PayloadTruncated, "5KB body at 16384 cap must NOT be truncated");
        Assert.Equal(body, resultAfter.AsRow().RequestSummary);
    }

    [Fact]
    public void Filter_PicksUp_NewBodyRedactor_OnConfigReload()
    {
        // The regex cache is keyed by pattern string — a redactor added via
        // config reload must compile + apply on the very next event without a
        // process restart. Pre-reload: no redactor, hunter2 survives. After
        // reload: hunter2 redacted.
        var monitor = new TestOptionsMonitor<AuditLogOptions>(new AuditLogOptions());
        var filter = new ScadaBridgeAuditRedactor(
            monitor,
            NullLogger<ScadaBridgeAuditRedactor>.Instance);

        const string body = "{\"user\":\"alice\",\"password\":\"hunter2\"}";
        var evt = ScadaBridgeAuditEventFactory.Create(
            eventId: Guid.NewGuid(),
            occurredAtUtc: DateTime.UtcNow,
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            requestSummary: body);

        var before = filter.Apply(evt);
        Assert.Contains("hunter2", before.AsRow().RequestSummary!);

        monitor.Set(new AuditLogOptions
        {
            GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
        });

        var after = filter.Apply(evt);
        Assert.DoesNotContain("hunter2", after.AsRow().RequestSummary!);
        Assert.Contains("<redacted>", after.AsRow().RequestSummary!);
    }

    /// <summary>
    /// IOptionsMonitor test double — exposes a <see cref="Set"/> method that
    /// updates the current value and fires registered OnChange callbacks.
    /// Avoids depending on Microsoft.Extensions.Configuration's reload-token
    /// plumbing, which is awkward to drive deterministically from xUnit.
    /// </summary>
    private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
    {
        private T _current;
        private readonly List<Action<T, string?>> _listeners = new();

        public TestOptionsMonitor(T initial) => _current = initial;

        public T CurrentValue => _current;

        public T Get(string? name) => _current;

        public IDisposable? OnChange(Action<T, string?> listener)
        {
            lock (_listeners)
            {
                _listeners.Add(listener);
            }
            return new Unsubscribe(_listeners, listener);
        }

        public void Set(T value)
        {
            _current = value;
            Action<T, string?>[] snapshot;
            lock (_listeners)
            {
                snapshot = _listeners.ToArray();
            }
            foreach (var l in snapshot)
            {
                l(_current, Options.DefaultName);
            }
        }

        private sealed class Unsubscribe : IDisposable
        {
            private readonly List<Action<T, string?>> _listeners;
            private readonly Action<T, string?> _listener;
            public Unsubscribe(List<Action<T, string?>> listeners, Action<T, string?> listener)
            {
                _listeners = listeners;
                _listener = listener;
            }
            public void Dispose()
            {
                lock (_listeners)
                {
                    _listeners.Remove(_listener);
                }
            }
        }
    }
}
