using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;

namespace ZB.MOM.WW.ScadaBridge.SiteEventLogging;

/// <summary>
/// Processes event log queries locally against SQLite.
/// Supports filtering by event_type, time range, instance_id, severity,
/// and keyword search (LIKE on message and source).
/// Uses keyset pagination with continuation token (last event ID).
/// </summary>
public class EventLogQueryService : IEventLogQueryService
{
    private readonly SiteEventLogger _eventLogger;
    private readonly SiteEventLogOptions _options;
    private readonly ILogger<EventLogQueryService> _logger;

    /// <summary>Initializes a new instance of <see cref="EventLogQueryService"/>.</summary>
    /// <param name="eventLogger">The concrete event logger providing lock-guarded database access.</param>
    /// <param name="options">Site event log options (page size and other query settings).</param>
    /// <param name="logger">Logger instance.</param>
    public EventLogQueryService(
        SiteEventLogger eventLogger,
        IOptions<SiteEventLogOptions> options,
        ILogger<EventLogQueryService> logger)
    {
        // Depend on the concrete recorder directly: queries must funnel database
        // access through its lock-guarded WithConnection. Taking ISiteEventLogger and
        // downcasting would throw InvalidCastException for any other implementation.
        _eventLogger = eventLogger;
        _options = options.Value;
        _logger = logger;
    }

    /// <summary>
    /// Escapes the SQL <c>LIKE</c> metacharacters (<c>\</c>, <c>%</c>, <c>_</c>) in a
    /// user-supplied keyword so it is matched as a literal substring. Used together
    /// with a <c>LIKE ... ESCAPE '\'</c> clause.
    /// </summary>
    private static string EscapeLikePattern(string input)
    {
        return input
            .Replace("\\", "\\\\")
            .Replace("%", "\\%")
            .Replace("_", "\\_");
    }

    /// <summary>
    /// Separator between the timestamp and id halves of a continuation token. Chosen
    /// because it cannot occur in an ISO 8601 "o" timestamp or in a GUID "N" string,
    /// so the split is unambiguous.
    /// </summary>
    private const char TokenSeparator = '|';

    /// <summary>
    /// Builds the opaque keyset cursor for the last row of a page. The token carries
    /// the exact stored timestamp string (not a re-formatted <see cref="DateTimeOffset"/>)
    /// so it compares byte-for-byte against the column under SQLite's BINARY collation.
    /// </summary>
    private static string FormatContinuationToken(EventLogEntry last) =>
        string.Concat(
            last.Timestamp.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
            TokenSeparator,
            last.Id);

    /// <summary>
    /// Parses a continuation token back into its timestamp and id halves.
    /// Returns <see langword="false"/> for null, empty, or malformed tokens — including
    /// a legacy numeric token from a client that predates the GUID id change — in which
    /// case the caller starts from the beginning instead of failing the query.
    /// </summary>
    private static bool TryParseContinuationToken(
        string? token, out string timestamp, out string id)
    {
        timestamp = "";
        id = "";

        if (string.IsNullOrWhiteSpace(token))
        {
            return false;
        }

        var separator = token.IndexOf(TokenSeparator);
        if (separator <= 0 || separator == token.Length - 1)
        {
            return false;
        }

        timestamp = token[..separator];
        id = token[(separator + 1)..];
        return true;
    }

