using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Sessions;

namespace ZB.MOM.WW.MxGateway.Server.Dashboard;

/// <summary>
///     Default <see cref="IDashboardLiveDataService"/>. Owns one shared gateway
///     session for the whole dashboard: it is opened lazily on first use and
///     re-opened transparently whenever it faults, is closed, or its lease
///     expires. All access is serialised through <see cref="_gate"/> so the
///     single backing worker only ever sees one in-flight command.
/// </summary>
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
{
    private const string BackendName = "Galaxy";
    private const string ClientName = "mxgateway-dashboard";
    private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);

    private readonly ISessionManager _sessionManager;
    private readonly IGatewayAlarmService _alarmService;
    private readonly ILogger<DashboardLiveDataService> _logger;
    private readonly SemaphoreSlim _gate = new(1, 1);
    private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);

    private GatewaySession? _session;
    private int _serverHandle;
    private bool _disposed;

    /// <summary>Initializes the live-data service.</summary>
    /// <param name="sessionManager">Gateway session manager.</param>
    /// <param name="alarmService">Gateway central alarm service.</param>
    /// <param name="logger">Diagnostic logger.</param>
    public DashboardLiveDataService(
        ISessionManager sessionManager,
        IGatewayAlarmService alarmService,
        ILogger<DashboardLiveDataService> logger)
    {
        _sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
        _alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    /// <inheritdoc />
    public async Task<DashboardLiveReadResult> ReadAsync(
        IReadOnlyCollection<string> tagAddresses,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(tagAddresses);
        if (tagAddresses.Count == 0)
        {
            return DashboardLiveReadResult.Empty;
        }

        await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
        try
        {
            (GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken)
                .ConfigureAwait(false);

            string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray();
            if (toSubscribe.Length > 0)
            {
                await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
                    .ConfigureAwait(false);
                foreach (string tag in toSubscribe)
                {
                    _subscribed.Add(tag);
                }
            }

            IReadOnlyList<BulkReadResult> results = await session
                .ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
                .ConfigureAwait(false);

            DashboardTagValue[] values = results
                .Select(DashboardTagValue.FromBulkReadResult)
                .ToArray();
            return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
        }
        catch (Exception exception) when (exception is not OperationCanceledException)
        {
            InvalidateSession();
            _logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened.");
            return new DashboardLiveReadResult([], exception.Message, null, null);
        }
        finally
        {
            _gate.Release();
        }
    }

    /// <inheritdoc />
    public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
    {
        // Alarms come from the gateway's always-on central monitor; the
        // dashboard reads its in-process cache directly — no session needed.
        DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms
            .Select(DashboardActiveAlarm.FromSnapshot)
            .ToArray();

        string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring
            or GatewayAlarmMonitorState.Disabled
            ? null
            : _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";

        return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
    }

    // Returns a Ready session + its Register server handle, opening a fresh
    // session when none exists or the current one is no longer usable. Callers
    // must hold _gate.
    private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync(
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        GatewaySession? existing = _session;
        if (existing is not null
            && existing.State == SessionState.Ready
            && _sessionManager.TryGetSession(existing.SessionId, out _))
        {
            return (existing, _serverHandle);
        }

        if (existing is not null)
        {
            _logger.LogInformation(
                "Dashboard session {SessionId} is no longer usable (state {State}); re-opening.",
                existing.SessionId,
                existing.State);
            await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false);
        }

        _subscribed.Clear();
        _session = null;

        GatewaySession session = await _sessionManager.OpenSessionAsync(
                new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
                ClientName,
                cancellationToken)
            .ConfigureAwait(false);

        WorkerCommandReply reply = await session.InvokeAsync(
                new WorkerCommand
                {
                    Command = new MxCommand
                    {
                        Kind = MxCommandKind.Register,
                        Register = new RegisterCommand { ClientName = ClientName },
                    },
                },
                cancellationToken)
            .ConfigureAwait(false);

        int? serverHandle = reply.Reply?.Register?.ServerHandle;
        if (serverHandle is null)
        {
            string diagnostic = reply.Reply?.ProtocolStatus?.Message
                ?? reply.Reply?.DiagnosticMessage
                ?? "Worker did not return a server handle for Register.";
            await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
            throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}");
        }

        _session = session;
        _serverHandle = serverHandle.Value;
        _logger.LogInformation(
            "Dashboard session {SessionId} opened (worker pid {WorkerPid}).",
            session.SessionId,
            session.WorkerProcessId);
        return (session, _serverHandle);
    }

    // Drops the cached session so the next call re-opens. Callers must hold _gate.
    private void InvalidateSession()
    {
        _session = null;
        _serverHandle = 0;
        _subscribed.Clear();
    }

    private async Task CloseQuietlyAsync(string sessionId)
    {
        try
        {
            await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
        }
        catch (Exception exception)
        {
            _logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId);
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }

        _disposed = true;
        GatewaySession? session = _session;
        _session = null;
        if (session is not null)
        {
            await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
        }

        _gate.Dispose();
    }
}
