using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
using Xunit;

namespace SIGCM2.Application.Tests.Infrastructure.Pricing;

/// <summary>
/// PRC-001 — Integration hardening tests for ChargeableCharConfig.
/// Covers cross-cutting concerns not addressed by individual layer batches:
///
///   T7.1  Concurrency: SemaphoreSlim barrier forces genuine parallel race on
///         usp_ChargeableCharConfig_InsertWithClose — only 1 winner, SERIALIZABLE guard holds.
///
///   T7.2  SYSTEM_VERSIONING: exact row count before/after close (0 → 1).
///
///   T7.3  FOR SYSTEM_TIME AS OF: temporal snapshot query at T0 returns pre-close state.
///
///   T7.4  Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync:
///         - ProductType1 override for '$' → per-PT row returned at priority
///         - ProductType2 (no override) → global fallback returned
///
/// V023 scope delta: MedioId → ProductTypeId. Seeds use dbo.ProductType rows.
///
/// All tests run against SIGCM2_Test_App (Database collection + SqlTestFixture).
/// Each test seeds its own unique symbols to avoid cross-test interference.
/// </summary>
[Collection("Database")]
public sealed class ChargeableCharConfigHardeningTests : IAsyncLifetime
{
    private readonly SqlTestFixture _db;
    // V023 scope delta: renamed from _eldiaId/_elplataId to ProductType-based IDs.
    // These are ProductType IDs (FK to dbo.ProductType), not Medio IDs.
    private int _productType1Id;  // has per-PT override (was ELDIA)
    private int _productType2Id;  // no override, falls back to global (was ELPLATA)

    public ChargeableCharConfigHardeningTests(SqlTestFixture db)
    {
        _db = db;
    }

    public async Task InitializeAsync()
    {
        await _db.ResetAndSeedAsync();

        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();

        // Seed two dedicated ProductTypes for override/fallback resolution tests.
        // V023: ChargeableCharConfig.ProductTypeId references dbo.ProductType(Id).
        _productType1Id = await conn.ExecuteScalarAsync<int>("""
            INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
            OUTPUT INSERTED.Id
            VALUES ('Hardening PT1 (override)', 0, 0, 0, 0, 0)
            """);

        _productType2Id = await conn.ExecuteScalarAsync<int>("""
            INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
            OUTPUT INSERTED.Id
            VALUES ('Hardening PT2 (fallback)', 0, 0, 0, 0, 0)
            """);
    }

    public Task DisposeAsync() => Task.CompletedTask;