    /// <inheritdoc />
    public EventLogQueryResponse ExecuteQuery(EventLogQueryRequest request)
    {
        try
        {
            // Clamp caller-supplied PageSize to a hard upper
            // bound so a central client sending int.MaxValue can't force the query
            // to materialise the entire log into a single list while holding the
            // shared write lock. Silent clamp — misconfigured clients still get a
            // usable response.
            var requestedSize = request.PageSize > 0 ? request.PageSize : _options.QueryPageSize;
            var pageSize = Math.Min(requestedSize, _options.MaxQueryPageSize);

            var whereClauses = new List<string>();
            var parameters = new List<SqliteParameter>();

            // Keyset pagination on the composite (timestamp, id) key.
            //
            // This used to be a plain "id > $afterId" against a monotonic autoincrement
            // id. Since LocalDb Phase 1 the id is a GUID, which does not sort
            // chronologically — a naive "id > x" would return an arbitrary subset and
            // SILENTLY DROP ROWS from the page-through. Timestamps alone are not unique
            // (two events can share a tick), so the tie is broken on id to guarantee a
            // total order and exactly-once paging.
            //
            // A malformed token is ignored rather than throwing: an old long-typed token
            // from a client mid-upgrade degrades to "start from the beginning", which is
            // a visible repeat rather than silent data loss.
            if (TryParseContinuationToken(request.ContinuationToken, out var afterTs, out var afterId))
            {
                whereClauses.Add("(timestamp > $afterTs OR (timestamp = $afterTs AND id > $afterId))");
                parameters.Add(new SqliteParameter("$afterTs", afterTs));
                parameters.Add(new SqliteParameter("$afterId", afterId));
            }

            if (request.From.HasValue)
            {
                // Timestamps are stored as ISO
                // 8601 "o" UTC strings (always +00:00). The store compares them
                // lexicographically (BINARY collation), so the bound MUST be
                // normalised to UTC before ToString("o") — otherwise a non-UTC
                // DateTimeOffset from a central client (e.g. +05:00) produces a
                // string that sorts wrongly against the +00:00 stored values and the
                // range filter silently includes/excludes the wrong rows.
                whereClauses.Add("timestamp >= $from");
                parameters.Add(new SqliteParameter("$from", request.From.Value.ToUniversalTime().ToString("o")));
            }

            if (request.To.HasValue)
            {
                // Normalise the upper bound to UTC for the same
                // reason as $from above.
                whereClauses.Add("timestamp <= $to");
                parameters.Add(new SqliteParameter("$to", request.To.Value.ToUniversalTime().ToString("o")));
            }

            if (!string.IsNullOrWhiteSpace(request.EventType))
            {
                whereClauses.Add("event_type = $eventType");
                parameters.Add(new SqliteParameter("$eventType", request.EventType));
            }

            if (!string.IsNullOrWhiteSpace(request.Severity))
            {
                whereClauses.Add("severity = $severity");
                parameters.Add(new SqliteParameter("$severity", request.Severity));
            }

            if (!string.IsNullOrWhiteSpace(request.InstanceId))
            {
                whereClauses.Add("instance_id = $instanceId");
                parameters.Add(new SqliteParameter("$instanceId", request.InstanceId));
            }

            if (!string.IsNullOrWhiteSpace(request.KeywordFilter))
            {
                // Keyword search is a literal substring match. The LIKE
                // metacharacters % and _ (and the escape char itself) must be
                // escaped so identifiers such as "store_and_forward" or a literal
                // "%" are not misinterpreted as wildcards.
                var escaped = EscapeLikePattern(request.KeywordFilter);
                whereClauses.Add(
                    "(message LIKE $keyword ESCAPE '\\' OR source LIKE $keyword ESCAPE '\\')");
                parameters.Add(new SqliteParameter("$keyword", $"%{escaped}%"));
            }

            var whereClause = whereClauses.Count > 0
                ? "WHERE " + string.Join(" AND ", whereClauses)
                : "";

            // Run the read against the shared connection under the logger's write
            // lock — the connection is not thread-safe and is also used by the
            // recorder and the purge service on other threads.
            var entries = _eventLogger.WithConnection(connection =>
            {
                using var cmd = connection.CreateCommand();

                // Fetch pageSize + 1 to determine if there are more results
                cmd.CommandText = $"""
                    SELECT id, timestamp, event_type, severity, instance_id, source, message, details
                    FROM site_events
                    {whereClause}
                    ORDER BY timestamp ASC, id ASC
                    LIMIT $limit
                    """;
                cmd.Parameters.AddWithValue("$limit", pageSize + 1);
                foreach (var p in parameters)
                    cmd.Parameters.Add(p);

                var rows = new List<EventLogEntry>();
                using var reader = cmd.ExecuteReader();
                while (reader.Read())
                {
                    rows.Add(new EventLogEntry(
                        Id: reader.GetString(0),
                        // Parse with explicit invariant culture and round-trip style.
                        // Stored values are ISO 8601 "o" UTC
                        // (see SiteEventLogger.LogEventAsync), and the recorder's
                        // emitted offset is always +00:00; AssumeUniversal +
                        // AdjustToUniversal guarantees the parsed value is UTC and
                        // does not depend on the host's CurrentCulture.
                        Timestamp: DateTimeOffset.Parse(
                            reader.GetString(1),
                            CultureInfo.InvariantCulture,
                            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal),
                        EventType: reader.GetString(2),
                        Severity: reader.GetString(3),
                        InstanceId: reader.IsDBNull(4) ? null : reader.GetString(4),
                        Source: reader.GetString(5),
                        Message: reader.GetString(6),
                        Details: reader.IsDBNull(7) ? null : reader.GetString(7)));
                }

                return rows;
            });

            var hasMore = entries.Count > pageSize;
            if (hasMore)
            {
                entries.RemoveAt(entries.Count - 1);
            }

            var continuationToken = entries.Count > 0
                ? FormatContinuationToken(entries[^1])
                : null;

            return new EventLogQueryResponse(
                CorrelationId: request.CorrelationId,
                SiteId: request.SiteId,
                Entries: entries,
                ContinuationToken: continuationToken,
                HasMore: hasMore,
                Success: true,
                ErrorMessage: null,
                Timestamp: DateTimeOffset.UtcNow);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to execute event log query: {CorrelationId}", request.CorrelationId);
            return new EventLogQueryResponse(
                CorrelationId: request.CorrelationId,
                SiteId: request.SiteId,
                Entries: [],
                ContinuationToken: null,
                HasMore: false,
                Success: false,
                ErrorMessage: ex.Message,
                Timestamp: DateTimeOffset.UtcNow);
        }
    }
}
