using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using DragonISO.Engine.Interop;

namespace DragonISO.Engine.Discovery;

/// <summary>
/// Polls <see cref="INdiInterop.GetCurrentSources"/> at a fixed cadence, diffs the
/// resulting set against the previous poll, and emits <see cref="DiscoveryEvent"/>s
/// on a channel for downstream consumers.
/// </summary>
public sealed class NdiDiscoveryService
{
    private readonly INdiInterop _interop;
    private readonly ChannelWriter<DiscoveryEvent> _writer;
    private readonly ILogger<NdiDiscoveryService> _logger;
    private NdiFindHandle _finder;
    private readonly HashSet<string> _previous = new();
    private string? _discoveryGroups;
    private int _refreshRequested;

    public NdiDiscoveryService(
        INdiInterop interop,
        ChannelWriter<DiscoveryEvent> writer,
        ILogger<NdiDiscoveryService> logger,
        string? discoveryGroups = null)
    {
        _interop = interop;
        _writer = writer;
        _logger = logger;
        _discoveryGroups = discoveryGroups;
        _finder = interop.CreateFinder(discoveryGroups);
    }

    /// <summary>
    /// Request that the next poll tick rebuild the underlying NDI finder. Useful right
    /// after the operator changes discovery groups or applies a new transcoder topology
    /// â€” without this, the finder is bound to the groups it was created with and never
    /// sees new sources from the just-configured group. Honored on the next
    /// <see cref="RunAsync"/> tick: the old finder is disposed, a fresh one is created,
    /// and the seen-set is cleared so all currently-visible sources re-fire as
    /// <see cref="DiscoveryEvent.Added"/>. Cheap (idempotent) â€” extra Refresh calls
    /// while a refresh is already pending are coalesced.
    /// </summary>
    public void RequestRefresh() => Interlocked.Exchange(ref _refreshRequested, 1);

    /// <summary>
    /// Runs a single poll cycle. Public for unit testing; production uses <see cref="RunAsync"/>.
    /// </summary>
    public void PollOnce()
    {
        var current = _interop.GetCurrentSources(_finder);
        var currentSet = new HashSet<string>(current);

        foreach (var name in currentSet.Except(_previous))
        {
            var parsed = NdiSourceParser.Parse(name);
            if (parsed is null)
            {
                _logger.LogTrace("Ignoring unrecognized source: {Name}", name);
                continue;
            }
            _writer.TryWrite(new DiscoveryEvent.Added(parsed));
        }

        foreach (var name in _previous.Except(currentSet))
        {
            var parsed = NdiSourceParser.Parse(name);
            if (parsed is null) continue;
            _writer.TryWrite(new DiscoveryEvent.Removed(parsed));
        }

        _previous.Clear();
        foreach (var name in currentSet) _previous.Add(name);
    }

    /// <summary>
    /// Long-running poll loop with cold-start ramp + self-healing.
    /// Cancel the token to stop.
    ///
    /// Cadence: 200ms for the first 3 seconds (fast cold-start mDNS settling),
    /// then the configured <paramref name="pollInterval"/>.
    ///
    /// Self-healing: certain process spawns end up with an NDI finder that
    /// returns 0 sources forever even when sources are visible to other
    /// processes (suspected cause: medium-integrity SAFER token from runas
    /// /trustlevel doesn't talk to NDI's mDNS responder reliably; could also
    /// be a NIC-bind race at finder construction). To recover, we rebuild
    /// the finder when:
    /// <list type="number">
    ///   <item>We've never seen a source AND it's been &gt;5s since startup AND
    ///         it's been &gt;5s since the last rebuild.</item>
    ///   <item>We previously saw sources but the set has been empty for &gt;15s
    ///         AND it's been &gt;10s since the last rebuild.</item>
    /// </list>
    /// Both rules apply backoff so we don't churn during legitimate empty
    /// periods (no meeting active, etc.) â€” the rebuild is cheap but the log
    /// noise isn't useful.
    /// </summary>
    public async Task RunAsync(TimeSpan pollInterval, CancellationToken cancellationToken)
    {
        try
        {
            // Immediate first poll â€” PeriodicTimer.WaitForNextTickAsync would
            // wait the full interval otherwise, costing us 200-500ms at cold
            // start when operators are most impatient.
            try { PollOnce(); } catch (Exception ex) { _logger.LogWarning(ex, "Initial discovery poll failed."); }

            var startedAt = DateTimeOffset.UtcNow;
            var fastUntil = startedAt + TimeSpan.FromSeconds(3);
            var fastInterval = TimeSpan.FromMilliseconds(200);
            DateTimeOffset? lastSeenAt = _previous.Count > 0 ? startedAt : null;
            var lastRebuildAt = startedAt;

            while (!cancellationToken.IsCancellationRequested)
            {
                var now = DateTimeOffset.UtcNow;
                var interval = now < fastUntil ? fastInterval : pollInterval;
                try { await Task.Delay(interval, cancellationToken); }
                catch (OperationCanceledException) { break; }

                now = DateTimeOffset.UtcNow;

                // Operator-requested rebuild (Refresh discovery in the UI) wins.
                if (Interlocked.Exchange(ref _refreshRequested, 0) == 1)
                {
                    RebuildFinder("operator request");
                    lastRebuildAt = now;
                }
                // Auto-healing rebuilds â€” see ShouldAutoRebuild.
                else if (_previous.Count == 0)
                {
                    var decision = ShouldAutoRebuild(
                        sinceStart:      now - startedAt,
                        sinceLastSeen:   lastSeenAt is { } seen ? now - seen : (TimeSpan?)null,
                        sinceLastRebuild: now - lastRebuildAt);
                    if (decision is { } reason)
                    {
                        RebuildFinder(reason);
                        lastRebuildAt = now;
                    }
                }

                try { PollOnce(); }
                catch (Exception ex) { _logger.LogWarning(ex, "Discovery poll failed; will retry on next tick."); }

                if (_previous.Count > 0) lastSeenAt = DateTimeOffset.UtcNow;
            }
        }
        finally
        {
            _finder.Dispose();
        }
    }