    // ─────────────────────────────────────────────────────────────────────────
    // T7.1 — Concurrency: only one winner survives the race
    //
    // Three parallel connections try to InsertWithClose for the same (ProductTypeId=null, Symbol).
    // The SP uses SERIALIZABLE + UPDLOCK + HOLDLOCK, so only one can commit.
    // The other two must receive SqlException (50409, 2601, 2627, or deadlock 1205).
    //
    // After resolution: exactly 1 vigente row exists for (ProductTypeId=NULL, Symbol).
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Concurrency_ThreeParallelInserts_ExactlyOneWins()
    {
        // Unique symbol so this test doesn't conflict with other tests or seed data.
        const string symbol = "¢";
        const string category = "Currency";

        // Barrier: all tasks must wait until released simultaneously to maximize the race.
        var barrier = new SemaphoreSlim(0, 3);

        async Task<Exception?> TryInsert(decimal price)
        {
            await barrier.WaitAsync();
            try
            {
                await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
                await conn.OpenAsync();

                var p = new DynamicParameters();
                p.Add("@ProductTypeId", null,                               System.Data.DbType.Int32);
                p.Add("@Symbol",       symbol,                             System.Data.DbType.String);
                p.Add("@Category",     category,                           System.Data.DbType.String);
                p.Add("@PricePerUnit", price,                              System.Data.DbType.Decimal, precision: 18, scale: 4);
                p.Add("@ValidFrom",    new DateTime(2027, 9, 1),           System.Data.DbType.Date);
                p.Add("@NewId",        dbType: System.Data.DbType.Int64,   direction: System.Data.ParameterDirection.Output);
                p.Add("@ClosedId",     dbType: System.Data.DbType.Int64,   direction: System.Data.ParameterDirection.Output);

                await conn.ExecuteAsync(
                    "dbo.usp_ChargeableCharConfig_InsertWithClose",
                    p,
                    commandType: System.Data.CommandType.StoredProcedure);

                return null; // winner
            }
            catch (SqlException ex)
            {
                // Expected losers: 50409 (forward-only), 2601/2627 (unique index), 1205 (deadlock)
                return ex;
            }
        }

        var t1 = Task.Run(() => TryInsert(1.00m));
        var t2 = Task.Run(() => TryInsert(2.00m));
        var t3 = Task.Run(() => TryInsert(3.00m));

        // Release all three simultaneously to create a genuine race.
        barrier.Release(3);

        var results = await Task.WhenAll(t1, t2, t3);

        var successes = results.Count(r => r is null);
        var failures  = results.Count(r => r is not null);

        successes.Should().Be(1, "exactly one concurrent InsertWithClose must succeed");
        failures.Should().Be(2, "the other two concurrent inserts must fail with SqlException");

        // Verify post-race state: exactly 1 vigente row for (ProductTypeId=NULL, '¢')
        // V023: MedioId → ProductTypeId; global fallback = ProductTypeId IS NULL
        await using var verifyConn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await verifyConn.OpenAsync();

        var vigente = await verifyConn.ExecuteScalarAsync<int>("""
            SELECT COUNT(1)
            FROM dbo.ChargeableCharConfig
            WHERE ProductTypeId IS NULL
              AND Symbol = @Symbol
              AND ValidTo IS NULL
              AND IsActive = 1
            """, new { Symbol = symbol });

        vigente.Should().Be(1,
            "filtered unique index UX_ChargeableCharConfig_Vigente must prevent more than 1 vigente row per (ProductTypeId, Symbol)");

        // No duplicates: total row count must also be 1 (only the winner was inserted)
        var total = await verifyConn.ExecuteScalarAsync<int>("""
            SELECT COUNT(1)
            FROM dbo.ChargeableCharConfig
            WHERE ProductTypeId IS NULL
              AND Symbol = @Symbol
            """, new { Symbol = symbol });

        total.Should().Be(1, "no duplicate rows must exist — SERIALIZABLE guard ensures only one insert commits");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T7.2 — SYSTEM_VERSIONING: exact history row count before and after close
    //
    // Before any UPDATE: history table has 0 rows for the new Id.
    // After InsertWithClose closes the previous row (UPDATE): exactly 1 history row.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task SystemVersioning_HistoryCount_IsZeroBeforeClose_AndOneAfter()
    {
        const string symbol = "₽";

        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();

        // Insert first row (becomes vigente)
        var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 1.0m, new DateTime(2026, 1, 1));

        // Before any UPDATE: history table must have 0 rows for firstId
        var histBefore = await conn.ExecuteScalarAsync<int>("""
            SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
            """, new { Id = firstId });

        histBefore.Should().Be(0,
            "SYSTEM_VERSIONING only creates history rows on UPDATE/DELETE — INSERT produces no history row");

        // Insert second row which closes (UPDATEs) the first
        await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 2.0m, new DateTime(2026, 7, 1));

        // After the UPDATE (close): exactly 1 history row for firstId
        var histAfter = await conn.ExecuteScalarAsync<int>("""
            SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
            """, new { Id = firstId });

        histAfter.Should().Be(1,
            "SYSTEM_VERSIONING must produce exactly one history row when the vigente row is closed via UPDATE");

