using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;

namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging.Tests;

public class EventLogQueryServiceTests : IDisposable
{
    private readonly SiteEventLogger _eventLogger;
    private readonly EventLogQueryService _queryService;
    private readonly TestLocalDb _localDb;
    private readonly string _dbPath;

    public EventLogQueryServiceTests()
    {
        _dbPath = Path.Combine(Path.GetTempPath(), $"test_query_{Guid.NewGuid()}.db");
        var options = Options.Create(new SiteEventLogOptions
        {
            DatabasePath = _dbPath,
            QueryPageSize = 500
        });
        _localDb = TestLocalDb.Create(_dbPath);
        _eventLogger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, _localDb.Db);
        _queryService = new EventLogQueryService(
            _eventLogger,
            options,
            NullLogger<EventLogQueryService>.Instance);
    }

    public void Dispose()
    {
        _eventLogger.Dispose();
        _localDb.Dispose();
        TestLocalDb.DeleteFiles(_dbPath);
        GC.SuppressFinalize(this);
    }

    private async Task SeedEvents()
    {
        await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Monitor", "Script timeout");
        await _eventLogger.LogEventAsync("alarm", "Warning", "inst-1", "AlarmActor:TempHigh", "Alarm triggered");
        await _eventLogger.LogEventAsync("deployment", "Info", "inst-2", "DeploymentManager", "Instance deployed");
        await _eventLogger.LogEventAsync("connection", "Error", null, "DCL:OPC1", "Connection lost");
        await _eventLogger.LogEventAsync("script", "Info", "inst-2", "ScriptActor:Calculate", "Script completed");
    }

    private EventLogQueryRequest MakeRequest(
        string? eventType = null,
        string? severity = null,
        string? instanceId = null,
        string? keyword = null,
        string? continuationToken = null,
        int pageSize = 500,
        DateTimeOffset? from = null,
        DateTimeOffset? to = null) =>
        new(
            CorrelationId: Guid.NewGuid().ToString(),
            SiteId: "site-1",
            From: from,
            To: to,
            EventType: eventType,
            Severity: severity,
            InstanceId: instanceId,
            KeywordFilter: keyword,
            ContinuationToken: continuationToken,
            PageSize: pageSize,
            Timestamp: DateTimeOffset.UtcNow);

    [Fact]
    public async Task Query_ReturnsAllEvents_WhenNoFilters()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest());

        Assert.True(response.Success);
        Assert.Equal(5, response.Entries.Count);
        Assert.False(response.HasMore);
    }

    [Fact]
    public async Task Query_FiltersByEventType()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(eventType: "script"));

        Assert.True(response.Success);
        Assert.Equal(2, response.Entries.Count);
        Assert.All(response.Entries, e => Assert.Equal("script", e.EventType));
    }

    [Fact]
    public async Task Query_FiltersBySeverity()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(severity: "Error"));

        Assert.True(response.Success);
        Assert.Equal(2, response.Entries.Count);
        Assert.All(response.Entries, e => Assert.Equal("Error", e.Severity));
    }

    [Fact]
    public async Task Query_FiltersByInstanceId()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(instanceId: "inst-1"));

        Assert.True(response.Success);
        Assert.Equal(2, response.Entries.Count);
        Assert.All(response.Entries, e => Assert.Equal("inst-1", e.InstanceId));
    }

    [Fact]
    public async Task Query_KeywordSearch_MatchesMessage()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
        Assert.Contains("timeout", response.Entries[0].Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Query_KeywordSearch_MatchesSource()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(keyword: "AlarmActor"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
        Assert.Contains("AlarmActor", response.Entries[0].Source);
    }

    [Fact]
    public async Task Query_CombinesMultipleFilters()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(
            eventType: "script",
            severity: "Error",
            instanceId: "inst-1"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
        Assert.Equal("Script timeout", response.Entries[0].Message);
    }

    [Fact]
    public async Task Query_Pagination_ReturnsCorrectPageSize()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));

        Assert.True(response.Success);
        Assert.Equal(2, response.Entries.Count);
        Assert.True(response.HasMore);
        Assert.NotNull(response.ContinuationToken);
    }

    [Fact]
    public async Task Query_Pagination_ContinuationTokenWorksCorrectly()
    {
        await SeedEvents();

        // Get first page
        var page1 = _queryService.ExecuteQuery(MakeRequest(pageSize: 2));
        Assert.Equal(2, page1.Entries.Count);
        Assert.True(page1.HasMore);

        // Get second page using continuation token
        var page2 = _queryService.ExecuteQuery(MakeRequest(
            pageSize: 2,
            continuationToken: page1.ContinuationToken));
        Assert.Equal(2, page2.Entries.Count);
        Assert.True(page2.HasMore);

        // Get third page
        var page3 = _queryService.ExecuteQuery(MakeRequest(
            pageSize: 2,
            continuationToken: page2.ContinuationToken));
        Assert.Single(page3.Entries);
        Assert.False(page3.HasMore);

        // Verify no overlapping entries
        var allIds = page1.Entries.Select(e => e.Id)
            .Concat(page2.Entries.Select(e => e.Id))
            .Concat(page3.Entries.Select(e => e.Id))
            .ToList();
        Assert.Equal(5, allIds.Distinct().Count());
    }

    [Fact]
    public async Task Query_FiltersByTimeRange()
    {
        // Insert events at controlled times
        var now = DateTimeOffset.UtcNow;

        // Insert with a direct SQL to control timestamps
        InsertEventAt(now.AddHours(-2), "script", "Info", null, "S1", "Old event");
        InsertEventAt(now.AddMinutes(-30), "script", "Info", null, "S2", "Recent event");
        InsertEventAt(now, "script", "Info", null, "S3", "Now event");

        var response = _queryService.ExecuteQuery(MakeRequest(
            from: now.AddHours(-1),
            to: now.AddMinutes(1)));

        Assert.True(response.Success);
        Assert.Equal(2, response.Entries.Count);
    }

    [Fact]
    public async Task Query_FiltersByTimeRange_HandlesNonUtcOffset()
    {
        // SiteEventLogging-024 (re-opens -016): the store holds UTC ISO-8601 "o"
        // strings (always +00:00) and compares them lexicographically. If the
        // From/To bounds are stringified verbatim without UTC normalisation, a
        // non-UTC DateTimeOffset from a central client sorts wrongly against the
        // stored +00:00 values and the wrong rows are returned. This test seeds
        // events at known UTC instants and queries with bounds expressed in a
        // +05:00 offset that bracket the middle row; it FAILS against the unfixed
        // code (verbatim ToString("o")) and PASSES once From/To are normalised with
        // .ToUniversalTime().

        // Three events at distinct, well-separated UTC instants. The recorder always
        // stores UTC, so seed the rows as UTC to mirror real data.
        var baseUtc = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
        InsertEventAt(baseUtc.AddHours(-2), "script", "Info", null, "EARLY", "Early event");   // 10:00 UTC
        InsertEventAt(baseUtc, "script", "Info", null, "MIDDLE", "Middle event");              // 12:00 UTC
        InsertEventAt(baseUtc.AddHours(2), "script", "Info", null, "LATE", "Late event");      // 14:00 UTC

        // Express the SAME wall-clock window the operator intends — 11:00..13:00 UTC —
        // but as a +05:00 DateTimeOffset (16:00..18:00 local). These bound only the
        // MIDDLE row. With the bug, ToString("o") emits "...+05:00" which compares
        // wrongly against the stored "...+00:00" rows.
        var offset = TimeSpan.FromHours(5);
        var fromNonUtc = new DateTimeOffset(2026, 6, 1, 16, 0, 0, offset); // == 11:00 UTC
        var toNonUtc = new DateTimeOffset(2026, 6, 1, 18, 0, 0, offset);   // == 13:00 UTC

        var response = _queryService.ExecuteQuery(MakeRequest(from: fromNonUtc, to: toNonUtc));

        Assert.True(response.Success);
        // Assert on row IDENTITIES, not just the count: only the MIDDLE row falls in
        // the 11:00..13:00 UTC window.
        Assert.Equal(new[] { "MIDDLE" }, response.Entries.Select(e => e.Source).ToArray());
    }

    [Fact]
    public async Task Query_EmptyResult_WhenNoMatches()
    {
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(eventType: "nonexistent"));

        Assert.True(response.Success);
        Assert.Empty(response.Entries);
        Assert.False(response.HasMore);
        Assert.Null(response.ContinuationToken);
    }

    [Fact]
    public void Query_ReturnsCorrelationId()
    {
        var request = MakeRequest();
        var response = _queryService.ExecuteQuery(request);

        Assert.Equal(request.CorrelationId, response.CorrelationId);
        Assert.Equal("site-1", response.SiteId);
    }

    [Fact]
    public async Task Query_ReturnsAllEventLogEntryFields()
    {
        await _eventLogger.LogEventAsync("script", "Error", "inst-1", "ScriptActor:Run", "Failure", "{\"stack\":\"trace\"}");

        var response = _queryService.ExecuteQuery(MakeRequest());
        Assert.Single(response.Entries);

        var entry = response.Entries[0];
        Assert.False(string.IsNullOrWhiteSpace(entry.Id));
        Assert.Equal("script", entry.EventType);
        Assert.Equal("Error", entry.Severity);
        Assert.Equal("inst-1", entry.InstanceId);
        Assert.Equal("ScriptActor:Run", entry.Source);
        Assert.Equal("Failure", entry.Message);
        Assert.Equal("{\"stack\":\"trace\"}", entry.Details);
    }

    [Fact]
    public async Task Query_KeywordSearch_TreatsUnderscoreAsLiteral_NotWildcard()
    {
        // SiteEventLogging-013: a SQL LIKE '_' matches any single character. A
        // keyword search for a literal underscore (common in identifiers such as
        // "store_and_forward") must NOT match strings that merely have any
        // character in that position.
        await _eventLogger.LogEventAsync("script", "Info", null, "store_and_forward", "Buffer queued");
        await _eventLogger.LogEventAsync("script", "Info", null, "storeXandYforward", "Other event");

        var response = _queryService.ExecuteQuery(MakeRequest(keyword: "store_and_forward"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
        Assert.Equal("store_and_forward", response.Entries[0].Source);
    }

    [Fact]
    public async Task Query_KeywordSearch_TreatsPercentAsLiteral_NotWildcard()
    {
        // SiteEventLogging-013: a SQL LIKE '%' matches any run of characters. A
        // keyword search containing a literal '%' must match only the literal.
        await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90% utilisation");
        await _eventLogger.LogEventAsync("script", "Warning", null, "ScriptActor:Run", "CPU at 90 then 0 utilisation");

        var response = _queryService.ExecuteQuery(MakeRequest(keyword: "90% utilisation"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
        Assert.Contains("90% utilisation", response.Entries[0].Message);
    }

    [Fact]
    public async Task Query_KeywordSearch_StillMatchesPlainSubstring()
    {
        // Escaping wildcards must not break ordinary substring search.
        await SeedEvents();

        var response = _queryService.ExecuteQuery(MakeRequest(keyword: "timeout"));

        Assert.True(response.Success);
        Assert.Single(response.Entries);
    }

    private void InsertEventAt(DateTimeOffset timestamp, string eventType, string severity, string? instanceId, string source, string message)
    {
        _eventLogger.WithConnection(connection =>
        {
            using var cmd = connection.CreateCommand();
            cmd.CommandText = """
                INSERT INTO site_events (id, timestamp, event_type, severity, instance_id, source, message)
                VALUES ($id, $ts, $et, $sev, $iid, $src, $msg)
                """;
            cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString("N"));
            cmd.Parameters.AddWithValue("$ts", timestamp.ToString("o"));
            cmd.Parameters.AddWithValue("$et", eventType);
            cmd.Parameters.AddWithValue("$sev", severity);
            cmd.Parameters.AddWithValue("$iid", (object?)instanceId ?? DBNull.Value);
            cmd.Parameters.AddWithValue("$src", source);
            cmd.Parameters.AddWithValue("$msg", message);
            cmd.ExecuteNonQuery();
        });
    }

    // --- SiteEventLogging-017: PageSize hard upper bound ---

    [Fact]
    public async Task Query_PageSize_IsClampedToMaxQueryPageSize()
    {
        // Tighten the cap to make the assertion deterministic without seeding 100k rows.
        var dbPath = Path.Combine(Path.GetTempPath(), $"test_pagesize_{Guid.NewGuid()}.db");
        var options = Options.Create(new SiteEventLogOptions
        {
            DatabasePath = dbPath,
            QueryPageSize = 500,
            MaxQueryPageSize = 3,
        });
        using var localDb = TestLocalDb.Create(dbPath);
        using var logger = new SiteEventLogger(options, NullLogger<SiteEventLogger>.Instance, localDb.Db);
        var query = new EventLogQueryService(logger, options, NullLogger<EventLogQueryService>.Instance);

        try
        {
            // Seed 5 rows but request PageSize = 100_000 — must be clamped to 3.
            for (var i = 0; i < 5; i++)
                await logger.LogEventAsync("script", "Info", null, $"src-{i}", $"msg-{i}");

            var response = query.ExecuteQuery(new EventLogQueryRequest(
                CorrelationId: Guid.NewGuid().ToString(),
                SiteId: "site-1",
                From: null, To: null,
                EventType: null, Severity: null, InstanceId: null, KeywordFilter: null,
                ContinuationToken: null,
                PageSize: 100_000,
                Timestamp: DateTimeOffset.UtcNow));

            Assert.Equal(3, response.Entries.Count);
            Assert.True(response.HasMore);
        }
        finally
        {
            logger.Dispose();
            localDb.Dispose();
            TestLocalDb.DeleteFiles(dbPath);
        }
    }
}
