using Dalamud.Interface; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.UI; using LightlessSync.UI.Models; using LightlessSync.API.Dto.Group; using LightlessSync.API.Dto.User; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using LightlessSync.WebAPI; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Threading; namespace LightlessSync.Services; public class BroadcastService : IHostedService, IMediatorSubscriber { private readonly ILogger _logger; private readonly ApiController _apiController; private readonly LightlessMediator _mediator; private readonly LightlessConfigService _config; private readonly DalamudUtilService _dalamudUtil; private CancellationTokenSource? _lightfinderCancelTokens; private Action? _connectedHandler; public LightlessMediator Mediator => _mediator; public bool IsLightFinderAvailable { get; private set; } = false; public bool IsBroadcasting => _config.Current.BroadcastEnabled; private bool _syncedOnStartup = false; private bool _waitingForTtlFetch = false; private TimeSpan? _remainingTtl = null; private DateTime _lastTtlCheck = DateTime.MinValue; private DateTime _lastForcedDisableTime = DateTime.MinValue; private static readonly TimeSpan _disableCooldown = TimeSpan.FromSeconds(5); public TimeSpan? RemainingTtl => _remainingTtl; public TimeSpan? RemainingCooldown { get { var elapsed = DateTime.UtcNow - _lastForcedDisableTime; if (elapsed >= _disableCooldown) return null; return _disableCooldown - elapsed; } } public BroadcastService(ILogger logger, LightlessMediator mediator, LightlessConfigService config, DalamudUtilService dalamudUtil, ApiController apiController) { _logger = logger; _mediator = mediator; _config = config; _dalamudUtil = dalamudUtil; _apiController = apiController; } private async Task RequireConnectionAsync(string context, Func action) { if (!_apiController.IsConnected) { _logger.LogDebug(context + " skipped, not connected"); return; } await action().ConfigureAwait(false); } private async Task GetLocalHashedCidAsync(string context) { try { var cid = await _dalamudUtil.GetCIDAsync().ConfigureAwait(false); return cid.ToString().GetHash256(); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to resolve CID for {Context}", context); return null; } } private void ApplyBroadcastDisabled(bool forcePublish = false) { bool wasEnabled = _config.Current.BroadcastEnabled; bool hadExpiry = _config.Current.BroadcastTtl != DateTime.MinValue; bool hadRemaining = _remainingTtl.HasValue; _config.Current.BroadcastEnabled = false; _config.Current.BroadcastTtl = DateTime.MinValue; if (wasEnabled || hadExpiry) _config.Save(); _remainingTtl = null; _waitingForTtlFetch = false; _syncedOnStartup = false; if (forcePublish || wasEnabled || hadRemaining) _mediator.Publish(new BroadcastStatusChangedMessage(false, null)); } private bool TryApplyBroadcastEnabled(TimeSpan? ttl, string context) { if (ttl is not { } validTtl || validTtl <= TimeSpan.Zero) { _logger.LogWarning("Lightfinder enable skipped ({Context}): invalid TTL ({TTL})", context, ttl); return false; } bool wasEnabled = _config.Current.BroadcastEnabled; TimeSpan? previousRemaining = _remainingTtl; DateTime previousExpiry = _config.Current.BroadcastTtl; var newExpiry = DateTime.UtcNow + validTtl; _config.Current.BroadcastEnabled = true; _config.Current.BroadcastTtl = newExpiry; if (!wasEnabled || previousExpiry != newExpiry) _config.Save(); _remainingTtl = validTtl; _waitingForTtlFetch = false; if (!wasEnabled || previousRemaining != validTtl) _mediator.Publish(new BroadcastStatusChangedMessage(true, validTtl)); _logger.LogInformation("Lightfinder broadcast enabled ({Context}), TTL: {TTL}", context, validTtl); return true; } private void HandleLightfinderUnavailable(string message, Exception? ex = null) { if (ex != null) _logger.LogWarning(ex, message); else _logger.LogWarning(message); IsLightFinderAvailable = false; ApplyBroadcastDisabled(forcePublish: true); } private void OnDisconnected() { IsLightFinderAvailable = false; ApplyBroadcastDisabled(forcePublish: true); _logger.LogDebug("Cleared Lightfinder state due to disconnect."); } public Task StartAsync(CancellationToken cancellationToken) { _mediator.Subscribe(this, OnEnableBroadcast); _mediator.Subscribe(this, OnBroadcastStatusChanged); _mediator.Subscribe(this, OnTick); _mediator.Subscribe(this, _ => OnDisconnected()); IsLightFinderAvailable = false; _lightfinderCancelTokens?.Cancel(); _lightfinderCancelTokens?.Dispose(); _lightfinderCancelTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _connectedHandler = () => _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token); _apiController.OnConnected += _connectedHandler; if (_apiController.IsConnected) _ = CheckLightfinderSupportAsync(_lightfinderCancelTokens.Token); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { _lightfinderCancelTokens?.Cancel(); _lightfinderCancelTokens?.Dispose(); _lightfinderCancelTokens = null; if (_connectedHandler is not null) { _apiController.OnConnected -= _connectedHandler; _connectedHandler = null; } _mediator.UnsubscribeAll(this); return Task.CompletedTask; } private async Task CheckLightfinderSupportAsync(CancellationToken cancellationToken) { try { while (!_apiController.IsConnected && !cancellationToken.IsCancellationRequested) await Task.Delay(250, cancellationToken).ConfigureAwait(false); if (cancellationToken.IsCancellationRequested) return; var hashedCid = await GetLocalHashedCidAsync("Lightfinder state check").ConfigureAwait(false); if (string.IsNullOrEmpty(hashedCid)) return; BroadcastStatusInfoDto? status = null; try { status = await _apiController.IsUserBroadcasting(hashedCid).ConfigureAwait(false); } catch (HubException ex) when (ex.Message.Contains("Method does not exist", StringComparison.OrdinalIgnoreCase)) { HandleLightfinderUnavailable("Lightfinder unavailable on server (required method missing).", ex); } if (!IsLightFinderAvailable) _logger.LogInformation("Lightfinder is available."); IsLightFinderAvailable = true; bool isBroadcasting = status?.IsBroadcasting == true; TimeSpan? ttl = status?.TTL; if (isBroadcasting) { if (ttl is not { } remaining || remaining <= TimeSpan.Zero) ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); if (TryApplyBroadcastEnabled(ttl, "server handshake")) { _syncedOnStartup = true; } else { isBroadcasting = false; } } if (!isBroadcasting) { ApplyBroadcastDisabled(forcePublish: true); _logger.LogInformation("Lightfinder is available but no active broadcast was found."); } if (_config.Current.LightfinderAutoEnableOnConnect && !isBroadcasting) { _logger.LogInformation("Auto-enabling Lightfinder broadcast after reconnect."); _mediator.Publish(new EnableBroadcastMessage(hashedCid, true)); _mediator.Publish(new NotificationMessage( "Broadcast Auto-Enabled", "Your Lightfinder broadcast has been automatically enabled.", NotificationType.Info)); } } catch (OperationCanceledException) { _logger.LogInformation("Lightfinder check was canceled."); } catch (Exception ex) { HandleLightfinderUnavailable("Lightfinder check failed.", ex); } } private void OnEnableBroadcast(EnableBroadcastMessage msg) { _ = RequireConnectionAsync(nameof(OnEnableBroadcast), async () => { try { GroupBroadcastRequestDto? groupDto = null; if (_config.Current.SyncshellFinderEnabled && _config.Current.SelectedFinderSyncshell != null) { groupDto = new GroupBroadcastRequestDto { HashedCID = msg.HashedCid, GID = _config.Current.SelectedFinderSyncshell, Enabled = msg.Enabled, }; } await _apiController.SetBroadcastStatus(msg.Enabled, groupDto).ConfigureAwait(false); _logger.LogDebug("Broadcast {Status} for {Cid}", msg.Enabled ? "enabled" : "disabled", msg.HashedCid); if (!msg.Enabled) { ApplyBroadcastDisabled(forcePublish: true); Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Disabled Lightfinder for Player: {msg.HashedCid}"))); return; } _waitingForTtlFetch = true; try { TimeSpan? ttl = await GetBroadcastTtlAsync(msg.HashedCid).ConfigureAwait(false); if (TryApplyBroadcastEnabled(ttl, "client request")) { _logger.LogDebug("Fetched TTL from server: {TTL}", ttl); Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(BroadcastService), Services.Events.EventSeverity.Informational, $"Enabled Lightfinder for Player: {msg.HashedCid}"))); } else { ApplyBroadcastDisabled(forcePublish: true); _logger.LogWarning("No valid TTL returned after enabling broadcast. Disabling."); } } finally { _waitingForTtlFetch = false; } } catch (Exception ex) { _logger.LogError(ex, "Failed to toggle broadcast for {Cid}", msg.HashedCid); _waitingForTtlFetch = false; } }); } private void OnBroadcastStatusChanged(BroadcastStatusChangedMessage msg) { _config.Current.BroadcastEnabled = msg.Enabled; _config.Save(); } public async Task CheckIfBroadcastingAsync(string targetCid) { bool result = false; await RequireConnectionAsync(nameof(CheckIfBroadcastingAsync), async () => { try { _logger.LogDebug("[BroadcastCheck] Checking CID: {cid}", targetCid); var info = await _apiController.IsUserBroadcasting(targetCid).ConfigureAwait(false); result = info?.TTL > TimeSpan.Zero; _logger.LogDebug("[BroadcastCheck] Result for {cid}: {result} (TTL: {ttl}, GID: {gid})", targetCid, result, info?.TTL, info?.GID); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to check broadcast status for {cid}", targetCid); } }).ConfigureAwait(false); return result; } public async Task GetBroadcastTtlAsync(string? cidForLog = null) { TimeSpan? ttl = null; await RequireConnectionAsync(nameof(GetBroadcastTtlAsync), async () => { try { ttl = await _apiController.GetBroadcastTtl().ConfigureAwait(false); } catch (Exception ex) { if (cidForLog is { Length: > 0 }) { _logger.LogWarning(ex, "Failed to fetch broadcast TTL for {Cid}", cidForLog); } else { _logger.LogWarning(ex, "Failed to fetch broadcast TTL"); } } }).ConfigureAwait(false); return ttl; } public async Task> AreUsersBroadcastingAsync(List hashedCids) { Dictionary result = new(); await RequireConnectionAsync(nameof(AreUsersBroadcastingAsync), async () => { try { var batch = await _apiController.AreUsersBroadcasting(hashedCids).ConfigureAwait(false); if (batch?.Results != null) { foreach (var kv in batch.Results) result[kv.Key] = kv.Value; } _logger.LogTrace("Batch broadcast status check complete for {Count} CIDs", hashedCids.Count); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to batch check broadcast status"); } }).ConfigureAwait(false); return result; } public async void ToggleBroadcast() { if (!IsLightFinderAvailable) { _logger.LogWarning("ToggleBroadcast - Lightfinder is not available."); _mediator.Publish(new NotificationMessage( "Broadcast Unavailable", "Lightfinder is not available on this server.", NotificationType.Error)); return; } await RequireConnectionAsync(nameof(ToggleBroadcast), async () => { var cooldown = RemainingCooldown; if (!_config.Current.BroadcastEnabled && cooldown is { } cd && cd > TimeSpan.Zero) { _logger.LogWarning("Cooldown active. Must wait {Remaining}s before re-enabling.", cd.TotalSeconds); _mediator.Publish(new NotificationMessage( "Broadcast Cooldown", $"Please wait {cd.TotalSeconds:F0} seconds before re-enabling broadcast.", NotificationType.Warning)); return; } var hashedCid = await GetLocalHashedCidAsync(nameof(ToggleBroadcast)).ConfigureAwait(false); if (string.IsNullOrEmpty(hashedCid)) { _logger.LogWarning("ToggleBroadcast - unable to resolve CID."); return; } try { var isCurrentlyBroadcasting = await CheckIfBroadcastingAsync(hashedCid).ConfigureAwait(false); var newStatus = !isCurrentlyBroadcasting; if (!newStatus) { _lastForcedDisableTime = DateTime.UtcNow; _logger.LogDebug("Manual disable: cooldown timer started."); } _logger.LogDebug("Toggling broadcast. Server currently broadcasting: {ServerStatus}, setting to: {NewStatus}", isCurrentlyBroadcasting, newStatus); _mediator.Publish(new EnableBroadcastMessage(hashedCid, newStatus)); _mediator.Publish(new NotificationMessage( newStatus ? "Broadcast Enabled" : "Broadcast Disabled", newStatus ? "Your Lightfinder broadcast has been enabled." : "Your Lightfinder broadcast has been disabled.", NotificationType.Info)); } catch (Exception ex) { _logger.LogError(ex, "Failed to determine current broadcast status for toggle"); _mediator.Publish(new NotificationMessage( "Broadcast Toggle Failed", $"Failed to toggle broadcast: {ex.Message}", NotificationType.Error)); } }).ConfigureAwait(false); } private async void OnTick(PriorityFrameworkUpdateMessage _) { if (!IsLightFinderAvailable) return; if (_config?.Current == null) return; if ((DateTime.UtcNow - _lastTtlCheck).TotalSeconds < 1) return; _lastTtlCheck = DateTime.UtcNow; await RequireConnectionAsync(nameof(OnTick), async () => { if (!_syncedOnStartup && _config.Current.BroadcastEnabled) { try { var hashedCid = await GetLocalHashedCidAsync("startup TTL refresh").ConfigureAwait(false); if (string.IsNullOrEmpty(hashedCid)) { _logger.LogDebug("Skipping TTL refresh; hashed CID unavailable."); return; } TimeSpan? ttl = await GetBroadcastTtlAsync(hashedCid).ConfigureAwait(false); if (TryApplyBroadcastEnabled(ttl, "startup TTL refresh")) { _syncedOnStartup = true; _logger.LogDebug("Refreshed broadcast TTL from server on first OnTick: {TTL}", ttl); } else { _logger.LogWarning("No valid TTL found on OnTick. Disabling broadcast state."); ApplyBroadcastDisabled(forcePublish: true); } } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh TTL in OnTick"); _syncedOnStartup = false; } } if (_config.Current.BroadcastEnabled) { if (_waitingForTtlFetch) { _logger.LogDebug("OnTick skipped: waiting for TTL fetch"); return; } DateTime expiry = _config.Current.BroadcastTtl; TimeSpan remaining = expiry - DateTime.UtcNow; _remainingTtl = remaining > TimeSpan.Zero ? remaining : null; if (_remainingTtl == null) { _logger.LogDebug("Broadcast TTL expired. Disabling broadcast locally."); ApplyBroadcastDisabled(forcePublish: true); ShowBroadcastExpiredNotification(); } } else { _remainingTtl = null; } }).ConfigureAwait(false); } private void ShowBroadcastExpiredNotification() { var notification = new LightlessNotification { Id = "broadcast_expired", Title = "Broadcast Expired", Message = "Your Lightfinder broadcast has expired after 3 hours. Would you like to re-enable it?", Type = NotificationType.PairRequest, Duration = TimeSpan.FromSeconds(180), Actions = new List { new() { Id = "re_enable", Label = "Re-enable", Icon = FontAwesomeIcon.Plus, Color = UIColors.Get("PairBlue"), IsPrimary = true, OnClick = (n) => { _logger.LogInformation("Re-enabling broadcast from notification"); ToggleBroadcast(); n.IsDismissed = true; n.IsAnimatingOut = true; } }, new() { Id = "close", Label = "Close", Icon = FontAwesomeIcon.Times, Color = UIColors.Get("DimRed"), OnClick = (n) => { _logger.LogInformation("Broadcast expiration notification dismissed"); n.IsDismissed = true; n.IsAnimatingOut = true; } } } }; _mediator.Publish(new LightlessNotificationMessage(notification)); } }