using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;

/// <summary>
/// EF/SQL-Server implementation of <see cref="IPartitionMaintenance"/> that
/// rolls forward <c>pf_AuditLog_Month</c> by issuing
/// <c>ALTER PARTITION FUNCTION … SPLIT RANGE</c> for each missing future
/// monthly boundary.
/// </summary>
/// <remarks>
/// <para>
/// The class is scoped (registered alongside the other repositories in
/// <c>AddConfigurationDatabase</c>) because it shares <see cref="ScadaBridgeDbContext"/>
/// — the hosted service opens a per-tick DI scope, resolves a fresh instance,
/// and lets the scope's <c>DbContext</c> dispose with it. The class itself
/// holds no state between calls.
/// </para>
/// <para>
/// <b>Idempotency model.</b> Each tick reads the current max boundary from
/// <c>sys.partition_range_values</c> and only issues SPLIT RANGE for
/// boundaries that strictly follow it — a boundary already covered is never
/// re-issued, so the "boundary already exists" failure (SQL Server msg 7708
/// / 7711) is avoided by construction rather than caught. The pre-check is
/// cheaper than the alternative TRY/CATCH around every SPLIT call and also
/// keeps the returned <c>added</c> list semantically precise.
/// </para>
/// <para>
/// <b>Why "first of next month".</b> The migration seeds boundaries on the
/// first-of-month at midnight UTC; we preserve that convention so the
/// resulting partition layout is uniform. <see cref="NormalizeToFirstOfMonth"/>
/// rounds an arbitrary timestamp up to the next first-of-month boundary
/// (e.g. 2026-05-20 → 2026-06-01), and <see cref="NextMonthBoundary"/>
/// walks one month at a time from there.
/// </para>
/// <para>
/// <b>Permissions.</b> The migration's <c>scadabridge_audit_purger</c> role
/// already carries <c>ALTER ON SCHEMA::dbo</c>, which is sufficient for
/// <c>ALTER PARTITION FUNCTION SPLIT RANGE</c>. No additional grant is
/// required.
/// </para>
/// </remarks>
public sealed class AuditLogPartitionMaintenance : IPartitionMaintenance
{
    private const string PartitionFunctionName = "pf_AuditLog_Month";
    private const string PartitionSchemeName = "ps_AuditLog_Month";
    private const string TargetFileGroup = "PRIMARY";

    private readonly ScadaBridgeDbContext _context;
    private readonly ILogger<AuditLogPartitionMaintenance> _logger;

