using System.Collections.Immutable;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LocalDb;
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.ScriptedAlarms;

/// <summary>
///     Round-trip + upsert + lifecycle coverage for <see cref="LocalDbAlarmConditionStateStore"/>,
///     the LocalDb-backed <see cref="IAlarmStateStore"/> over the replicated
///     <c>alarm_condition_state</c> table.
/// </summary>
/// <remarks>
///     Ported verbatim from <c>EfAlarmConditionStateStoreTests</c> — the two stores share the
///     <see cref="IAlarmStateStore"/> contract, so the assertions transfer unchanged and prove
///     parity. Only store construction differs: this suite drives a real temp-file
///     <see cref="ILocalDb"/> (the library has no in-memory mode, and a bare
///     <see cref="SqliteConnection"/> would lack the <c>zb_hlc_next()</c> UDF the capture triggers
///     call), whereas the EF suite used an in-memory DbContext factory.
/// </remarks>
public sealed class LocalDbAlarmConditionStateStoreTests : IDisposable
{
    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), $"otopcua-alarm-state-{Guid.NewGuid():N}.db");

    private readonly ServiceProvider _provider;
    private readonly ILocalDb _db;

    public LocalDbAlarmConditionStateStoreTests()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { ["LocalDb:Path"] = _dbPath })
            .Build();

        _provider = new ServiceCollection()
            .AddZbLocalDb(configuration, db =>
            {
                using (var connection = db.CreateConnection())
                {
                    AlarmConditionStateSchema.Apply(connection);
                }

                db.RegisterReplicated(AlarmConditionStateSchema.StateTable);
            })
            .BuildServiceProvider();

        _db = _provider.GetRequiredService<ILocalDb>();
    }

    public void Dispose()
    {
        _provider.Dispose();

        // Pooled connections keep a handle open past dispose; clearing the pools first is what
        // makes the delete actually succeed.
        SqliteConnection.ClearAllPools();

        foreach (var path in new[] { _dbPath, $"{_dbPath}-wal", $"{_dbPath}-shm" })
        {
            try { if (File.Exists(path)) File.Delete(path); } catch { /* best effort */ }
        }
    }

    private LocalDbAlarmConditionStateStore NewStore()
        => new(_db, NullLogger<LocalDbAlarmConditionStateStore>.Instance);

    private static AlarmConditionState Sample(
        string alarmId,
        AlarmActiveState active = AlarmActiveState.Inactive,
        ShelvingState? shelving = null,
        ImmutableList<AlarmComment>? comments = null)
    {
        var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
        return new AlarmConditionState(
            AlarmId: alarmId,
            Enabled: AlarmEnabledState.Disabled,
            Active: active,
            Acked: AlarmAckedState.Unacknowledged,
            Confirmed: AlarmConfirmedState.Unconfirmed,
            Shelving: shelving ?? new ShelvingState(ShelvingKind.Timed, t.AddMinutes(30)),
            LastTransitionUtc: t,
            LastActiveUtc: t.AddMinutes(-5),
            LastClearedUtc: t.AddMinutes(-1),
            LastAckUtc: t.AddMinutes(-2),
            LastAckUser: "jane",
            LastAckComment: "ack-comment",
            LastConfirmUtc: t.AddMinutes(-3),
            LastConfirmUser: "bob",
            LastConfirmComment: "confirm-comment",
            Comments: comments ?? ImmutableList<AlarmComment>.Empty);
    }

    /// <summary>Save then load round-trips every persisted operator-state + audit field.</summary>
    [Fact]
    public async Task SaveAsync_then_LoadAsync_round_trips_persisted_fields()
    {
        var store = NewStore();
        var state = Sample("alarm-1");

        await store.SaveAsync(state, CancellationToken.None);
        var loaded = await store.LoadAsync("alarm-1", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.AlarmId.ShouldBe("alarm-1");
        loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
        loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
        loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
        loaded.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
        loaded.Shelving.UnshelveAtUtc.ShouldBe(state.Shelving.UnshelveAtUtc);
        loaded.LastTransitionUtc.ShouldBe(state.LastTransitionUtc);
        loaded.LastAckUtc.ShouldBe(state.LastAckUtc);
        loaded.LastAckUser.ShouldBe("jane");
        loaded.LastAckComment.ShouldBe("ack-comment");
        loaded.LastConfirmUtc.ShouldBe(state.LastConfirmUtc);
        loaded.LastConfirmUser.ShouldBe("bob");
        loaded.LastConfirmComment.ShouldBe("confirm-comment");
        // Shouldly's DateTime ShouldBe compares ticks and ignores DateTimeKind, so an explicit Kind
        // assertion is the only guard on the Utc-kind contract the round-trip ("O") format encodes.
        loaded.LastTransitionUtc.Kind.ShouldBe(DateTimeKind.Utc);
    }

    /// <summary>Unset audit fields (LastAckUtc/User, LastConfirmUtc/User) round-trip back as null.</summary>
    [Fact]
    public async Task Null_audit_fields_round_trip_as_null()
    {
        var store = NewStore();
        var t = new DateTime(2026, 06, 10, 12, 00, 00, DateTimeKind.Utc);
        var state = new AlarmConditionState(
            AlarmId: "alarm-null-audit",
            Enabled: AlarmEnabledState.Enabled,
            Active: AlarmActiveState.Inactive,
            Acked: AlarmAckedState.Unacknowledged,
            Confirmed: AlarmConfirmedState.Unconfirmed,
            Shelving: ShelvingState.Unshelved,
            LastTransitionUtc: t,
            LastActiveUtc: null,
            LastClearedUtc: null,
            LastAckUtc: null,
            LastAckUser: null,
            LastAckComment: null,
            LastConfirmUtc: null,
            LastConfirmUser: null,
            LastConfirmComment: null,
            Comments: ImmutableList<AlarmComment>.Empty);

        await store.SaveAsync(state, CancellationToken.None);
        var loaded = await store.LoadAsync("alarm-null-audit", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.LastAckUtc.ShouldBeNull();
        loaded.LastAckUser.ShouldBeNull();
        loaded.LastAckComment.ShouldBeNull();
        loaded.LastConfirmUtc.ShouldBeNull();
        loaded.LastConfirmUser.ShouldBeNull();
        loaded.LastConfirmComment.ShouldBeNull();
    }

    /// <summary>Loading an id that was never saved returns null.</summary>
    [Fact]
    public async Task LoadAsync_for_unknown_id_returns_null()
    {
        var store = NewStore();

        var loaded = await store.LoadAsync("never-saved", CancellationToken.None);

        loaded.ShouldBeNull();
    }

    /// <summary>Active is not persisted — a saved Active state loads back as Inactive.</summary>
    [Fact]
    public async Task Active_is_ignored_on_load_and_defaults_to_Inactive()
    {
        var store = NewStore();
        await store.SaveAsync(Sample("alarm-active", active: AlarmActiveState.Active), CancellationToken.None);

        var loaded = await store.LoadAsync("alarm-active", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Active.ShouldBe(AlarmActiveState.Inactive);
    }

    /// <summary>LastActiveUtc / LastClearedUtc have no columns and default to null on load.</summary>
    [Fact]
    public async Task Derived_timestamps_default_to_null_on_load()
    {
        var store = NewStore();
        await store.SaveAsync(Sample("alarm-derived"), CancellationToken.None);

        var loaded = await store.LoadAsync("alarm-derived", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.LastActiveUtc.ShouldBeNull();
        loaded.LastClearedUtc.ShouldBeNull();
    }

    /// <summary>Unshelved round-trips with a null UnshelveAtUtc.</summary>
    [Fact]
    public async Task Unshelved_round_trips()
    {
        var store = NewStore();
        await store.SaveAsync(
            Sample("alarm-unshelved", shelving: ShelvingState.Unshelved),
            CancellationToken.None);

        var loaded = await store.LoadAsync("alarm-unshelved", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
        loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
    }

    /// <summary>OneShot shelving round-trips.</summary>
    [Fact]
    public async Task OneShot_shelving_round_trips()
    {
        var store = NewStore();
        await store.SaveAsync(
            Sample("alarm-oneshot", shelving: new ShelvingState(ShelvingKind.OneShot, null)),
            CancellationToken.None);

        var loaded = await store.LoadAsync("alarm-oneshot", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Shelving.Kind.ShouldBe(ShelvingKind.OneShot);
        loaded.Shelving.UnshelveAtUtc.ShouldBeNull();
    }

    /// <summary>The append-only comment trail round-trips all four fields per comment.</summary>
    [Fact]
    public async Task Comments_round_trip_all_fields()
    {
        var store = NewStore();
        var c1 = new AlarmComment(
            new DateTime(2026, 06, 10, 10, 00, 00, DateTimeKind.Utc), "jane", "Acknowledge", "checking pump");
        var c2 = new AlarmComment(
            new DateTime(2026, 06, 10, 11, 30, 00, DateTimeKind.Utc), "bob", "Confirm", "all clear");
        var comments = ImmutableList.Create(c1, c2);

        await store.SaveAsync(Sample("alarm-comments", comments: comments), CancellationToken.None);
        var loaded = await store.LoadAsync("alarm-comments", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Comments.Count.ShouldBe(2);
        loaded.Comments[0].TimestampUtc.ShouldBe(c1.TimestampUtc);
        loaded.Comments[0].User.ShouldBe("jane");
        loaded.Comments[0].Kind.ShouldBe("Acknowledge");
        loaded.Comments[0].Text.ShouldBe("checking pump");
        loaded.Comments[1].TimestampUtc.ShouldBe(c2.TimestampUtc);
        loaded.Comments[1].User.ShouldBe("bob");
        loaded.Comments[1].Kind.ShouldBe("Confirm");
        loaded.Comments[1].Text.ShouldBe("all clear");
    }

    /// <summary>An empty comment list round-trips as an empty list.</summary>
    [Fact]
    public async Task Empty_comments_round_trip()
    {
        var store = NewStore();
        await store.SaveAsync(
            Sample("alarm-empty-comments", comments: ImmutableList<AlarmComment>.Empty),
            CancellationToken.None);

        var loaded = await store.LoadAsync("alarm-empty-comments", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Comments.ShouldBeEmpty();
    }

    /// <summary>An empty comment list persists as the literal <c>"[]"</c> in the column.</summary>
    [Fact]
    public async Task Empty_comments_persist_as_bracket_pair()
    {
        var store = NewStore();
        await store.SaveAsync(
            Sample("alarm-empty-literal", comments: ImmutableList<AlarmComment>.Empty),
            CancellationToken.None);

        using var conn = _db.CreateConnection();
        using var cmd = conn.CreateCommand();
        cmd.CommandText =
            "SELECT comments_json FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-empty-literal'";
        var json = (string?)cmd.ExecuteScalar();

        json.ShouldBe("[]");
    }

    /// <summary>An unknown enabled string loads back as Enabled (safe default).</summary>
    [Fact]
    public async Task Unknown_enum_strings_map_to_safe_defaults()
    {
        // Write raw rows carrying garbage enum strings to prove the load-side mappers coerce them.
        using (var conn = _db.CreateConnection())
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText =
                """
                INSERT INTO alarm_condition_state
                    (scripted_alarm_id, enabled_state, acked_state, confirmed_state, shelving_state,
                     comments_json, updated_at_utc)
                VALUES
                    ('garbage', 'nonsense', 'nonsense', 'nonsense', 'nonsense', '[]', '2026-06-10T12:00:00.0000000Z')
                """;
            cmd.ExecuteNonQuery();
        }

        var loaded = await NewStore().LoadAsync("garbage", CancellationToken.None);

        loaded.ShouldNotBeNull();
        loaded.Enabled.ShouldBe(AlarmEnabledState.Enabled);            // unknown → Enabled
        loaded.Acked.ShouldBe(AlarmAckedState.Unacknowledged);        // unknown → Unacknowledged
        loaded.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);   // unknown → Unconfirmed
        loaded.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);        // unknown → Unshelved
    }

    /// <summary>Saving the same id twice is an upsert — one row, latest values win.</summary>
    [Fact]
    public async Task SaveAsync_twice_upserts_latest()
    {
        var store = NewStore();
        var first = Sample("alarm-upsert") with { LastAckUser = "first-user", Enabled = AlarmEnabledState.Enabled };
        var second = Sample("alarm-upsert") with { LastAckUser = "second-user", Enabled = AlarmEnabledState.Disabled };

        await store.SaveAsync(first, CancellationToken.None);
        await store.SaveAsync(second, CancellationToken.None);

        using (var conn = _db.CreateConnection())
        using (var cmd = conn.CreateCommand())
        {
            cmd.CommandText =
                "SELECT COUNT(*) FROM alarm_condition_state WHERE scripted_alarm_id = 'alarm-upsert'";
            ((long)cmd.ExecuteScalar()!).ShouldBe(1);
        }

        var loaded = await store.LoadAsync("alarm-upsert", CancellationToken.None);
        loaded.ShouldNotBeNull();
        loaded.LastAckUser.ShouldBe("second-user");
        loaded.Enabled.ShouldBe(AlarmEnabledState.Disabled);
    }

    /// <summary>LoadAllAsync returns every saved state.</summary>
    [Fact]
    public async Task LoadAllAsync_returns_all_saved_states()
    {
        var store = NewStore();
        await store.SaveAsync(Sample("a-1"), CancellationToken.None);
        await store.SaveAsync(Sample("a-2"), CancellationToken.None);
        await store.SaveAsync(Sample("a-3"), CancellationToken.None);

        var all = await store.LoadAllAsync(CancellationToken.None);

        all.Select(s => s.AlarmId).OrderBy(x => x).ShouldBe(new[] { "a-1", "a-2", "a-3" });
    }

    /// <summary>RemoveAsync deletes a row so a subsequent load returns null; others survive.</summary>
    [Fact]
    public async Task RemoveAsync_deletes_one_and_LoadAsync_returns_null()
    {
        var store = NewStore();
        await store.SaveAsync(Sample("keep"), CancellationToken.None);
        await store.SaveAsync(Sample("drop"), CancellationToken.None);

        await store.RemoveAsync("drop", CancellationToken.None);

        (await store.LoadAsync("drop", CancellationToken.None)).ShouldBeNull();
        (await store.LoadAsync("keep", CancellationToken.None)).ShouldNotBeNull();
    }

    /// <summary>Removing an unknown id is a no-op (does not throw).</summary>
    [Fact]
    public async Task RemoveAsync_for_unknown_id_is_noop()
    {
        var store = NewStore();

        await Should.NotThrowAsync(() => store.RemoveAsync("ghost", CancellationToken.None));
    }
}
