using System.Text.Json;
using System.Text.Json.Serialization;
using DeepDrftModels.DTOs;
using DeepDrftModels.Enums;
using Microsoft.AspNetCore.Components;
namespace DeepDrftPublic.Client.Services;
///
/// Records share events from SharePopover (Phase 16 §1b / §2.1). After a successful clipboard
/// write the popover calls ; this tracker applies the per-(target,channel)
/// debounce — at most one event per target+channel per per session — and
/// fires the event via the first-party POST to the proxied api/event/share
/// route. A share is always a user-interaction close with the page alive (never a tab-unload), so it uses
/// the fetch transport unconditionally — there is no sendBeacon arm here (telemetry
/// transport-resilience).
///
///
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
/// The POST is fire-and-forget; the current anonId (wave 16.3) is read synchronously from the
/// warmed cache and omitted when null.
///
///
public sealed class ShareTracker
{
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
// recommendation — long enough to fold a flurry of repeat copies into one intent.
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
// change the integer enum encoding the 16.1 contract relies on.
private static readonly JsonSerializerOptions EventJson =
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
private readonly IEventPoster _poster;
private readonly IAnonIdProvider _anonId;
private readonly string _shareUrl;
private readonly Dictionary _lastSent = new();
public ShareTracker(IEventPoster poster, IAnonIdProvider anonId, NavigationManager navigation)
{
_poster = poster;
_anonId = anonId;
_shareUrl = $"{navigation.BaseUri}api/event/share";
}
///
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
/// debounce without reaching into the beacon.
///
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
///
/// Debounce-aware record with an injectable so the 60s window is testable
/// without wall-clock waits. The parameterless overload above passes .
///
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(targetKey))
return false;
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
return false;
_lastSent[dedupeKey] = now;
var json = JsonSerializer.Serialize(new ShareEventDto
{
TargetType = targetType,
TargetKey = targetKey,
Channel = channel,
AnonId = _anonId.Current,
}, EventJson);
// Fire-and-forget first-party POST — a dropped share telemetry event is acceptable.
_ = _poster.PostAsync(_shareUrl, json);
return true;
}
}