using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder;

namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live;

/// <summary>
///     End-to-end live validation of the gateway-backed historian backend against a real, running
///     <c>ZB.MOM.WW.HistorianGateway</c> sidecar (typically <c>wonder-sql-vd03</c> on the VPN). This is
///     the validation gate the operator runs on the VPN before the Wonderware backend's retirement is
///     trusted — every path exercised here is a <em>real</em> driver component, not a fake:
///     <list type="bullet">
///         <item><see cref="GatewayHistorianDataSource"/> — the read + <c>ReadEvents</c> path.</item>
///         <item><see cref="HistorianGatewayClientAdapter"/> — the <c>EnsureTags</c> seam.</item>
///         <item><see cref="GatewayHistorianValueWriter"/> — the recorder's <c>WriteLiveValues</c> path.</item>
///         <item><see cref="GatewayAlarmHistorianWriter"/> — the alarm <c>SendEvent</c> path.</item>
///     </list>
///     <para>
///         <b>Env-gated + skip-clean.</b> Every test calls <c>Assert.Skip</c> via the fixture when the
///         suite is unconfigured / the gateway is unreachable, and again when its own required tag /
///         source env var is absent — so <c>dotnet test --filter "Category=LiveIntegration"</c> stays
///         green offline (all skip, none fail). See <see cref="GatewayLiveFixture"/> for the env vars.
///     </para>
///     <para>
///         <b>Gateway prerequisites</b> (when run on the VPN): the target gateway must run with
///         <c>RuntimeDb:Enabled=true</c> (the <c>WriteLiveValues</c> SQL path) and
///         <c>RuntimeDb:EventReadsEnabled=true</c> (the SQL <c>ReadEvents</c> path), and the API key
///         must carry the <c>historian:read</c> + <c>historian:write</c> scopes.
///     </para>
/// </summary>
[Trait("Category", "LiveIntegration")]
public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IClassFixture<GatewayLiveFixture>
{
    private readonly GatewayLiveFixture _fx = fixture;

    /// <summary>
    ///     Read round-trip — <c>ReadRaw</c> for an existing tag over the last hour through the real
    ///     <see cref="GatewayHistorianDataSource"/>. Asserts the read completes without throwing and
    ///     returns a (possibly empty) sample set: a sparse tag legitimately has zero samples in the
    ///     window, so the meaningful live signal is "the gateway answered, not faulted".
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task Galaxy_tag_read_round_trip()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.TestTag is null)
            Assert.Skip("Skipped: set HISTGW_TEST_TAG to an existing Galaxy/historian tag to run the read round-trip.");

        var ct = TestContext.Current.CancellationToken;
        await using var dataSource = _fx.CreateDataSource();

        var endUtc = DateTime.UtcNow;
        var startUtc = endUtc - TimeSpan.FromHours(1);

        var result = await dataSource.ReadRawAsync(_fx.TestTag, startUtc, endUtc, maxValuesPerNode: 1000, ct);

        result.ShouldNotBeNull();
        result.Samples.Count.ShouldBeGreaterThanOrEqualTo(0, "a live ReadRaw must answer (zero samples is a valid sparse-tag result, not a fault)");

        TestContext.Current.SendDiagnosticMessage(
            $"read round-trip: ReadRaw('{_fx.TestTag}', last 1h) returned {result.Samples.Count} sample(s).");
    }

    /// <summary>
    ///     Write round-trip — <c>EnsureTags</c> (Float) → <c>WriteLiveValues</c> (a known value via the
    ///     real recorder writer) → <c>ReadRaw</c> the recent window and assert the written sample is
    ///     present. Requires the gateway running <c>RuntimeDb:Enabled=true</c> and that <c>EnsureTags</c>
    ///     provisioned the tag (the SQL live-write path only accepts provisioned analog tags). The write
    ///     value is an exact-in-float integer so the float-precision round-trip compares cleanly.
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task Write_then_read_on_sandbox_tag()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.WriteSandboxTag is null)
            Assert.Skip("Skipped: set HISTGW_WRITE_SANDBOX_TAG to a writable Float sandbox tag (e.g. HistGW.LiveTest.Sandbox) to run the write round-trip.");

        var ct = TestContext.Current.CancellationToken;
        var tag = _fx.WriteSandboxTag;

        // A value that is exactly representable in float32 (integer < 2^24) so the analog
        // store/read round-trip is not muddied by single-precision rounding. The millisecond
        // component keeps consecutive runs from colliding on the same value.
        const ushort goodQuality = 192; // OPC-DA "Good" floor.
        var writeUtc = DateTime.UtcNow;
        double written = 1_000_000 + writeUtc.Millisecond;

        // EnsureTags (Float) through the real adapter seam — create-or-update, idempotent for an
        // already-provisioned sandbox tag.
        await using var writeClient = _fx.CreateClient();
        var ensure = await writeClient.EnsureTagsAsync(
            new[]
            {
                new HistorianTagDefinition
                {
                    TagName = tag,
                    DataType = HistorianDataType.Float,
                    EngineeringUnit = string.Empty,
                    Description = "OtOpcUa live validation sandbox",
                    StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0).
                },
            },
            ct);
        ensure.ShouldNotBeNull();

        // WriteLiveValues through the real recorder writer (SQL live-write path).
        var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
        var acked = await valueWriter.WriteLiveValuesAsync(
            tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
        acked.ShouldBeTrue(
            "the live write must be acked — needs the gateway running RuntimeDb:Enabled=true and the tag EnsureTags-provisioned.");

        // Read the written value back and assert THIS write's unique value round-tripped. The SQL write
        // can lag the read by a flush cadence, so poll briefly. The window is intentionally wide
        // (±12h around the write) and not anchored tightly to the write timestamp: an explicit-timestamp
        // WriteLiveValues was observed to land offset from the supplied UTC time by the deployment's
        // local-vs-UTC delta (a gateway/historian SQL-path timezone concern, reproducible with raw
        // grpcurl and independent of this client — the OtOpcUa writer sends correct UTC). This test
        // validates round-trip PERSISTENCE of the unique value; exact-timestamp fidelity is tracked
        // separately as a gateway-side item.
        await using var dataSource = _fx.CreateDataSource();
        DataValueSnapshot? hit = null;
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
        do
        {
            var read = await dataSource.ReadRawAsync(
                tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct);
            hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5);
            if (hit is not null) break;
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
        }
        while (DateTime.UtcNow < deadline);

        hit.ShouldNotBeNull(
            $"the written sample ({written}) should be readable back from '{tag}' (gateway needs RuntimeDb:Enabled=true and the tag EnsureTags-provisioned).");

        TestContext.Current.SendDiagnosticMessage(
            $"write round-trip: EnsureTags + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
    }

    /// <summary>
    ///     Alarm round-trip — <c>SendEvent</c> for the configured source through the real
    ///     <see cref="GatewayAlarmHistorianWriter"/>, then <c>ReadEvents</c> the recent window for that
    ///     source through the real <see cref="GatewayHistorianDataSource"/> and assert the event is
    ///     present. Requires the gateway running <c>RuntimeDb:EventReadsEnabled=true</c> (the SQL
    ///     alarm-history read path). Presence is asserted as "at least one event for the source surfaced
    ///     in the post-send window" (the data source filters by source); the exact AlarmId / message
    ///     match is surfaced as a diagnostic, since the SQL event store may re-key the row.
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task Alarm_SendEvent_then_ReadEvents()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.AlarmSource is null)
            Assert.Skip("Skipped: set HISTGW_ALARM_SOURCE to a source name to run the alarm SendEvent → ReadEvents round-trip.");

        var ct = TestContext.Current.CancellationToken;
        var source = _fx.AlarmSource;
        var alarmId = "OtOpcUaLive-" + Guid.NewGuid().ToString("N");
        var eventUtc = DateTime.UtcNow;

        var alarm = new AlarmHistorianEvent(
            AlarmId: alarmId,
            EquipmentPath: source, // becomes the wire event's SourceName / SQL Source_Object filter key.
            AlarmName: "OtOpcUaLiveValidation",
            AlarmTypeName: "LimitAlarm",
            Severity: AlarmSeverity.High,
            EventKind: "Activated",
            Message: "OtOpcUa live validation event",
            User: "system",
            Comment: null,
            TimestampUtc: eventUtc);

        // SendEvent through the real alarm writer (never throws — returns a per-event outcome).
        using var alarmClient = _fx.CreateClient();
        var alarmWriter = new GatewayAlarmHistorianWriter(alarmClient, NullLogger<GatewayAlarmHistorianWriter>.Instance);
        var outcomes = await alarmWriter.WriteBatchAsync(new[] { alarm }, ct);
        outcomes.ShouldHaveSingleItem().ShouldBe(
            HistorianWriteOutcome.Ack,
            "the alarm SendEvent must be acked — needs the gateway write scope (historian:write) and SendEvent path.");

        // Read the event back over a recent window for the source. The SQL event write can lag (the
        // live event view has variable flush latency), so poll generously.
        await using var dataSource = _fx.CreateDataSource();
        IReadOnlyList<HistoricalEvent> events = Array.Empty<HistoricalEvent>();
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(60);
        do
        {
            var read = await dataSource.ReadEventsAsync(
                source, eventUtc - TimeSpan.FromMinutes(5), DateTime.UtcNow + TimeSpan.FromMinutes(1), maxEvents: 0, ct);
            events = read.Events;
            if (events.Count > 0) break;
            await Task.Delay(TimeSpan.FromSeconds(2), ct);
        }
        while (DateTime.UtcNow < deadline);

        // The source-filtered READBACK of OtOpcUa's own just-sent alarm now round-trips: the gateway
        // threads the wire event's SourceName into the `source_object` event property, so an ad-hoc
        // SendEvent is recorded in dbo.Events WITH a Source_Object and a source-filtered ReadEvents
        // finds it (gateway C4 fix, 2026-06-27; pending.md C4 / histsdk #3). Previously this branch
        // skipped because ad-hoc sends landed with Source_Object=NULL; that limitation is now closed,
        // so the round-trip is asserted.
        events.ShouldNotBeEmpty(
            $"a source-filtered ReadEvents must return the just-sent alarm for source '{source}': the gateway now " +
            "populates Source_Object from the event SourceName (C4 fix), so OtOpcUa's own alarm sends round-trip.");

        var exactMatch = events.Any(e => string.Equals(e.EventId, alarmId, StringComparison.Ordinal));
        TestContext.Current.SendDiagnosticMessage(
            $"alarm round-trip: SendEvent source='{source}' id={alarmId} → ReadEvents returned {events.Count} event(s); exact-id match={exactMatch}.");
    }

    /// <summary>
    ///     Alarm send contract — the OtOpcUa responsibility in isolation: an <see cref="AlarmHistorianEvent"/>
    ///     maps to a wire event the gateway accepts and <c>SendEvent</c> returns <see cref="HistorianWriteOutcome.Ack"/>.
    ///     This is the half that must always hold (the readback half is historian-gated; see
    ///     <see cref="Alarm_SendEvent_then_ReadEvents"/>). Regression guard for the event-id mapping —
    ///     a client-supplied wire <c>Id</c> makes the gateway's SendEvent handler throw, so the mapper
    ///     must leave it unset.
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task Alarm_SendEvent_is_acked()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.AlarmSource is null)
            Assert.Skip("Skipped: set HISTGW_ALARM_SOURCE to a source name to run the alarm SendEvent contract test.");

        var ct = TestContext.Current.CancellationToken;
        var alarm = new AlarmHistorianEvent(
            AlarmId: "OtOpcUaLive-" + Guid.NewGuid().ToString("N"),
            EquipmentPath: _fx.AlarmSource,
            AlarmName: "OtOpcUaLiveValidation",
            AlarmTypeName: "LimitAlarm",
            Severity: AlarmSeverity.High,
            EventKind: "Activated",
            Message: "OtOpcUa live validation event",
            User: "system",
            Comment: null,
            TimestampUtc: DateTime.UtcNow);

        using var alarmClient = _fx.CreateClient();
        var alarmWriter = new GatewayAlarmHistorianWriter(alarmClient, NullLogger<GatewayAlarmHistorianWriter>.Instance);
        var outcomes = await alarmWriter.WriteBatchAsync(new[] { alarm }, ct);

        outcomes.ShouldHaveSingleItem().ShouldBe(
            HistorianWriteOutcome.Ack,
            "the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
    }

    /// <summary>
    ///     issue #441 — Boolean historization leg. Through the <b>real</b>
    ///     <see cref="GatewayTagProvisioner"/>, <c>EnsureTags</c> a <see cref="DriverDataType.Boolean"/>
    ///     request for the dedicated Boolean sandbox tag (mapped to <c>Int2</c> — the historian's
    ///     <c>Int1</c> analog-tag creation path is server-degenerate, see issue #441 / HistorianGateway
    ///     #11, so Boolean historizes as a small 0/1 integer), assert it is ensured (not failed), then
    ///     <c>WriteLiveValues</c> a <c>1.0</c> and read it back over a wide window. The ±12h read window
    ///     + timezone caveat mirror <see cref="Write_then_read_on_sandbox_tag"/>.
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task EnsureTags_boolean_sandbox_provisions_as_Int2()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.BoolSandboxTag is null)
            Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the Boolean historization leg.");

        var ct = TestContext.Current.CancellationToken;
        var tag = _fx.BoolSandboxTag;

        // EnsureTags (Boolean → Int2) through the REAL provisioner seam (the same code path deploys use).
        await using var writeClient = _fx.CreateClient();
        var provisioner = new GatewayTagProvisioner(writeClient, NullLogger<GatewayTagProvisioner>.Instance);
        var provision = await provisioner.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest(tag, DriverDataType.Boolean, null, "OtOpcUa live validation Boolean sandbox") },
            ct);

        provision.Ensured.ShouldBe(1,
            "the Boolean sandbox tag must be ensured as Int2 (needs the gateway on 0.2.0+ contracts + an API key carrying historian:tags:write).");
        provision.Failed.ShouldBe(0);

        // WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
        const ushort goodQuality = 192; // OPC-DA "Good" floor.
        var writeUtc = DateTime.UtcNow;
        const double written = 1.0;
        var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
        var acked = await valueWriter.WriteLiveValuesAsync(
            tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
        acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag).");

        await using var dataSource = _fx.CreateDataSource();
        DataValueSnapshot? hit = null;
        var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
        do
        {
            var read = await dataSource.ReadRawAsync(
                tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct);
            hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5);
            if (hit is not null) break;
            await Task.Delay(TimeSpan.FromSeconds(1), ct);
        }
        while (DateTime.UtcNow < deadline);

        hit.ShouldNotBeNull($"the written Boolean sample ({written}) should read back from '{tag}'.");
        TestContext.Current.SendDiagnosticMessage(
            $"Boolean round-trip: EnsureTags(Int2) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
    }

    /// <summary>
    ///     archreview 06/U-7 retype probe — answers the assessment note's open question: what does the
    ///     deployed AVEVA Historian do when <c>EnsureTags</c> (an <b>upsert</b>) is asked to change an
    ///     existing tag's type? Against the <b>dedicated Boolean sandbox tag only</b> (never the shared
    ///     Float write sandbox), EnsureTags first as <c>Float</c> then as <c>Int2</c> (both supported
    ///     analog types — <c>Int2</c> is the type Boolean now historizes as; the previously-probed
    ///     <c>Int1</c> is a documented server-degenerate exclusion, issue #441) and assert the second
    ///     call's per-tag outcome is <em>deterministic and observable</em> — either <c>Success=true</c>
    ///     (retype / tag-versioning accepted) or <c>Success=false</c> with a non-empty <c>Error</c> (mapped
    ///     to the provisioner's <c>failed</c> tally). The assertion accepts both because the contract
    ///     guarantees only the observability, not the policy; the observed behavior is emitted as a
    ///     diagnostic so the run documents which of accepted-versioned / accepted-retyped / rejected the
    ///     historian exhibits (fed back into docs/Historian.md after the first live run).
    /// </summary>
    [Fact]
    [Trait("Category", "LiveIntegration")]
    public async Task EnsureTags_type_change_outcome_is_observable()
    {
        if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
        if (_fx.BoolSandboxTag is null)
            Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a dedicated Boolean sandbox tag to run the retype probe.");

        var ct = TestContext.Current.CancellationToken;
        var tag = _fx.BoolSandboxTag;

        await using var client = _fx.CreateClient();

        // First establish the tag as Float, then ask EnsureTags to retype it to Int2.
        await client.EnsureTagsAsync(
            new[]
            {
                new HistorianTagDefinition
                {
                    TagName = tag,
                    DataType = HistorianDataType.Float,
                    EngineeringUnit = string.Empty,
                    Description = "OtOpcUa live validation retype probe (stage 1: Float)",
                    StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0).
                },
            },
            ct);

        var retype = await client.EnsureTagsAsync(
            new[]
            {
                new HistorianTagDefinition
                {
                    TagName = tag,
                    DataType = HistorianDataType.Int2,
                    EngineeringUnit = string.Empty,
                    Description = "OtOpcUa live validation retype probe (stage 2: Int2)",
                    StorageRateMs = 1000, // 0 makes the gateway's EnsureTags throw (storageRateMs must be > 0).
                },
            },
            ct);

        var outcome = retype.Results.ShouldHaveSingleItem();
        var observable = outcome.Success || !string.IsNullOrEmpty(outcome.Error);
        observable.ShouldBeTrue(
            "the retype outcome must be deterministic + observable: either Success=true (retype/version accepted) " +
            "or Success=false with a non-empty Error (mapped to the provisioner's failed tally).");

        TestContext.Current.SendDiagnosticMessage(
            $"retype probe '{tag}' Float→Int2: Success={outcome.Success}, Error='{outcome.Error}'. " +
            "(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md).");
    }
}
