using System.Text.Json;
using Google.Protobuf;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;

namespace ZB.MOM.WW.MxGateway.Tests.Contracts;

/// <summary>
/// Tests for behavior fixture manifests and contract conformance.
/// </summary>
public sealed class ClientBehaviorFixtureTests
{
    private static readonly JsonParser ProtobufJsonParser = new(JsonParser.Settings.Default);

    /// <summary>
    /// Verifies the behavior manifest declares correct protocol versions and fixture references.
    /// </summary>
    [Fact]
    public void BehaviorManifest_DeclaresCurrentProtocolVersionsAndExistingFixtures()
    {
        using JsonDocument manifest = LoadBehaviorManifest();
        JsonElement root = manifest.RootElement;

        Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
        Assert.Equal("mxaccess-gateway-client-behavior", root.GetProperty("fixtureSet").GetString());
        Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
        Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());

        HashSet<string> fixtureIds = new(StringComparer.Ordinal);
        foreach (JsonElement fixture in root.GetProperty("fixtures").EnumerateArray())
        {
            string id = fixture.GetProperty("id").GetString()!;
            string path = fixture.GetProperty("path").GetString()!;
            string category = fixture.GetProperty("category").GetString()!;
            string messageType = fixture.GetProperty("messageType").GetString()!;

            Assert.True(fixtureIds.Add(id), $"Duplicate behavior fixture id '{id}'.");
            Assert.Contains(category, KnownCategories);
            Assert.Contains(messageType, KnownMessageTypes);
            Assert.True(
                File.Exists(Path.Combine(GetBehaviorFixtureRoot().FullName, path)),
                $"Expected behavior fixture '{path}' to exist.");
            Assert.False(Path.IsPathRooted(path), $"Fixture path '{path}' must be relative.");
            Assert.NotEmpty(fixture.GetProperty("expectation").GetString()!);
        }
    }

    /// <summary>
    /// Verifies proto inputs manifest references the expected behavior fixture root.
    /// </summary>
    [Fact]
    public void ProtoInputManifest_ReferencesBehaviorFixtureRoot()
    {
        DirectoryInfo repositoryRoot = FindRepositoryRoot();
        string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");

        using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
        string fixtureRoot = manifest.RootElement.GetProperty("behaviorFixtureRoot").GetString()!;

        Assert.Equal("clients/proto/fixtures/behavior", fixtureRoot);
        Assert.True(Directory.Exists(Path.Combine(repositoryRoot.FullName, fixtureRoot)));
    }

    /// <summary>
    /// Verifies command reply fixtures parse and preserve MXAccess details.
    /// </summary>
    [Fact]
    public void CommandReplyFixtures_ParseWithCurrentContractAndPreserveMxAccessDetails()
    {
        IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("command_replies");
        Assert.NotEmpty(fixtures);

        foreach (JsonElement fixture in fixtures)
        {
            string fixtureId = GetFixtureId(fixture);
            MxCommandReply reply = ParseFixture<MxCommandReply>(
                fixture,
                MxCommandReply.Parser);

            // Universal invariants: every command-reply fixture parses to a concrete
            // command kind and a concrete protocol status, regardless of what MXAccess
            // reply detail (if any) it carries.
            Assert.NotEqual(MxCommandKind.Unspecified, reply.Kind);
            Assert.NotEqual(ProtocolStatusCode.Unspecified, reply.ProtocolStatus.Code);

            // The malformed-reply and credential-redaction fixtures
            // (command-reply.authenticate-user.*) deliberately omit hresult, statuses,
            // and/or return_value to exercise the "absent detail" contract paths — see
            // docs/ClientBehaviorFixtures.md. The strict MXAccess-reply-detail
            // invariants below apply only to fixtures that carry that detail.
            if (fixtureId.StartsWith("command-reply.authenticate-user.", StringComparison.Ordinal))
            {
                continue;
            }

            Assert.True(reply.HasHresult, $"Fixture '{fixtureId}' must carry an HRESULT.");
            Assert.NotEmpty(reply.Statuses);
            Assert.NotEqual(MxDataType.Unspecified, reply.ReturnValue.DataType);
            Assert.True(
                reply.ReturnValue.KindCase != MxValue.KindOneofCase.None || reply.ReturnValue.IsNull,
                $"Fixture '{fixtureId}' must carry a typed value, raw value, or explicit null.");
        }

        MxCommandReply failedWrite = ParseFixture<MxCommandReply>(
            Assert.Single(fixtures, fixture => GetFixtureId(fixture) == "command-reply.write.mxaccess-failure"),
            MxCommandReply.Parser);

        Assert.Equal(ProtocolStatusCode.MxaccessFailure, failedWrite.ProtocolStatus.Code);
        Assert.Equal(-2147220992, failedWrite.Hresult);
        Assert.True(failedWrite.Statuses.Count > 1);
        Assert.All(failedWrite.Statuses, status => Assert.Equal(0, status.Success));
    }

    /// <summary>
    /// Verifies event stream fixtures have monotonic sequences and expected event families.
    /// </summary>
    [Fact]
    public void EventStreamFixtures_ParseWithMonotonicSequencesAndExpectedFamilies()
    {
        IReadOnlyList<JsonElement> fixtures = LoadManifestFixtures("event_streams");
        Assert.NotEmpty(fixtures);

        foreach (JsonElement fixture in fixtures)
        {
            using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));
            ulong previousSequence = 0;
            List<MxEventFamily> families = [];

            foreach (JsonElement eventElement in document.RootElement.GetProperty("events").EnumerateArray())
            {
                MxEvent gatewayEvent = ProtobufJsonParser.Parse<MxEvent>(eventElement.GetRawText());

                Assert.True(gatewayEvent.WorkerSequence > previousSequence);
                Assert.Equal(document.RootElement.GetProperty("sessionId").GetString(), gatewayEvent.SessionId);
                Assert.NotEmpty(gatewayEvent.Statuses);
                AssertEventBodyMatchesFamily(gatewayEvent);

                previousSequence = gatewayEvent.WorkerSequence;
                families.Add(gatewayEvent.Family);
            }

            Assert.Contains(MxEventFamily.OnDataChange, families);
            Assert.Contains(MxEventFamily.OnWriteComplete, families);
            Assert.Contains(MxEventFamily.OperationComplete, families);
            Assert.Contains(MxEventFamily.OnBufferedDataChange, families);
        }
    }

    /// <summary>
    /// Verifies value conversion fixtures parse typed values and raw fallbacks.
    /// </summary>
    [Fact]
    public void ValueConversionFixtures_ParseTypedValuesAndRawFallbacks()
    {
        JsonElement cases = LoadCaseSet("value_conversion", "cases");
        bool sawRawFallback = false;
        bool sawRawArrayFallback = false;
        bool sawTypedArray = false;

        foreach (JsonElement valueCase in cases.EnumerateArray())
        {
            MxValue value = ProtobufJsonParser.Parse<MxValue>(
                valueCase.GetProperty("value").GetRawText());
            string expectedKind = valueCase.GetProperty("expectedKind").GetString()!;

            Assert.NotEqual(MxDataType.Unspecified, value.DataType);
            AssertJsonKindMatchesValueKind(expectedKind, value);

            sawRawFallback |= value.KindCase == MxValue.KindOneofCase.RawValue
                && !string.IsNullOrWhiteSpace(value.RawDiagnostic)
                && value.RawDataType != 0;
            sawRawArrayFallback |= value.KindCase == MxValue.KindOneofCase.ArrayValue
                && value.ArrayValue.ValuesCase == MxArray.ValuesOneofCase.RawValues
                && !string.IsNullOrWhiteSpace(value.ArrayValue.RawDiagnostic)
                && value.ArrayValue.RawElementDataType != 0;
            sawTypedArray |= value.KindCase == MxValue.KindOneofCase.ArrayValue
                && value.ArrayValue.ValuesCase != MxArray.ValuesOneofCase.RawValues;
        }

        Assert.True(sawRawFallback, "Expected at least one raw scalar fallback case.");
        Assert.True(sawRawArrayFallback, "Expected at least one raw array fallback case.");
        Assert.True(sawTypedArray, "Expected at least one typed array case.");
    }

    /// <summary>
    /// Verifies status conversion fixtures parse status arrays and raw fields.
    /// </summary>
    [Fact]
    public void StatusConversionFixtures_ParseStatusArraysAndRawFields()
    {
        JsonElement cases = LoadCaseSet("status_conversion", "cases");
        bool sawRawUnknown = false;

        foreach (JsonElement statusCase in cases.EnumerateArray())
        {
            MxStatusProxy status = ProtobufJsonParser.Parse<MxStatusProxy>(
                statusCase.GetProperty("status").GetRawText());

            Assert.NotEqual(MxStatusCategory.Unspecified, status.Category);
            Assert.NotEqual(MxStatusSource.Unspecified, status.DetectedBy);
            Assert.NotEmpty(status.DiagnosticText);

            sawRawUnknown |= status.Category == MxStatusCategory.Unknown
                && status.RawCategory != 0
                && status.RawDetectedBy != 0;
        }

        Assert.True(sawRawUnknown, "Expected a status case with unknown raw native fields.");
    }

    /// <summary>
    /// Verifies auth error fixtures map authentication/authorization and redact credentials.
    /// </summary>
    [Fact]
    public void AuthErrorFixtures_MapAuthenticationAuthorizationAndRedactCredentials()
    {
        JsonElement cases = LoadCaseSet("auth_errors", "cases");
        HashSet<string> statusCodes = new(StringComparer.Ordinal);

        foreach (JsonElement authCase in cases.EnumerateArray())
        {
            string grpcStatusCode = authCase.GetProperty("grpcStatusCode").GetString()!;
            string category = authCase.GetProperty("clientErrorCategory").GetString()!;
            string redactedOutput = authCase.GetProperty("expectedRedactedOutput").GetString()!;
            string serialized = authCase.GetRawText();

            Assert.Contains(grpcStatusCode, AuthGrpcStatusCodes);
            Assert.Contains(category, AuthClientErrorCategories);
            string authorization = authCase.GetProperty("inputMetadata").GetProperty("authorization").GetString()!;
            if (!string.IsNullOrEmpty(authorization))
            {
                Assert.Contains("<redacted>", serialized);
            }

            Assert.DoesNotContain("mxgw_", serialized, StringComparison.Ordinal);
            Assert.DoesNotContain("secret", redactedOutput, StringComparison.OrdinalIgnoreCase);

            statusCodes.Add(grpcStatusCode);
        }

        Assert.Contains("UNAUTHENTICATED", statusCodes);
        Assert.Contains("PERMISSION_DENIED", statusCodes);
    }

    /// <summary>Verifies timeout and cancellation test fixtures document client and worker behavior.</summary>
    [Fact]
    public void TimeoutCancelFixtures_DocumentClientWaitAndWorkerCommandBehavior()
    {
        JsonElement cases = LoadCaseSet("timeout_cancel", "cases");
        HashSet<string> statusCodes = new(StringComparer.Ordinal);

        foreach (JsonElement timeoutCase in cases.EnumerateArray())
        {
            string grpcStatusCode = timeoutCase.GetProperty("grpcStatusCode").GetString()!;

            Assert.Contains(grpcStatusCode, TimeoutGrpcStatusCodes);
            Assert.NotEmpty(timeoutCase.GetProperty("clientDeadline").GetString()!);
            Assert.NotEmpty(timeoutCase.GetProperty("gatewayWaitBehavior").GetString()!);
            Assert.NotEmpty(timeoutCase.GetProperty("workerCommandBehavior").GetString()!);
            Assert.NotEmpty(timeoutCase.GetProperty("expectedClientAction").GetString()!);

            statusCodes.Add(grpcStatusCode);
        }

        Assert.Contains("DEADLINE_EXCEEDED", statusCodes);
        Assert.Contains("CANCELLED", statusCodes);
    }

    private static readonly string[] KnownCategories =
    [
        "command_replies",
        "event_streams",
        "value_conversion",
        "status_conversion",
        "auth_errors",
        "timeout_cancel",
    ];

    private static readonly string[] KnownMessageTypes =
    [
        "mxaccess_gateway.v1.MxCommandReply",
        "mxaccess_gateway.v1.MxEvent",
        "mxaccess_gateway.v1.MxValue",
        "mxaccess_gateway.v1.MxStatusProxy",
        "client_behavior.v1.AuthErrorCase",
        "client_behavior.v1.TimeoutCancelCase",
    ];

    private static readonly string[] AuthGrpcStatusCodes =
    [
        "UNAUTHENTICATED",
        "PERMISSION_DENIED",
    ];

    private static readonly string[] AuthClientErrorCategories =
    [
        "AuthenticationError",
        "AuthorizationError",
    ];

    private static readonly string[] TimeoutGrpcStatusCodes =
    [
        "DEADLINE_EXCEEDED",
        "CANCELLED",
    ];

    private static T ParseFixture<T>(
        JsonElement fixture,
        MessageParser<T> parser)
        where T : IMessage<T>
    {
        return parser.ParseJson(File.ReadAllText(GetFixturePath(fixture)));
    }

    private static JsonElement LoadCaseSet(
        string category,
        string propertyName)
    {
        JsonElement fixture = Assert.Single(LoadManifestFixtures(category));
        using JsonDocument document = JsonDocument.Parse(File.ReadAllText(GetFixturePath(fixture)));

        return document.RootElement.GetProperty(propertyName).Clone();
    }

    private static IReadOnlyList<JsonElement> LoadManifestFixtures(string category)
    {
        using JsonDocument manifest = LoadBehaviorManifest();

        return manifest.RootElement
            .GetProperty("fixtures")
            .EnumerateArray()
            .Where(fixture => fixture.GetProperty("category").GetString() == category)
            .Select(fixture => fixture.Clone())
            .ToArray();
    }

    private static JsonDocument LoadBehaviorManifest()
    {
        return JsonDocument.Parse(File.ReadAllText(Path.Combine(GetBehaviorFixtureRoot().FullName, "manifest.json")));
    }

    private static string GetFixturePath(JsonElement fixture)
    {
        return Path.Combine(GetBehaviorFixtureRoot().FullName, fixture.GetProperty("path").GetString()!);
    }

    private static string GetFixtureId(JsonElement fixture)
    {
        return fixture.GetProperty("id").GetString()!;
    }

    private static DirectoryInfo GetBehaviorFixtureRoot()
    {
        DirectoryInfo repositoryRoot = FindRepositoryRoot();

        return new DirectoryInfo(Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "behavior"));
    }

    private static DirectoryInfo FindRepositoryRoot()
    {
        DirectoryInfo? current = new(AppContext.BaseDirectory);

        while (current is not null)
        {
            if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
                && Directory.Exists(Path.Combine(current.FullName, "src"))
                && Directory.Exists(Path.Combine(current.FullName, "clients")))
            {
                return current;
            }

            current = current.Parent;
        }

        throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory.");
    }

    private static void AssertEventBodyMatchesFamily(MxEvent gatewayEvent)
    {
        switch (gatewayEvent.Family)
        {
            case MxEventFamily.OnDataChange:
                Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
                break;
            case MxEventFamily.OnWriteComplete:
                Assert.Equal(MxEvent.BodyOneofCase.OnWriteComplete, gatewayEvent.BodyCase);
                break;
            case MxEventFamily.OperationComplete:
                Assert.Equal(MxEvent.BodyOneofCase.OperationComplete, gatewayEvent.BodyCase);
                break;
            case MxEventFamily.OnBufferedDataChange:
                Assert.Equal(MxEvent.BodyOneofCase.OnBufferedDataChange, gatewayEvent.BodyCase);
                break;
            default:
                throw new InvalidOperationException($"Unexpected event family '{gatewayEvent.Family}'.");
        }
    }

    private static void AssertJsonKindMatchesValueKind(
        string expectedKind,
        MxValue value)
    {
        MxValue.KindOneofCase expected = expectedKind switch
        {
            "boolValue" => MxValue.KindOneofCase.BoolValue,
            "int32Value" => MxValue.KindOneofCase.Int32Value,
            "int64Value" => MxValue.KindOneofCase.Int64Value,
            "floatValue" => MxValue.KindOneofCase.FloatValue,
            "doubleValue" => MxValue.KindOneofCase.DoubleValue,
            "stringValue" => MxValue.KindOneofCase.StringValue,
            "timestampValue" => MxValue.KindOneofCase.TimestampValue,
            "arrayValue" => MxValue.KindOneofCase.ArrayValue,
            "rawValue" => MxValue.KindOneofCase.RawValue,
            _ => throw new InvalidOperationException($"Unexpected expected value kind '{expectedKind}'."),
        };

        Assert.Equal(expected, value.KindCase);
    }
}