        // Verify: the history row captured the pre-close state (ValidTo was NULL before the UPDATE)
        var histRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
            SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig_History WHERE Id = @Id
            """, new { Id = firstId });

        ((object?)histRow).Should().NotBeNull("history row must exist after close");

        // The history captures the state as it was BEFORE the UPDATE:
        // before close, the row had ValidTo = NULL and IsActive = 1.
        ((object?)histRow!.ValidTo).Should().BeNull(
            "the history row captures the state before the close UPDATE — ValidTo was NULL at that point");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T7.3 — FOR SYSTEM_TIME AS OF: temporal snapshot returns pre-close state
    //
    // Create row at T0 → query FOR SYSTEM_TIME AS OF T0 → returns row with ValidTo = NULL.
    // After close → current query → row has ValidTo != NULL.
    // This validates that SYSTEM_VERSIONING preserves immutable history.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ForSystemTimeAsOf_ReturnsSnapshotAtT0_BeforeClose()
    {
        const string symbol = "₿";

        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();

        // Insert first row and capture the UTC timestamp immediately after
        var firstId = await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 5.0m, new DateTime(2026, 3, 1));

        // Capture T0: the SYSUTCDATETIME() right after INSERT (row is still active)
        var t0 = await conn.ExecuteScalarAsync<DateTime>("SELECT SYSUTCDATETIME()");

        // Wait 200ms so the SYSTEM_VERSIONING SysEndTime is strictly after T0
        // (SQL Server DATETIME2 has ~100ns precision — 200ms is more than sufficient)
        await Task.Delay(200);

        // Insert second row — this closes (UPDATEs) the first, sending it to history
        await ExecInsertWithCloseAsync(conn, null, symbol, "Currency", 6.0m, new DateTime(2026, 8, 1));

        // FOR SYSTEM_TIME AS OF T0: must return the first row in its pre-close state
        var snapshotRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
            SELECT Id, PricePerUnit, ValidTo
            FROM dbo.ChargeableCharConfig
            FOR SYSTEM_TIME AS OF @T0
            WHERE Id = @Id
            """, new { T0 = t0, Id = firstId });

        ((object?)snapshotRow).Should().NotBeNull(
            "FOR SYSTEM_TIME AS OF T0 must return the row as it existed at T0 (before the close UPDATE)");

        ((decimal)snapshotRow!.PricePerUnit).Should().Be(5.0m,
            "snapshot must reflect the original price before the close");

        ((object?)snapshotRow.ValidTo).Should().BeNull(
            "at T0 the row was vigente (ValidTo IS NULL) — snapshot must preserve this");

        // Current state: first row must now have ValidTo != NULL (it was closed)
        var currentRow = await conn.QuerySingleOrDefaultAsync<dynamic>("""
            SELECT ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id
            """, new { Id = firstId });

        ((object?)currentRow).Should().NotBeNull("the closed row still exists in the current table");
        ((object?)currentRow!.ValidTo).Should().NotBeNull(
            "after the close, the current row has ValidTo set — it is no longer vigente");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T7.4 — Per-ProductType + global fallback resolution via GetActiveForProductTypeAsync
    //
    // V023 scope delta: MedioId → ProductTypeId in table + SP.
    //
    // Scenario:
    //   - Global '$' at price 0.00 (seed row from ResetAndSeedAsync / canonical V022+V024 seed)
    //   - ProductType1-specific '$' at price 5.00 effective from 2026-01-01 (per-PT override)
    //   - ProductType2 has no override for '$'
    //
    // GetActiveConfigForProductTypeAsync(PT1, today) → '$' = 5.00 (per-PT override wins)
    // GetActiveConfigForProductTypeAsync(PT2, today) → '$' = 0.00 (global fallback)
    //
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetActiveConfigForProductType_Override_WinsOverGlobal()
    {
        var asOf = new DateOnly(2026, 6, 1);

        // Seed per-PT override for ProductType1: '$' at 5.00 effective from 2026-01-01
        await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await seedConn.OpenAsync();

        await ExecInsertWithCloseAsync(seedConn, _productType1Id, "$", "Currency", 5.0000m, new DateTime(2026, 1, 1));

        // Build the repository + service (C# method will be renamed in Agent 2)
        var repo = BuildRepository();
        var rows = await repo.GetActiveForProductTypeAsync((long)_productType1Id, asOf);

        // The per-PT '$' must be returned
        var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
        dollarRow.Should().NotBeNull("ProductType1 has a per-PT '$' override — SP must return it");

        dollarRow!.ProductTypeId.Should().Be(_productType1Id,
            "the per-PT row (ProductTypeId = PT1) must take priority over the global row");

        dollarRow.PricePerUnit.Should().Be(5.0000m,
            "ProductType1 override has price 5.00, not the global 0.00");
    }

    [Fact]
    public async Task GetActiveConfigForProductType_NoOverride_FallsBackToGlobal()
    {
        var asOf = new DateOnly(2026, 6, 1);

        // ProductType2 has no per-PT rows — the canonical global seed from ResetAndSeedAsync
        // provides '$' at global price (0.0000 after V024).
        var repo = BuildRepository();
        var rows = await repo.GetActiveForProductTypeAsync((long)_productType2Id, asOf);

        // Must have at least the global '$' from seed
        rows.Should().NotBeEmpty("canonical seed provides global rows active as of 2026-06-01");

        var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
        dollarRow.Should().NotBeNull("global '$' must be returned for ProductType2 (no override exists)");

        dollarRow!.ProductTypeId.Should().BeNull(
            "ProductType2 has no override — the returned row must be the global row (ProductTypeId = NULL)");
    }

    [Fact]
    public async Task GetActiveConfigForProductType_ServiceLayer_AppliesPriorityCorrectly()
    {
        // End-to-end: IChargeableCharConfigService resolves the final dictionary.
        // Seed ProductType1 override for '%' (percentage) at 3.00; global '%' at 0.00 (from V024 seed).
        var asOf = new DateOnly(2026, 6, 1);

        await using var seedConn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await seedConn.OpenAsync();

        await ExecInsertWithCloseAsync(seedConn, _productType1Id, "%", "Percentage", 3.0000m, new DateTime(2026, 1, 1));

        // Build the service (wraps repo with priority resolution)
        var service = BuildService();

        var pt1Config  = await service.GetActiveConfigForProductTypeAsync((long)_productType1Id, asOf);
        var pt2Config  = await service.GetActiveConfigForProductTypeAsync((long)_productType2Id, asOf);

        // ProductType1: '%' must come from per-PT override at 3.00
        pt1Config.Should().ContainKey("%",
            "ProductType1 has a per-PT override for '%'");
        pt1Config["%"].PricePerUnit.Should().Be(3.0000m,
            "per-PT '%' at 3.00 must override the global 0.00 for ProductType1");

        // ProductType2: '%' must come from global fallback
        // Note: ChargeableCharSnapshot only exposes Category + PricePerUnit (not MedioId/ProductTypeId).
        // We verify via price: global '%' is seeded at 0.0000 by V024 (opt-in billing, was 1.0000 in V022).
        pt2Config.Should().ContainKey("%",
            "global '%' from canonical seed must appear in ProductType2's resolved config");
        // V024 resets global '%' to 0.0000 (opt-in billing — V022 placeholder 1.0000 replaced)
        pt2Config["%"].PricePerUnit.Should().Be(0.0000m,
            "ProductType2 falls back to the global '%' at 0.00 (V024 seed opt-in billing price)");
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    /// <summary>
    /// Helper: calls usp_ChargeableCharConfig_InsertWithClose directly via SQL.
    /// V023 scope delta: parameter renamed from @MedioId to @ProductTypeId.
    /// </summary>
    private static async Task<long> ExecInsertWithCloseAsync(
        SqlConnection conn,
        int? productTypeId,
        string symbol,
        string category,
        decimal pricePerUnit,
        DateTime validFrom)
    {
        var p = new DynamicParameters();
        p.Add("@ProductTypeId", productTypeId,    System.Data.DbType.Int32);   // V023: was @MedioId
        p.Add("@Symbol",        symbol,            System.Data.DbType.String);
        p.Add("@Category",      category,          System.Data.DbType.String);
        p.Add("@PricePerUnit",  pricePerUnit,      System.Data.DbType.Decimal, precision: 18, scale: 4);
        p.Add("@ValidFrom",     validFrom,         System.Data.DbType.Date);
        p.Add("@NewId",         dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);
        p.Add("@ClosedId",      dbType: System.Data.DbType.Int64, direction: System.Data.ParameterDirection.Output);

        await conn.ExecuteAsync(
            "dbo.usp_ChargeableCharConfig_InsertWithClose",
            p,
            commandType: System.Data.CommandType.StoredProcedure);

        return p.Get<long>("@NewId");
    }

    private static ChargeableCharConfigRepository BuildRepository()
        => new(new SqlConnectionFactory(TestConnectionStrings.AppTestDb));

    private static SIGCM2.Application.Pricing.ChargeableChars.ChargeableCharConfigService BuildService()
        => new(BuildRepository());
}