    /// <summary>Initializes the maintenance implementation with the database context and optional logger.</summary>
    /// <param name="context">The EF Core database context used to execute partition-management SQL.</param>
    /// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/> when null.</param>
    public AuditLogPartitionMaintenance(
        ScadaBridgeDbContext context,
        ILogger<AuditLogPartitionMaintenance>? logger = null)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _logger = logger ?? NullLogger<AuditLogPartitionMaintenance>.Instance;
    }

    /// <inheritdoc />
    public async Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default)
    {
        // CAST the sql_variant `value` column to datetime2(7) — every boundary in
        // pf_AuditLog_Month is declared as datetime2(7) by the migration, so the
        // cast never loses precision.
        const string sql = @"
SELECT MAX(CAST(rv.value AS datetime2(7)))
FROM sys.partition_range_values rv
INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id
WHERE pf.name = 'pf_AuditLog_Month';";

        var conn = _context.Database.GetDbConnection();
        var openedHere = false;
        if (conn.State != System.Data.ConnectionState.Open)
        {
            await conn.OpenAsync(ct).ConfigureAwait(false);
            openedHere = true;
        }

        try
        {
            await using var cmd = conn.CreateCommand();
            cmd.CommandText = sql;
            var raw = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
            if (raw is null || raw is DBNull)
            {
                return null;
            }

            // ExecuteScalarAsync materialises datetime2 as DateTime with
            // DateTimeKind.Unspecified; the boundary values are stored at
            // UTC midnight by convention (migration seeds with 'T00:00:00'),
            // so we re-tag the kind so downstream comparisons against
            // DateTime.UtcNow stay in the same kind space.
            var dt = (DateTime)raw;
            return DateTime.SpecifyKind(dt, DateTimeKind.Utc);
        }
        finally
        {
            if (openedHere)
            {
                await conn.CloseAsync().ConfigureAwait(false);
            }
        }
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(
        int lookaheadMonths,
        CancellationToken ct = default)
    {
        if (lookaheadMonths < 1)
        {
            throw new ArgumentOutOfRangeException(
                nameof(lookaheadMonths),
                lookaheadMonths,
                "Lookahead must be at least one month — the partition function would otherwise be allowed to fall behind 'now'.");
        }

        var nowUtc = DateTime.UtcNow;
        // Horizon: the FIRST-OF-MONTH that must be the strictly-greater-than
        // max boundary after this call. Example: nowUtc = 2026-05-20 and
        // lookaheadMonths = 1 → horizon = 2026-07-01 (so the partition for
        // June 2026 is already in place by mid-May).
        var horizon = NormalizeToFirstOfMonth(nowUtc).AddMonths(lookaheadMonths);

        var max = await GetMaxBoundaryAsync(ct).ConfigureAwait(false);
        if (max is null)
        {
            // No partition function (e.g. migrations not applied) — nothing
            // we can safely SPLIT against. Log and return; the absence is a
            // genuine misconfiguration that other parts of the system will
            // surface louder than we could here.
            _logger.LogWarning(
                "EnsureLookaheadAsync: partition function {PartitionFunctionName} not found; skipping.",
                PartitionFunctionName);
            return Array.Empty<DateTime>();
        }

        // Start splitting from the FIRST month strictly after max — if max is
        // already first-of-month (the common case), that's max + 1 month;
        // otherwise NormalizeToFirstOfMonth rounds up.
        var next = NormalizeToFirstOfMonth(max.Value.AddDays(1));

        // Edge case: max already past horizon → no work to do.
        if (next > horizon)
        {
            return Array.Empty<DateTime>();
        }

        var added = new List<DateTime>();
        while (next <= horizon)
        {
            // Boundary literal must be a deterministic, culture-invariant ISO
            // string — SQL Server parses it as datetime2 via implicit conversion.
            // SPLIT RANGE does NOT accept @-parameters; the value is part of the
            // DDL statement, so we render it directly. The format is
            // guaranteed (yyyy-MM-ddTHH:mm:ss.fffffff) so there is no injection
            // surface.
            var literal = next.ToString("yyyy-MM-ddTHH:mm:ss.fffffff", CultureInfo.InvariantCulture);

            // Before every SPLIT we must (re-)set the NEXT USED filegroup on
            // ps_AuditLog_Month. Even though the scheme was created with
            // `ALL TO ([PRIMARY])` (which auto-populates NEXT USED once), SQL
            // Server consumes that hint on the FIRST split — subsequent splits
            // raise msg 7707 ("partition scheme … does not have any next used
            // filegroup") unless NEXT USED is explicitly re-set. Re-issuing it
            // before every split is idempotent and keeps the loop simple.
            var sql = $@"
ALTER PARTITION SCHEME {PartitionSchemeName} NEXT USED [{TargetFileGroup}];
ALTER PARTITION FUNCTION {PartitionFunctionName}() SPLIT RANGE ('{literal}');";

            // The loop pre-reads max-boundary and only issues
            // SPLITs for strictly-greater months, so msg 7708/7711 ("boundary
            // already exists") cannot happen by construction. Any OTHER
            // SqlException (permission revoked on the role, deadlock victim,
            // log full, filegroup full, transient connection drop) means the
            // boundary genuinely failed to create. The previous catch-and-
            // continue silently moved on to the next month, splitting month
            // N+1 successfully and leaving a permanent partition hole for
            // month N that blocks partition-switch purge until an operator
            // notices and rebuilds. Let SqlException propagate so the daily
            // hosted-service tick logs an Error and the next tick retries
            // from the same boundary (at-least-once, no holes).
            await _context.Database.ExecuteSqlRawAsync(sql, ct).ConfigureAwait(false);
            added.Add(next);

            next = NextMonthBoundary(next);
        }

        return added;
    }

    /// <summary>
    /// Rounds an arbitrary instant UP to the next first-of-month UTC. Inputs
    /// that ARE already a first-of-month at midnight are returned as-is so
    /// callers can compose this freely without double-incrementing.
    /// </summary>
    private static DateTime NormalizeToFirstOfMonth(DateTime instant)
    {
        var utc = instant.Kind == DateTimeKind.Utc
            ? instant
            : DateTime.SpecifyKind(instant, DateTimeKind.Utc);

        var firstOfThisMonth = new DateTime(utc.Year, utc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
        return utc == firstOfThisMonth ? firstOfThisMonth : firstOfThisMonth.AddMonths(1);
    }

    private static DateTime NextMonthBoundary(DateTime boundary) =>
        boundary.AddMonths(1);
}
