using System.Text.Json;
using DeepDrftModels.Enums;
using DeepDrftPublic.Client.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

namespace DeepDrftTests;

/// <summary>
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
/// </summary>
[TestFixture]
public class AnonIdPayloadTests
{
    // Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
    // fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
    private sealed class CapturingJsRuntime : IJSRuntime
    {
        public string? LastJson { get; private set; }

        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
        {
            if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
                LastJson = json;
            return ValueTask.FromResult<TValue>(default!);
        }

        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
            => InvokeAsync<TValue>(identifier, args);
    }

    private sealed class StubAnonIdProvider : IAnonIdProvider
    {
        public StubAnonIdProvider(string? current) => Current = current;
        public string? Current { get; }
        public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
    }

    private sealed class TestNavigationManager : NavigationManager
    {
        public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
        protected override void NavigateToCore(string uri, bool forceLoad) { }
    }

    // The sinks serialize with default (PascalCase) property names; the API binds case-insensitively, so
    // the wire field is "AnonId". Match it case-insensitively here so the test asserts the value, not the
    // casing convention. Returns (present, value) while the document is still alive.
    private static (bool Present, string? Value) FindAnonId(string json)
    {
        using var doc = JsonDocument.Parse(json);
        foreach (var prop in doc.RootElement.EnumerateObject())
        {
            if (string.Equals(prop.Name, "anonId", StringComparison.OrdinalIgnoreCase))
                return (true, prop.Value.GetString());
        }
        return (false, null);
    }

    private static string? ReadAnonId(string json) => FindAnonId(json).Value;

    private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;

    // A play emitted while the provider holds a token carries that token in the payload.
    [Test]
    public void PlaySink_WithAnonId_IncludesItInPayload()
    {
        var js = new CapturingJsRuntime();
        var sink = new BeaconPlayEventSink(
            new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());

        sink.EmitPlay("track-key", PlayBucket.Complete);

        Assert.That(js.LastJson, Is.Not.Null);
        Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
    }

    // A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
    // entirely rather than sending anonId:null.
    [Test]
    public void PlaySink_WithoutAnonId_OmitsItFromPayload()
    {
        var js = new CapturingJsRuntime();
        var sink = new BeaconPlayEventSink(
            new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());

        sink.EmitPlay("track-key", PlayBucket.Partial);

        Assert.That(js.LastJson, Is.Not.Null);
        Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
    }

    // A share recorded while the provider holds a token carries it in the payload.
    [Test]
    public void ShareTracker_WithAnonId_IncludesItInPayload()
    {
        var js = new CapturingJsRuntime();
        var tracker = new ShareTracker(
            new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());

        tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);

        Assert.That(js.LastJson, Is.Not.Null);
        Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
    }

    // A share recorded with no token omits anonId from the payload.
    [Test]
    public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
    {
        var js = new CapturingJsRuntime();
        var tracker = new ShareTracker(
            new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());

        tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);

        Assert.That(js.LastJson, Is.Not.Null);
        Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
    }

    // A JS runtime that throws on every call — models localStorage interop being unavailable (private
    // mode, blocked storage, or the module not yet imported during prerender).
    private sealed class ThrowingJsRuntime : IJSRuntime
    {
        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
            => throw new InvalidOperationException("interop unavailable");

        public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
            => throw new InvalidOperationException("interop unavailable");
    }

    // The real AnonIdProvider degrades to null (no throw) when the localStorage interop is unavailable —
    // the acceptance-criterion "degrades safely if localStorage is unavailable". EnsureLoadedAsync
    // swallows the interop failure and leaves Current null.
    [Test]
    public async Task AnonIdProvider_WhenInteropUnavailable_DegradesToNullWithoutThrowing()
    {
        var provider = new AnonIdProvider(new ThrowingJsRuntime());

        await provider.EnsureLoadedAsync();   // must not throw

        Assert.That(provider.Current, Is.Null);
    }
}
