using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using DragonISO.Engine.Discovery;
using DragonISO.Engine.Domain;
using DragonISO.Engine.Interop;
using DragonISO.Engine.Persistence;
using DragonISO.Engine.Pipeline;

namespace DragonISO.Engine.Controller;

/// <summary>
/// Default implementation of <see cref="IIsoController"/>.
/// Holds the participant tracker, the discovery service, the pipeline dictionary, the config store,
/// and the runtime probe. Exposes participant and alert observables for the host UI.
/// </summary>
public sealed class IsoController : IIsoController
{
    private readonly INdiInterop _interop;
    private readonly Func<IsoPipelineConfig, IsoPipeline> _pipelineFactory;
    private readonly ConfigStore _configStore;
    private readonly NdiRuntimeProbe _runtimeProbe;
    private readonly ILoggerFactory _loggerFactory;
    private readonly ILogger<IsoController> _logger;
    private readonly TimeSpan _renameWindow;
    private readonly TimeSpan _discoveryInterval;
    private bool _recordingEnabled;
    private string? _recordingDirectory;

    private readonly ParticipantTracker _tracker;
    private readonly Channel<DiscoveryEvent> _discoveryChannel;
    private readonly NdiDiscoveryService _discovery;
    private readonly Dictionary<Guid, IsoPipeline> _pipelines = new();
    // Parallel map of active recorders keyed by participant id, for the
    // marker-drop API which needs to fan out to every running recorder.
    private readonly Dictionary<Guid, Pipeline.IRecorderSink> _recorders = new();
    // Per-participant FrameProcessingSettings overrides. Null entry / missing
    // key â†’ use global settings. Set entry â†’ that pipeline runs at the
    // override values regardless of global. Persisted to config.json alongside
    // the IsoAssignment record so it survives process restarts.
    private readonly Dictionary<Guid, FrameProcessingSettings> _overrides = new();
    private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
        new(Array.Empty<Participant>());
    private readonly Subject<EngineAlert> _alerts = new();
    private readonly object _gate = new();

    private FrameProcessingSettings _settings;
    private NdiGroupSettings _groupSettings;
    private CancellationTokenSource? _cts;
    private Task? _discoveryTask;
    private Task? _eventPumpTask;

    public IObservable<IReadOnlyList<Participant>> Participants => _participants.AsObservable();
    public IObservable<EngineAlert> Alerts => _alerts.AsObservable();
    public FrameProcessingSettings GlobalSettings { get { lock (_gate) return _settings; } }
    public NdiGroupSettings GroupSettings { get { lock (_gate) return _groupSettings; } }

