using System.Collections.Concurrent;
using System.Threading.Channels;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Cache;
using Mbproxy.Proxy.Multiplexing;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Options;
using PolicyFactory = Mbproxy.Proxy.Supervision.PolicyFactory;

namespace Mbproxy.Configuration;

/// <summary>
/// Subscribes to <see cref="IOptionsMonitor{TOptions}.OnChange"/> and reconciles the
/// running set of <see cref="PlcListenerSupervisor"/> instances against the new
/// <see cref="MbproxyOptions"/> snapshot.
///
/// <para><b>Threading model</b>:
/// <list type="bullet">
///   <item>The <c>OnChange</c> callback is not allowed to block. It enqueues a
///         sentinel to a <see cref="Channel{T}"/> and returns immediately.</item>
///   <item>A dedicated background loop drains the channel, debounces rapid saves
///         (250 ms quiescent window), and then calls <see cref="ApplyAsync"/>.</item>
///   <item><see cref="ApplyAsync"/> is guarded by a <see cref="SemaphoreSlim(1,1)"/>
///         so concurrent reloads are serialised — the second change waits until the
///         first apply finishes. The last change wins.</item>
/// </list>
/// </para>
///
/// <para><b>Debounce rationale</b>: text editors on Windows commonly write via a
/// rename-and-replace pattern, which triggers 2–3 <c>FileSystemWatcher</c> events for
/// a single save. Without debouncing, the reconciler would run 2–3 times per save and
/// see intermediate half-written files. 250 ms covers every editor pattern observed in
/// practice while adding imperceptible latency for operators.</para>
///
/// <para><b>Partial-apply on error</b>: if one step of the apply sequence throws, the
/// exception is logged at Error and execution continues with the remaining steps. The
/// validator should have caught most preconditions; a runtime exception here is a true
/// bug worth surfacing. The host stays up regardless.</para>
/// </summary>
internal sealed partial class ConfigReconciler : IDisposable
{
    // Dependencies
    private readonly IOptionsMonitor<MbproxyOptions> _monitor;
    private readonly ILoggerFactory _loggerFactory;
    private readonly ILogger<ConfigReconciler> _logger;
    private readonly ServiceCounters _serviceCounters;
    private readonly Proxy.TagCaptureRegistry _captureRegistry;

    // The supervisor dictionary is set by ProxyWorker after initial startup.
    // ConcurrentDictionary so the per-PLC Add/Remove/Restart task continuations inside
    // ApplyUnderLockAsync can mutate it concurrently. The outer Apply is serialised by
    // _applySemaphore but the inner Task.WhenAll runs in parallel.
    private ConcurrentDictionary<string, PlcListenerSupervisor>? _supervisors;
    private MbproxyOptions? _currentOptions;

    // Live accessor for ReadCoalescingOptions, threaded through Attach so PLCs added or
    // restarted via hot-reload honour the current
    // `Mbproxy.Resilience.ReadCoalescing.{Enabled,MaxParties}` values. Without this,
    // reconciler-built supervisors would use the default `new ReadCoalescingOptions()`
    // and a hot-reload of `Enabled = false` would not propagate to them.
    private Func<ReadCoalescingOptions>? _coalescingAccessor;

    // Live accessor for KeepaliveOptions, threaded through Attach so PLCs added or
    // restarted via hot-reload honour the current `Connection.Keepalive` values.
    private Func<KeepaliveOptions>? _keepaliveAccessor;

    // ── Debounce + serialisation machinery ───────────────────────────────────────────────