    /// <summary>
    /// Pure-function decision for whether the discovery loop should rebuild the
    /// NDI finder on the current tick. Returns a non-null reason string when
    /// the rebuild should fire (which is also logged); null means "leave the
    /// finder alone." Caller is responsible for tracking the timestamps and
    /// updating <c>lastRebuildAt</c> after the rebuild.
    ///
    /// Public + static for unit-testability â€” the time-based rules are easy to
    /// regress and hard to spot in integration testing.
    ///
    /// Rules:
    /// <list type="number">
    ///   <item><b>Never seen a source</b> (<paramref name="sinceLastSeen"/> is null):
    ///         rebuild when sinceStart &gt; 5s AND sinceLastRebuild &gt; 5s.</item>
    ///   <item><b>Used to see sources, now empty</b>: rebuild when sinceLastSeen
    ///         &gt; 15s AND sinceLastRebuild &gt; 10s.</item>
    /// </list>
    /// Both rules back off the rebuild cadence to avoid churn during legitimate
    /// empty periods (no meeting active, all participants left, etc.).
    /// </summary>
    public static string? ShouldAutoRebuild(TimeSpan sinceStart, TimeSpan? sinceLastSeen, TimeSpan sinceLastRebuild)
    {
        if (sinceLastSeen is null)
        {
            if (sinceStart > TimeSpan.FromSeconds(5) && sinceLastRebuild > TimeSpan.FromSeconds(5))
                return "auto-heal: never saw a source";
            return null;
        }
        if (sinceLastSeen.Value > TimeSpan.FromSeconds(15) && sinceLastRebuild > TimeSpan.FromSeconds(10))
            return "auto-heal: source set went empty 15s ago";
        return null;
    }

    /// <summary>
    /// Create a fresh finder against the cached discovery groups and, only once it
    /// is successfully in hand, dispose the old one and clear the seen-set so all
    /// currently-visible sources re-fire as <see cref="DiscoveryEvent.Added"/> on
    /// the next poll.
    ///
    /// Ordering matters: we build the replacement <em>before</em> disposing the
    /// incumbent. If <see cref="INdiInterop.CreateFinder"/> throws (the failure mode
    /// this method's catch exists for), the incumbent finder is left untouched and
    /// fully usable, the seen-set is preserved (no spurious re-Add storm), and the
    /// loop simply keeps polling the existing finder until the next rebuild attempt.
    ///
    /// Returns true if the finder was actually swapped; false if the rebuild failed
    /// and the existing finder was retained. Internal for unit-testing the failure
    /// path.
    /// </summary>
    internal bool RebuildFinder(string reason)
    {
        NdiFindHandle replacement;
        try
        {
            _logger.LogInformation("Rebuilding NDI finder ({Reason}).", reason);
            replacement = _interop.CreateFinder(_discoveryGroups);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Finder rebuild failed ({Reason}); continuing with existing finder.", reason);
            return false;
        }

        // New finder is live â€” retire the old one and reset discovery state.
        var old = _finder;
        _finder = replacement;
        try { old.Dispose(); }
        catch (Exception ex) { _logger.LogWarning(ex, "Disposing previous finder threw after rebuild ({Reason}).", reason); }
        _previous.Clear();
        return true;
    }

    /// <summary>
    /// Updates the cached discovery-groups string used by future finder rebuilds.
    /// Call <see cref="RequestRefresh"/> after this to actually pick up the change.
    /// </summary>
    public void UpdateDiscoveryGroups(string? discoveryGroups) =>
        _discoveryGroups = discoveryGroups;
}