    public IsoController(
        INdiInterop interop,
        Func<IsoPipelineConfig, IsoPipeline> pipelineFactory,
        ConfigStore configStore,
        NdiRuntimeProbe runtimeProbe,
        ILoggerFactory loggerFactory,
        TimeSpan? renameWindow = null,
        TimeSpan? discoveryInterval = null,
        Func<DateTimeOffset>? clock = null)
    {
        _interop = interop;
        _pipelineFactory = pipelineFactory;
        _configStore = configStore;
        _runtimeProbe = runtimeProbe;
        _loggerFactory = loggerFactory;
        _logger = loggerFactory.CreateLogger<IsoController>();
        _renameWindow = renameWindow ?? TimeSpan.FromSeconds(5);
        _discoveryInterval = discoveryInterval ?? TimeSpan.FromMilliseconds(500);

        var loaded = configStore.Load();
        _settings = loaded.Global;
        _groupSettings = loaded.GroupsOrDefault;

        // Hydrate per-participant overrides from the persisted assignments.
        // Each assignment with a non-null Override gets parked here; pipelines
        // get the override when they're enabled (whether autonomously by
        // auto-apply or manually by the operator).
        foreach (var asn in loaded.Assignments)
        {
            if (asn.Override is not null)
                _overrides[asn.ParticipantId] = asn.Override;
        }

        _tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow));
        _discoveryChannel = Channel.CreateUnbounded<DiscoveryEvent>();
        _discovery = new NdiDiscoveryService(
            interop, _discoveryChannel.Writer,
            loggerFactory.CreateLogger<NdiDiscoveryService>(),
            _groupSettings.DiscoveryGroups);
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        if (_cts is not null)
            throw new InvalidOperationException("Controller already started.");
        _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

        // Runtime probe â€” surface alert if mismatch but don't fail startup.
        var probeResult = _runtimeProbe.Probe();
        if (probeResult is NdiRuntimeProbeResult.Mismatch mismatch)
        {
            _alerts.OnNext(new EngineAlert.NdiRuntimeMismatch(mismatch.Detected, mismatch.Expected));
            _logger.LogWarning("NDI runtime mismatch: detected {Detected}, expected {Expected}.",
                mismatch.Detected, mismatch.Expected);
        }

        _discoveryTask = _discovery.RunAsync(_discoveryInterval, _cts.Token);
        _eventPumpTask = PumpDiscoveryEventsAsync(_cts.Token);
        await Task.CompletedTask;
    }

    public IsoHealthStats GetStats(Guid participantId)
    {
        IsoPipeline? pipeline;
        lock (_gate)
        {
            if (!_pipelines.TryGetValue(participantId, out pipeline))
                return IsoHealthStats.Empty;
        }
        // GetStats() is thread-safe and fast; pull outside the gate so a slow stats
        // read doesn't serialize the controller's other operations.
        return pipeline.GetStats();
    }

    public Pipeline.ProcessedFrame? GetLatestProcessedFrame(Guid participantId)
    {
        IsoPipeline? pipeline;
        lock (_gate)
        {
            if (!_pipelines.TryGetValue(participantId, out pipeline))
                return null;
        }
        return pipeline.LatestProcessedFrame;
    }

    public Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken) =>
        EnableIsoAsync(participantId, customName, recordOverride: null, cancellationToken);

    public async Task EnableIsoAsync(Guid participantId, string? customName, bool? recordOverride, CancellationToken cancellationToken)
    {
        Participant? p;
        lock (_gate)
        {
            if (_pipelines.ContainsKey(participantId)) return;
            p = _tracker.Participants.FirstOrDefault(x => x.Id == participantId);
        }
        if (p is null || p.CurrentSource is null)
            throw new InvalidOperationException($"Participant {participantId} not currently visible on the network.");

        var output = customName ?? DefaultOutputName(participantId);
        string? outputGroups;
        FrameProcessingSettings settingsSnapshot;
        bool recordingEnabled;
        string? recordingDirectory;
        lock (_gate)
        {
            outputGroups = _groupSettings.OutputGroups;
            // Per-participant override beats global. The override is a frozen
            // record; mutations during a pipeline's lifetime are handled by
            // SetIsoOverrideAsync which stops + restarts the pipeline so the
            // new value flows through.
            settingsSnapshot = _overrides.TryGetValue(participantId, out var ovr) ? ovr : _settings;
            recordingEnabled = _recordingEnabled;
            recordingDirectory = _recordingDirectory;
        }

        // Per-call override beats global toggle. recordOverride=true forces a
        // recorder even when global recording is off (useful for "record just
        // this one"); recordOverride=false suppresses the recorder when global
        // is on (operator opt-out per participant).
        var shouldRecord = recordOverride ?? recordingEnabled;
        IRecorderSink? recorder = null;
        if (shouldRecord)
        {
            // Per-pipeline recorder instance â€” each ISO writes to its own
            // subdirectory keyed by display name. Wrapping in try/catch so a
            // recorder construction failure (no logger, weird platform) never
            // takes down EnableIsoAsync.
            try
            {
                recorder = new RawBgraRecorderSink(_loggerFactory.CreateLogger<RawBgraRecorderSink>());
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Could not construct recorder for participant {Id}; ISO will run without recording.", participantId);
            }
        }

        var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, settingsSnapshot)
        {
            OutputGroups = outputGroups,
            Recorder = recorder,
            // Pass the directory whenever we have a recorder, regardless of the
            // global flag â€” the per-call override may have forced one even when
            // global recording is off.
            RecordingOutputDirectory = recorder is not null ? recordingDirectory : null,
            RecorderDisplayName = p.DisplayName,
        };
        var pipeline = _pipelineFactory(config);

        lock (_gate)
        {
            _pipelines[participantId] = pipeline;
            if (recorder is not null) _recorders[participantId] = recorder;
        }
        await pipeline.StartAsync();
        await PersistAssignmentsAsync(cancellationToken);
    }

    public async Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken)
    {
        IsoPipeline? pipeline;
        lock (_gate)
        {
            if (!_pipelines.Remove(participantId, out pipeline)) return;
            // Pipeline.DisposeAsync calls IRecorderSink.Close internally via
            // its supervisor finally; we just drop our parallel reference here.
            _recorders.Remove(participantId);
        }
        await pipeline.StopAsync();
        await pipeline.DisposeAsync();
        await PersistAssignmentsAsync(cancellationToken);
    }

    public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken)
    {
        lock (_gate) _settings = settings;
        return PersistAssignmentsAsync(cancellationToken);
    }

    public FrameProcessingSettings? GetIsoOverride(Guid participantId)
    {
        lock (_gate)
        {
            return _overrides.TryGetValue(participantId, out var ovr) ? ovr : null;
        }
    }

    /// <summary>
    /// Sets or clears the per-participant override and, if the pipeline is
    /// currently running, hot-swaps it: stop + brief delay + restart with
    /// the new settings. The downstream NDI receiver will see a one-frame
    /// drop on the swap but reconnect within ~200ms, same behavior as
    /// RestartIso. Persists alongside the assignment so the choice survives
    /// process restarts.
    /// </summary>
    public async Task SetIsoOverrideAsync(Guid participantId, FrameProcessingSettings? settings, CancellationToken cancellationToken)
    {
        bool wasRunning;
        string? customName;
        lock (_gate)
        {
            if (settings is null) _overrides.Remove(participantId);
            else _overrides[participantId] = settings;
            wasRunning = _pipelines.ContainsKey(participantId);
            // Custom name lives on the assignment; for now there's only ever
            // one in-flight name (the one passed to EnableIsoAsync), and we
            // don't track that â€” so the restart path uses the default name.
            // OK for v2; per-pipeline custom names can be retrofitted later.
            customName = null;
        }
        if (wasRunning)
        {
            try
            {
                await DisableIsoAsync(participantId, cancellationToken);
                await Task.Delay(150, cancellationToken);
                await EnableIsoAsync(participantId, customName, cancellationToken);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Hot-swap failed for participant {Id} after override change", participantId);
            }
        }
        else
        {
            // Pipeline not running â€” just persist the override so it takes
            // effect the NEXT time the operator enables this participant.
            await PersistAssignmentsAsync(cancellationToken);
        }
    }

    /// <summary>
    /// Updates the NDI group configuration. Note: existing finder/sender handles aren't
    /// rebuilt â€” group changes take effect on the next process restart, since rebuilding
    /// the live finder mid-flight would orphan in-flight participants. The settings
    /// panel surfaces this caveat to the user.
    /// </summary>
    public Task SetGroupSettingsAsync(NdiGroupSettings groupSettings, CancellationToken cancellationToken)
    {
        lock (_gate) _groupSettings = groupSettings;
        // Push the new discovery-groups string into the live discovery service so that the
        // next operator-initiated Refresh picks them up. We don't auto-refresh here because
        // mid-flight rebuilds are cheap but visible (sources blink) and the Settings panel
        // tells the operator to use the explicit Refresh button.
        _discovery.UpdateDiscoveryGroups(groupSettings.DiscoveryGroups);
        return PersistAssignmentsAsync(cancellationToken);
    }

    /// <summary>
    /// Forces the discovery service to rebuild its NDI finder on the next poll tick.
    /// Implemented as a non-blocking flag set so the controller method returns instantly;
    /// the actual rebuild + re-emit happens on the dedicated discovery loop.
    /// </summary>
    public void RefreshDiscovery()
    {
        _discovery.RequestRefresh();
        _logger.LogInformation("Discovery refresh requested.");
    }

    public void SetRecording(bool enabled, string? outputDirectory)
    {
        lock (_gate)
        {
            _recordingEnabled = enabled;
            _recordingDirectory = outputDirectory;
        }
        _logger.LogInformation(
            "Recording {State} (output: {Dir})",
            enabled ? "ENABLED" : "DISABLED",
            outputDirectory ?? "(unset)");
    }

    public bool RecordingEnabled { get { lock (_gate) return _recordingEnabled; } }
    public string? RecordingDirectory { get { lock (_gate) return _recordingDirectory; } }

    public void AddRecordingMarker(string label)
    {
        Pipeline.IRecorderSink[] snapshot;
        lock (_gate) snapshot = _recorders.Values.ToArray();
        foreach (var rec in snapshot)
        {
            try { rec.AddMarker(label); }
            catch { /* per-recorder defensive */ }
        }
        if (snapshot.Length > 0)
            _logger.LogInformation("Marker dropped on {Count} active recording(s): {Label}", snapshot.Length, label);
    }

    private Task PersistAssignmentsAsync(CancellationToken cancellationToken)
    {
        try
        {
            FrameProcessingSettings settings;
            NdiGroupSettings groupSettings;
            IReadOnlyList<IsoAssignment> assignments;
            lock (_gate)
            {
                settings = _settings;
                groupSettings = _groupSettings;
                // Build assignments from BOTH running pipelines (current
                // enabled set) AND any persisted overrides for participants
                // whose pipeline isn't currently active. An operator who set
                // a 30 fps override yesterday, then closed the app, then
                // re-enabled today should get 30 fps on re-enable.
                var ids = _pipelines.Keys
                    .Concat(_overrides.Keys.Where(k => !_pipelines.ContainsKey(k)))
                    .Distinct();
                assignments = ids
                    .Select(id => new IsoAssignment(
                        id,
                        IsEnabled: _pipelines.ContainsKey(id),
                        CustomOutputName: null,
                        Override: _overrides.TryGetValue(id, out var o) ? o : null))
                    .ToArray();
            }
            _configStore.Save(new EngineConfig(settings, assignments, groupSettings));
        }
        catch (Exception ex)
        {
            _alerts.OnNext(new EngineAlert.ConfigSaveFailed(ex.Message));
            _logger.LogWarning(ex, "Failed to persist engine config.");
        }
        return Task.CompletedTask;
    }

    private async Task PumpDiscoveryEventsAsync(CancellationToken cancellationToken)
    {
        try
        {
            await foreach (var ev in _discoveryChannel.Reader.ReadAllAsync(cancellationToken))
            {
                lock (_gate)
                {
                    _tracker.Apply(ev);
                    _participants.OnNext(_tracker.Participants);
                }
            }
        }
        catch (OperationCanceledException) { }
    }

    private static string DefaultOutputName(Guid participantId) =>
        $"Dragon-ISO_{participantId.ToString("N")[..8].ToUpperInvariant()}";

    public async ValueTask DisposeAsync()
    {
        _cts?.Cancel();
        if (_eventPumpTask is not null)
        {
            try { await _eventPumpTask; } catch { /* swallow */ }
        }
        if (_discoveryTask is not null)
        {
            try { await _discoveryTask; } catch { /* swallow */ }
        }
        IsoPipeline[] toDispose;
        lock (_gate)
        {
            toDispose = _pipelines.Values.ToArray();
            _pipelines.Clear();
        }
        foreach (var p in toDispose) await p.DisposeAsync();
        _alerts.OnCompleted();
        _participants.OnCompleted();
        _cts?.Dispose();
    }
}