    // Channel carries Unit to signal "something changed — please check".
    // The background loop drains it with a 250 ms quiescent window.
    private readonly Channel<bool> _changeSignal =
        Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
        {
            FullMode = BoundedChannelFullMode.DropOldest,
        });

    // Serialises concurrent ApplyAsync invocations.
    // A slow apply will queue the next one, and the last enqueued state wins.
    private readonly SemaphoreSlim _applySemaphore = new(1, 1);

    private readonly CancellationTokenSource _disposalCts = new();
    private readonly IDisposable? _changeRegistration;
    private readonly Task _debounceLoop;

    // Debounce window: how long to wait for additional OnChange events before applying.
    private static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(250);

    // ── Construction ─────────────────────────────────────────────────────────────────────

    public ConfigReconciler(
        IOptionsMonitor<MbproxyOptions> monitor,
        ILoggerFactory loggerFactory,
        ServiceCounters serviceCounters,
        Proxy.TagCaptureRegistry captureRegistry)
    {
        _monitor         = monitor;
        _loggerFactory   = loggerFactory;
        _logger          = loggerFactory.CreateLogger<ConfigReconciler>();
        _serviceCounters = serviceCounters;
        _captureRegistry = captureRegistry;

        // Subscribe to OnChange. The callback must return immediately — enqueue only.
        _changeRegistration = _monitor.OnChange((_, _) =>
        {
            // Best-effort write — if the channel is full (BoundedChannelFullMode.DropOldest)
            // the oldest signal is dropped and replaced; the reconciler will still see the
            // latest options value when it wakes up. No blocking.
            _changeSignal.Writer.TryWrite(true);
        });

        // Start the debounce/apply background loop.
        _debounceLoop = Task.Run(() => DebounceLoopAsync(_disposalCts.Token));
    }

    // ── Wire-up called by ProxyWorker after initial startup ──────────────────────────────

    /// <summary>
    /// Provides the reconciler with the supervisor dictionary, the initial options snapshot,
    /// and the live <see cref="ReadCoalescingOptions"/> accessor that add/restart
    /// supervisors must use so a hot-reloaded
    /// <c>Mbproxy.Resilience.ReadCoalescing.Enabled</c> propagates to them. Must be called
    /// exactly once by <see cref="Proxy.ProxyWorker"/> before any <c>OnChange</c> events
    /// can arrive (i.e. immediately after the supervisors are created). Thread-safe: the
    /// reconciler hasn't started processing changes yet at this point.
    /// </summary>
    public void Attach(
        ConcurrentDictionary<string, PlcListenerSupervisor> supervisors,
        MbproxyOptions initialOptions,
        Func<ReadCoalescingOptions>? coalescingAccessor = null,
        Func<KeepaliveOptions>? keepaliveAccessor = null)
    {
        _supervisors        = supervisors;
        _currentOptions     = initialOptions;
        _coalescingAccessor = coalescingAccessor;
        _keepaliveAccessor  = keepaliveAccessor;
    }

    // ── ApplyAsync (exposed for tests) ───────────────────────────────────────────────────

    /// <summary>
    /// Validates <paramref name="next"/>, computes a <see cref="ReloadPlan"/>, and applies
    /// it to the running supervisor set. Serialised by <c>_applySemaphore</c> so two
    /// concurrent calls never interleave.
    ///
    /// <para>Returns <c>true</c> if the reload was accepted and applied (even partially).
    /// Returns <c>false</c> if validation failed — no state was mutated.</para>
    /// </summary>
    public async Task<bool> ApplyAsync(MbproxyOptions next, CancellationToken ct)
    {
        await _applySemaphore.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            return await ApplyUnderLockAsync(next, ct).ConfigureAwait(false);
        }
        finally
        {
            _applySemaphore.Release();
        }
    }

    // ── Debounce loop ─────────────────────────────────────────────────────────────────────

    private async Task DebounceLoopAsync(CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                // Wait for the first signal.
                await _changeSignal.Reader.WaitToReadAsync(ct).ConfigureAwait(false);

                // Drain and keep waiting until no new signal arrives for DebounceWindow.
                // This merges bursts of 2–3 events from rename-and-replace saves into one apply.
                bool gotSignal;
                do
                {
                    _changeSignal.Reader.TryRead(out _); // consume the pending signal
                    using var debounceCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                    debounceCts.CancelAfter(DebounceWindow);

                    try
                    {
                        gotSignal = await _changeSignal.Reader.WaitToReadAsync(debounceCts.Token)
                            .ConfigureAwait(false);
                    }
                    catch (OperationCanceledException) when (!ct.IsCancellationRequested)
                    {
                        // Debounce window elapsed with no new signal — good, proceed with apply.
                        gotSignal = false;
                    }
                }
                while (gotSignal);

                if (ct.IsCancellationRequested) break;

                // Snapshot the current options value (IOptionsMonitor always returns the latest).
                var next = _monitor.CurrentValue;
                try
                {
                    await ApplyAsync(next, ct).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (ct.IsCancellationRequested)
                {
                    break;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Unexpected exception in ConfigReconciler debounce loop: {Message}", ex.Message);
                }
            }
        }
        catch (OperationCanceledException)
        {
            // Normal: disposal cancelled the token.
        }
    }

    // ── Core apply logic (runs under _applySemaphore) ─────────────────────────────────────

    private async Task<bool> ApplyUnderLockAsync(MbproxyOptions next, CancellationToken ct)
    {
        // If Attach() hasn't been called yet, skip (initial startup is still in progress).
        if (_supervisors is null || _currentOptions is null)
        {
            _logger.LogDebug("ConfigReconciler.ApplyAsync called before Attach() — skipping.");
            return false;
        }

        // ── 1. Validate atomically ────────────────────────────────────────────
        if (!ReloadValidator.Validate(next, out var errors))
        {
            string joined = string.Join("; ", errors);
            LogReloadRejected(_logger, joined);
            _serviceCounters.RecordReloadRejected();
            return false;
        }

        // ── 2. Compute the plan ───────────────────────────────────────────────
        var plan = ReloadPlan.Compute(_currentOptions, next);

        int plcsAdded     = plan.ToAdd.Count;
        int plcsRemoved   = plan.ToRemove.Count;
        int plcsRestarted = plan.ToRestart.Count;
        int plcsReseated  = plan.ToReseat.Count;

        // Compute global tag delta (count of entries that differ).
        int globalTagDelta = ComputeGlobalTagDelta(_currentOptions.BcdTags, next.BcdTags);

        // ── 3. Apply: Remove ─────────────────────────────────────────────────
        if (plan.ToRemove.Count > 0)
        {
            var removeTasks = plan.ToRemove
                .Select(async name =>
                {
                    if (!_supervisors.TryRemove(name, out var s))
                        return;
                    _captureRegistry.Remove(name);
                    try
                    {
                        using var stopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                        stopCts.CancelAfter(TimeSpan.FromSeconds(10));
                        await s.StopAsync(stopCts.Token).ConfigureAwait(false);
                        await s.DisposeAsync().ConfigureAwait(false);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogError(ex, "Error stopping supervisor for removed PLC '{Plc}': {Message}",
                            name, ex.Message);
                    }
                })
                .ToArray();

            await Task.WhenAll(removeTasks).ConfigureAwait(false);
        }

        // ── 4. Apply: Restart (stop + rebuild + start) ───────────────────────
        if (plan.ToRestart.Count > 0)
        {
            var resilienceOpts  = next.Resilience;
            var backendPipeline = PolicyFactory.BuildBackendConnect(
                resilienceOpts.BackendConnect,
                _loggerFactory.CreateLogger("Mbproxy.Proxy.BackendConnect"));

            var restartTasks = plan.ToRestart.Select(async entry =>
            {
                var (name, plcNew) = entry;
                try
                {
                    // Stop old supervisor.
                    if (_supervisors.TryRemove(name, out var old))
                    {
                        using var stopCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                        stopCts.CancelAfter(TimeSpan.FromSeconds(10));
                        await old.StopAsync(stopCts.Token).ConfigureAwait(false);
                        await old.DisposeAsync().ConfigureAwait(false);
                    }

                    // Build fresh context. Pass DefaultCacheTtlMs so per-PLC default
                    // caching folds into the resolved tag map.
                    var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
                    var newCtx = new PerPlcContext
                    {
                        PlcName  = plcNew.Name,
                        TagMap   = result.Map,
                        Counters = new Proxy.ProxyCounters(),
                        Logger   = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
                        Cache    = BuildCacheIfNeeded(result.Map, next.Cache),
                        Capture  = _captureRegistry.GetOrCreate(plcNew.Name, result.Map),
                    };

                    // Build and start new supervisor.
                    var recoveryPipeline = PolicyFactory.BuildListenerRecovery(
                        resilienceOpts.ListenerRecovery,
                        _loggerFactory.CreateLogger($"Mbproxy.Proxy.ListenerRecovery.{plcNew.Name}"));

                    var newSupervisor = new PlcListenerSupervisor(
                        plcNew,
                        next.Connection,
                        new Proxy.BcdPduPipeline(),
                        _loggerFactory.CreateLogger<Proxy.PlcListener>(),
                        _loggerFactory.CreateLogger<PlcMultiplexer>(),
                        _loggerFactory.CreateLogger($"Mbproxy.Proxy.UpstreamPipe.{plcNew.Name}"),
                        newCtx,
                        recoveryPipeline,
                        _loggerFactory.CreateLogger<PlcListenerSupervisor>(),
                        backendPipeline,
                        _coalescingAccessor,
                        _keepaliveAccessor);

                    _supervisors[name] = newSupervisor;
                    await newSupervisor.StartAsync(ct).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error restarting supervisor for PLC '{Plc}': {Message}",
                        name, ex.Message);
                }
            }).ToArray();

            await Task.WhenAll(restartTasks).ConfigureAwait(false);
        }

        // ── 5. Apply: Reseat (swap tag map, keep listener socket) ────────────
        foreach (var (name, newMap) in plan.ToReseat)
        {
            if (!_supervisors.TryGetValue(name, out var supervisor))
                continue;

            try
            {
                var plcNew = next.Plcs.First(p => p.Name == name);
                var newCtx = new PerPlcContext
                {
                    PlcName  = name,
                    TagMap   = newMap,
                    // Preserve existing counters so operators see real history.
                    Counters = supervisor.CurrentCounters,
                    Logger   = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{name}"),
                    // Any reseat (tag-map change) constructs a fresh cache. The
                    // supervisor disposes the old one inside ReplaceContextAsync.
                    Cache    = BuildCacheIfNeeded(newMap, next.Cache),
                    // Rebuild the capture for the new tag set; GetOrCreate preserves the
                    // armed flag so an open detail page keeps capturing across the reload.
                    Capture  = _captureRegistry.GetOrCreate(name, newMap),
                };

                using var reseatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
                reseatCts.CancelAfter(TimeSpan.FromSeconds(5));
                await supervisor.ReplaceContextAsync(newCtx, reseatCts.Token).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error reseating context for PLC '{Plc}': {Message}",
                    name, ex.Message);
            }
        }

        // ── 6. Apply: Add new PLCs ────────────────────────────────────────────
        if (plan.ToAdd.Count > 0)
        {
            var resilienceOpts  = next.Resilience;
            var backendPipeline = PolicyFactory.BuildBackendConnect(
                resilienceOpts.BackendConnect,
                _loggerFactory.CreateLogger("Mbproxy.Proxy.BackendConnect"));

            var addTasks = plan.ToAdd.Select(async plcNew =>
            {
                try
                {
                    // Pass DefaultCacheTtlMs so per-PLC default caching folds into the
                    // resolved tag map.
                    var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
                    var newCtx = new PerPlcContext
                    {
                        PlcName  = plcNew.Name,
                        TagMap   = result.Map,
                        Counters = new Proxy.ProxyCounters(),
                        Logger   = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
                        Cache    = BuildCacheIfNeeded(result.Map, next.Cache),
                        Capture  = _captureRegistry.GetOrCreate(plcNew.Name, result.Map),
                    };

                    var recoveryPipeline = PolicyFactory.BuildListenerRecovery(
                        resilienceOpts.ListenerRecovery,
                        _loggerFactory.CreateLogger($"Mbproxy.Proxy.ListenerRecovery.{plcNew.Name}"));

                    var newSupervisor = new PlcListenerSupervisor(
                        plcNew,
                        next.Connection,
                        new Proxy.BcdPduPipeline(),
                        _loggerFactory.CreateLogger<Proxy.PlcListener>(),
                        _loggerFactory.CreateLogger<PlcMultiplexer>(),
                        _loggerFactory.CreateLogger($"Mbproxy.Proxy.UpstreamPipe.{plcNew.Name}"),
                        newCtx,
                        recoveryPipeline,
                        _loggerFactory.CreateLogger<PlcListenerSupervisor>(),
                        backendPipeline,
                        _coalescingAccessor,
                        _keepaliveAccessor);

                    _supervisors[plcNew.Name] = newSupervisor;
                    await newSupervisor.StartAsync(ct).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Error adding supervisor for PLC '{Plc}': {Message}",
                        plcNew.Name, ex.Message);
                }
            }).ToArray();

            await Task.WhenAll(addTasks).ConfigureAwait(false);
        }

        // ── 7. Record success ─────────────────────────────────────────────────
        _currentOptions = next;
        var appliedAt = DateTimeOffset.UtcNow;
        _serviceCounters.RecordReloadApplied(appliedAt);

        LogReloadApplied(_logger, plcsAdded, plcsRemoved, plcsRestarted, plcsReseated, globalTagDelta);

        return true;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Constructs a <see cref="ResponseCache"/> only when at least one resolved tag in
    /// <paramref name="map"/> opts in (<see cref="BcdTag.CacheTtlMs"/> &gt; 0). Returns
    /// <c>null</c> otherwise so the no-cache path bypasses cache logic entirely.
    /// </summary>
    private static ResponseCache? BuildCacheIfNeeded(BcdTagMap map, CacheOptions opts)
    {
        foreach (var t in map.All)
            if (t.CacheTtlMs > 0)
                return new ResponseCache(opts.MaxEntriesPerPlc, opts.EvictionIntervalMs);
        return null;
    }

    private static int ComputeGlobalTagDelta(BcdTagListOptions before, BcdTagListOptions after)
    {
        // Count entries in before but not in after (removed), plus entries in after
        // but not in before (added), plus entries with the same address but different width.
        var beforeDict = before.Global.ToDictionary(t => t.Address);
        var afterDict  = after.Global.ToDictionary(t => t.Address);

        int delta = 0;
        foreach (var addr in beforeDict.Keys.Union(afterDict.Keys).Distinct())
        {
            bool inBefore = beforeDict.TryGetValue(addr, out var bTag);
            bool inAfter  = afterDict.TryGetValue(addr, out var aTag);

            if (!inBefore || !inAfter)
                delta++; // added or removed
            else if (bTag!.Width != aTag!.Width)
                delta++; // width changed
        }

        return delta;
    }

    // ── IDisposable ───────────────────────────────────────────────────────────────────────

    public void Dispose()
    {
        _changeRegistration?.Dispose();
        _disposalCts.Cancel();

        try
        {
            _debounceLoop.Wait(TimeSpan.FromSeconds(2));
        }
        catch
        {
            // Best effort.
        }

        _disposalCts.Dispose();
        _applySemaphore.Dispose();
    }

    // ── Logging ───────────────────────────────────────────────────────────────────────────

    [LoggerMessage(EventId = 60, EventName = "mbproxy.config.reload.applied",
        Level = LogLevel.Information,
        Message = "Config reload applied — PlcsAdded={PlcsAdded} PlcsRemoved={PlcsRemoved} " +
                  "PlcsRestarted={PlcsRestarted} PlcsReseated={PlcsReseated} GlobalTagDelta={GlobalTagDelta}")]
    private static partial void LogReloadApplied(
        ILogger logger, int plcsAdded, int plcsRemoved, int plcsRestarted, int plcsReseated, int globalTagDelta);

    [LoggerMessage(EventId = 61, EventName = "mbproxy.config.reload.rejected",
        Level = LogLevel.Error,
        Message = "Config reload rejected — Errors={Errors}")]
    private static partial void LogReloadRejected(ILogger logger, string errors);
}
