using ZB.MOM.WW.MxGateway.Client;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;

/// <summary>
///     Pure-construction + lifecycle coverage of <see cref="GalaxyBrowseSession"/>.
///     The session is <c>internal</c>; visibility comes via <c>InternalsVisibleTo</c>
///     on the production project.
///     <para>
///         <b>Blocker:</b> the hierarchy/expand/attribute paths all call into
///         <see cref="GalaxyRepositoryClient"/>, which only ships an <c>internal</c>
///         transport seam (<c>IGalaxyRepositoryClientTransport</c>) and an <c>internal</c>
///         constructor — both keyed via <c>InternalsVisibleTo</c> on the vendored
///         <c>ZB.MOM.WW.MxGateway.Client</c> assembly, and only granted to that repo's own
///         <c>ZB.MOM.WW.MxGateway.Client.Tests</c>. We can't substitute a fake transport from
///         here without changing the upstream repo, and the public <c>Create</c>
///         factory always opens a real gRPC channel. So in-memory traversal coverage
///         (RootAsync / ExpandAsync / AttributesAsync, including the SecurityClass
///         mapping) is deferred to the integration suite (Task 17) and the manual
///         smoke pass (Task 18) — both of which run the gateway for real.
///     </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class GalaxyBrowseSessionTests
{
    /// <summary>Builds a <see cref="GalaxyRepositoryClient"/> bound to an
    /// unreachable endpoint. No connection is opened — <c>Create</c> just builds the
    /// gRPC channel object — so this is safe to call without a fixture.</summary>
    private static GalaxyRepositoryClient NewClient() =>
        GalaxyRepositoryClient.Create(new MxGatewayClientOptions
        {
            Endpoint = new Uri("http://127.0.0.1:1"),
            ApiKey = "test-key",
            UseTls = false,
            ConnectTimeout = TimeSpan.FromSeconds(1),
            DefaultCallTimeout = TimeSpan.FromSeconds(1),
        });

    /// <summary>The internal ctor must reject a null client — the production caller
    /// (the factory in <c>GalaxyDriverBrowser.OpenAsync</c>) hands off ownership of a
    /// real client and never passes null, but defence-in-depth catches a future caller
    /// who skips that handoff.</summary>
    [Fact]
    public void Constructor_with_null_client_throws_ArgumentNullException()
    {
        Should.Throw<ArgumentNullException>(() => new GalaxyBrowseSession(null!));
    }

    /// <summary>Each session must publish a distinct <see cref="GalaxyBrowseSession.Token"/>
    /// so the AdminUI registry can disambiguate concurrent browse sessions against the
    /// same driver config.</summary>
    [Fact]
    public async Task Token_is_unique_per_session()
    {
        await using var a = new GalaxyBrowseSession(NewClient());
        await using var b = new GalaxyBrowseSession(NewClient());
        a.Token.ShouldNotBe(b.Token);
        a.Token.ShouldNotBe(Guid.Empty);
    }

    /// <summary><see cref="GalaxyBrowseSession.LastUsedUtc"/> is primed to the
    /// construction time so the registry reaper has a sensible baseline before the
    /// first Root/Expand/Attributes call lands.</summary>
    [Fact]
    public async Task LastUsedUtc_is_initialized_at_construction()
    {
        var before = DateTime.UtcNow;
        await using var session = new GalaxyBrowseSession(NewClient());
        var after = DateTime.UtcNow;
        // Allow generous slop — the field is set inside the ctor body, both bookends
        // are wall-clock UtcNow, and we only care that it isn't default(DateTime).
        session.LastUsedUtc.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
        session.LastUsedUtc.ShouldBeLessThanOrEqualTo(after.AddSeconds(1));
    }

    /// <summary><see cref="GalaxyBrowseSession.DisposeAsync"/> is idempotent — the
    /// registry's reaper may race a client-initiated close, so the second call must
    /// no-op rather than throw <see cref="ObjectDisposedException"/> or hit the
    /// already-disposed gRPC channel.</summary>
    [Fact]
    public async Task DisposeAsync_is_idempotent()
    {
        var session = new GalaxyBrowseSession(NewClient());
        await session.DisposeAsync();
        // Second call should silently no-op.
        await Should.NotThrowAsync(async () => await session.DisposeAsync());
    }

    /// <summary>After disposal, any <see cref="GalaxyBrowseSession.ExpandAsync"/> call
    /// must surface <see cref="ObjectDisposedException"/> — not a downstream channel
    /// fault — so the AdminUI sees a clean "session closed" signal.</summary>
    [Fact]
    public async Task ExpandAsync_after_dispose_throws_ObjectDisposedException()
    {
        var session = new GalaxyBrowseSession(NewClient());
        await session.DisposeAsync();
        await Should.ThrowAsync<ObjectDisposedException>(
            () => session.ExpandAsync("anything", TestContext.Current.CancellationToken));
    }

    /// <summary><see cref="GalaxyBrowseSession.ExpandAsync"/> must reject a tag that
    /// hasn't been seen by a prior Root/Expand call — the cache is the source of
    /// truth, and silently returning [] would mask AdminUI bugs that browse with a
    /// stale path.</summary>
    [Fact]
    public async Task ExpandAsync_unknown_tag_throws_ArgumentException()
    {
        await using var session = new GalaxyBrowseSession(NewClient());
        // No RootAsync call ⇒ cache is empty ⇒ any tag is unknown.
        await Should.ThrowAsync<ArgumentException>(
            () => session.ExpandAsync("Galaxy.Unknown", TestContext.Current.CancellationToken));
    }

    /// <summary>
    ///     Verifies that each of the seven defined Galaxy security_classification codes
    ///     maps to the correct label string — matching the <c>SecurityClassification</c>
    ///     enum ordinals and the runtime <c>SecurityMap</c> in Driver.Galaxy.
    ///     (Regression for Driver.Galaxy.Browser-001: codes 2–6 were all wrong before
    ///     this fix.)
    /// </summary>
    [Theory]
    [InlineData(0, "FreeAccess")]
    [InlineData(1, "Operate")]
    [InlineData(2, "SecuredWrite")]
    [InlineData(3, "VerifiedWrite")]
    [InlineData(4, "Tune")]
    [InlineData(5, "Configure")]
    [InlineData(6, "ViewOnly")]
    public void MapSecurityClass_maps_all_known_codes(int code, string expectedLabel)
    {
        GalaxyBrowseSession.MapSecurityClass(code).ShouldBe(expectedLabel);
    }

    /// <summary>
    ///     An unrecognised Galaxy security_classification code must produce an
    ///     <c>Unknown(N)</c> label — not throw and not silently return a valid class.
    /// </summary>
    [Theory]
    [InlineData(7)]
    [InlineData(99)]
    [InlineData(-1)]
    public void MapSecurityClass_unknown_code_returns_Unknown_label(int code)
    {
        var label = GalaxyBrowseSession.MapSecurityClass(code);
        label.ShouldStartWith("Unknown(");
        label.ShouldContain(code.ToString());
    }

    /// <summary>
    ///     Two concurrent <see cref="GalaxyBrowseSession.DisposeAsync"/> calls on the
    ///     same session must not throw. The registry reaper and a browser-side Close
    ///     can race in production.
    ///     (Regression for Driver.Galaxy.Browser-002: the second caller could throw
    ///     <see cref="ObjectDisposedException"/> from <c>SemaphoreSlim.Dispose()</c>.)
    /// </summary>
    [Fact]
    public async Task DisposeAsync_concurrent_calls_do_not_throw()
    {
        var session = new GalaxyBrowseSession(NewClient());
        // Fire two concurrent dispose calls and await both — neither must throw.
        await Should.NotThrowAsync(async () =>
        {
            await Task.WhenAll(session.DisposeAsync().AsTask(), session.DisposeAsync().AsTask());
        });
    }
}
