using Dapper;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Pricing.ChargeableChars;
using SIGCM2.Domain.Pricing.Exceptions;
using SIGCM2.Infrastructure.Persistence;
using SIGCM2.TestSupport;
using Xunit;

namespace SIGCM2.Application.Tests.Infrastructure.Pricing;

/// <summary>
/// PRC-001 — Integration tests for ChargeableCharConfigRepository (Dapper) against SIGCM2_Test_App.
///
/// All tests run against the real DB via SqlTestFixture (Database collection).
/// Canonical seed (4 global symbols: $, %, !, ¡) is loaded by ResetAndSeedAsync().
/// Tests that mutate specific (ProductTypeId, Symbol) pairs clean their own state before mutating.
///
/// V023 scope delta: MedioId → ProductTypeId. Uses dbo.ProductType for per-PT override tests.
/// Uses unique name "RepoIntegration PT1" to avoid uniqueness conflicts with HardeningTests.
///
/// Spec coverage:
///   T4.1  InsertWithCloseAsync — first insert for symbol → new row, returns Id
///   T4.2  InsertWithCloseAsync — with existing vigente → closes previous, inserts new
///   T4.3  InsertWithCloseAsync — backdate attempt → ThrowsForwardOnlyException
///   T4.4  InsertWithCloseAsync — system versioning captures history row after mutation
///   T4.5  GetActiveForProductTypeAsync — PT has override → returns both PT and global rows
///   T4.6  GetActiveForProductTypeAsync — no PT override → returns only global rows
///   T4.7  ListAsync — paginates (skip/take)
///   T4.8  CountAsync — filters by activeOnly
///   T4.9  GetByIdAsync — missing → returns null
///   T4.10 GetByIdAsync — exists → returns entity
///   T4.11 DeactivateAsync — sets IsActive = false and ValidTo = today
///   T4.12 DeactivateAsync — already inactive → idempotent (no-op)
///   T4.13 ReactivateAsync — last closed row → returns reactivated entity
///   T4.14 ReactivateAsync — already active → throws ALREADY_ACTIVE
///   T4.15 DeleteAsync — row exists → deleted (0 rows after)
///   T4.16 DeleteAsync — row not found → throws KeyNotFoundException
/// </summary>
[Collection("Database")]
public class ChargeableCharConfigRepositoryIntegrationTests : IAsyncLifetime
{
    private readonly SqlTestFixture _db;
    private int _productTypeId;

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

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

        // Create a dedicated ProductType for per-PT tests.
        // Unique name to avoid conflicts with HardeningTests ("RepoIntegration PT1").
        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();

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

    public Task DisposeAsync() => Task.CompletedTask;

    // ─────────────────────────────────────────────────────────────────────────
    // T4.1 — InsertWithCloseAsync: first insert for new symbol → row created, returns Id > 0
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task InsertWithCloseAsync_FirstInsertForSymbol_CreatesRowAndReturnsId()
    {
        // NEW symbol not in canonical seed — use per-PT so it doesn't conflict
        const string symbol = "@";
        var repo = BuildRepository();

        var newId = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         2.5000m,
            validFrom:     new DateOnly(2026, 1, 1));

        newId.Should().BeGreaterThan(0, "first insert must return the new row's Id");

        // Verify the row exists in DB
        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();

        var row = await conn.QuerySingleOrDefaultAsync<dynamic>(
            "SELECT Id, Symbol, IsActive, ValidTo FROM dbo.ChargeableCharConfig WHERE Id = @Id",
            new { Id = newId });

        ((object?)row).Should().NotBeNull();
        ((string)row!.Symbol).Should().Be(symbol);
        ((bool)row.IsActive).Should().BeTrue();
        ((object?)row.ValidTo).Should().BeNull("first insert has no ValidTo — still active");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.2 — InsertWithCloseAsync: with existing vigente → closes previous, inserts new
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task InsertWithCloseAsync_WithExistingVigente_ClosesPreviousAndInsertsNew()
    {
        const string symbol = "#";
        var repo = BuildRepository();

        // First insert — becomes the vigente
        var firstId = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         1.0000m,
            validFrom:     new DateOnly(2026, 3, 1));

        // Second insert (forward) — must close the first
        var secondValidFrom = new DateOnly(2026, 6, 1);
        var secondId = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         2.0000m,
            validFrom:     secondValidFrom);

        secondId.Should().BeGreaterThan(firstId, "second row must be a new insert with higher Id");

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

        // First row must now have ValidTo = secondValidFrom - 1 day = 2026-05-31
        var firstRow = await conn.QuerySingleAsync<dynamic>(
            "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id",
            new { Id = firstId });

        ((DateTime)firstRow.ValidTo).Date.Should().Be(new DateTime(2026, 5, 31),
            "SP closes the vigente with ValidTo = new ValidFrom - 1 day");

        // Second row must be the new vigente (ValidTo IS NULL)
        var secondRow = await conn.QuerySingleAsync<dynamic>(
            "SELECT ValidTo, IsActive FROM dbo.ChargeableCharConfig WHERE Id = @Id",
            new { Id = secondId });

        ((object?)secondRow.ValidTo).Should().BeNull("new vigente has ValidTo = NULL");
        ((bool)secondRow.IsActive).Should().BeTrue();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.3 — InsertWithCloseAsync: backdate attempt → ThrowsForwardOnlyException
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task InsertWithCloseAsync_BackdateAttempt_ThrowsChargeableCharConfigForwardOnlyException()
    {
        const string symbol = "€";
        var repo = BuildRepository();

        // Establish a vigente at 2026-04-01
        await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Currency",
            price:         1.5000m,
            validFrom:     new DateOnly(2026, 4, 1));

        // Try to insert retroactively — SP will THROW 50409
        var act = async () => await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Currency",
            price:         1.2000m,
            validFrom:     new DateOnly(2026, 3, 1));

        await act.Should()
            .ThrowAsync<ChargeableCharConfigForwardOnlyException>(
                "SQL THROW 50409 must be mapped to ChargeableCharConfigForwardOnlyException");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.4 — InsertWithCloseAsync: SYSTEM_VERSIONING captures history row after close
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task InsertWithCloseAsync_SystemVersioningCaptures_HistoryHasRowAfterClose()
    {
        const string symbol = "£";
        var repo = BuildRepository();

        // Insert the first row
        var firstId = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Currency",
            price:         3.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        // Insert a second row — this triggers an UPDATE on the first row → history
        await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Currency",
            price:         4.0000m,
            validFrom:     new DateOnly(2026, 7, 1));

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

        var histCount = await conn.ExecuteScalarAsync<int>(
            "SELECT COUNT(1) FROM dbo.ChargeableCharConfig_History WHERE Id = @Id",
            new { Id = firstId });

        histCount.Should().BeGreaterThanOrEqualTo(1,
            "SYSTEM_VERSIONING must create a history row when the vigente row is closed via UPDATE");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.5 — GetActiveForProductTypeAsync: PT has override → returns both PT and global rows
    // Note: SP returns ALL rows (global + per-PT); service does priority resolution.
    //       This test verifies the REPOSITORY returns both, not just one.
    // ─────────────────────────────────────────────────────────────────────────

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

        // Add a per-PT override for symbol '$'
        // Canonical seed already has global '$' from ResetAndSeedAsync
        await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        "$",
            category:      "Currency",
            price:         5.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var rows = await repo.GetActiveForProductTypeAsync((long)_productTypeId, asOf);

        // The SP returns both the per-PT '$' AND global rows for other symbols
        rows.Should().NotBeEmpty("there are active global rows seeded by canonical seed");

        var dollarRow = rows.FirstOrDefault(r => r.Symbol == "$");
        dollarRow.Should().NotBeNull("the SP must return a row for '$'");
        dollarRow!.ProductTypeId.Should().Be(_productTypeId,
            "per-PT row takes priority over global in the SP's ROW_NUMBER ordering");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.6 — GetActiveForProductTypeAsync: no PT override → returns only global rows
    // ─────────────────────────────────────────────────────────────────────────

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

        // Use a DIFFERENT productTypeId that has no per-PT rows
        await using var conn = new SqlConnection(TestConnectionStrings.AppTestDb);
        await conn.OpenAsync();
        var otherPTId = await conn.ExecuteScalarAsync<int>("""
            INSERT INTO dbo.ProductType (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages)
            OUTPUT INSERTED.Id
            VALUES ('RepoIntegration PT2 NoOverride', 0, 0, 0, 0, 0)
            """);

        var rows = await repo.GetActiveForProductTypeAsync((long)otherPTId, asOf);

        rows.Should().NotBeEmpty("canonical seed has 4 global rows active since 2026-01-01");
        rows.Should().AllSatisfy(r =>
            r.ProductTypeId.Should().BeNull("all returned rows must be global (ProductTypeId = NULL)"));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.7 — ListAsync: paginates via skip/take
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ListAsync_Paginates_ReturnsCorrectSubset()
    {
        var repo = BuildRepository();

        // Canonical seed has 4 global rows. Request page 1 (skip=0, take=2) and page 2 (skip=2, take=2).
        var page1 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 0, take: 2);
        var page2 = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 2, take: 2);

        page1.Should().HaveCount(2, "take=2 with at least 4 rows");
        page2.Should().HaveCount(2, "second page of 4 rows");

        // No overlap
        page1.Select(r => r.Id).Intersect(page2.Select(r => r.Id))
            .Should().BeEmpty("two non-overlapping pages must not share any row");
    }

    [Fact]
    public async Task ListAsync_PageBeyondTotal_ReturnsEmpty()
    {
        var repo = BuildRepository();
        var result = await repo.ListAsync(productTypeId: null, activeOnly: false, skip: 1000, take: 10);
        result.Should().BeEmpty("skip far beyond available data must return empty");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.8 — CountAsync: filters by activeOnly
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task CountAsync_FiltersByActiveOnly_CountsOnlyActiveRows()
    {
        var repo = BuildRepository();

        // Canonical seed: 4 active global rows
        var countAll    = await repo.CountAsync(productTypeId: null, activeOnly: false);
        var countActive = await repo.CountAsync(productTypeId: null, activeOnly: true);

        countAll.Should().BeGreaterThanOrEqualTo(4,
            "canonical seed provides at least 4 rows (may have more if other tests ran)");
        countActive.Should().BeGreaterThanOrEqualTo(4,
            "all canonical rows are active");
        countActive.Should().BeLessThanOrEqualTo(countAll,
            "active-only count must be <= total count");
    }

    [Fact]
    public async Task CountAsync_AfterDeactivation_ActiveCountDecreases()
    {
        var repo = BuildRepository();

        // Insert a row, then deactivate it — active count should decrease by 1
        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        "~",
            category:      "Other",
            price:         1.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var today = new DateOnly(2026, 4, 20);
        var beforeDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true);

        await repo.DeactivateAsync(id, today);

        var afterDeactivate = await repo.CountAsync(productTypeId: (long?)_productTypeId, activeOnly: true);

        afterDeactivate.Should().Be(beforeDeactivate - 1,
            "deactivating one row must decrease the active count by 1");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.9 — GetByIdAsync: missing → returns null
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetByIdAsync_Missing_ReturnsNull()
    {
        var repo = BuildRepository();
        var result = await repo.GetByIdAsync(999_999_999L);
        result.Should().BeNull("non-existent Id must return null");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.10 — GetByIdAsync: exists → returns entity with all fields correct
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetByIdAsync_Exists_ReturnsEntityWithCorrectFields()
    {
        var repo = BuildRepository();

        var expectedValidFrom = new DateOnly(2026, 2, 1);
        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        "^",
            category:      "Other",
            price:         7.5000m,
            validFrom:     expectedValidFrom);

        var entity = await repo.GetByIdAsync(id);

        entity.Should().NotBeNull();
        entity!.Id.Should().Be(id);
        entity.ProductTypeId.Should().Be(_productTypeId);
        entity.Symbol.Should().Be("^");
        entity.Category.Should().Be("Other");
        entity.PricePerUnit.Should().Be(7.5000m);
        entity.ValidFrom.Should().Be(expectedValidFrom);
        entity.ValidTo.Should().BeNull("freshly inserted row has no ValidTo");
        entity.IsActive.Should().BeTrue();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.11 — DeactivateAsync: sets IsActive = false and ValidTo = today
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task DeactivateAsync_SetsIsActiveFalseAndValidToToday()
    {
        var repo = BuildRepository();

        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        "&",
            category:      "Other",
            price:         1.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var today = new DateOnly(2026, 4, 20);
        await repo.DeactivateAsync(id, today);

        var entity = await repo.GetByIdAsync(id);

        entity.Should().NotBeNull();
        entity!.IsActive.Should().BeFalse("deactivated row must have IsActive = false");
        entity.ValidTo.Should().Be(today, "ValidTo must be set to the provided today date");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.12 — DeactivateAsync: already inactive → idempotent (no error, row unchanged)
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task DeactivateAsync_AlreadyInactive_IsIdempotent()
    {
        var repo = BuildRepository();

        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        "*",
            category:      "Other",
            price:         1.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var today = new DateOnly(2026, 4, 20);

        // Deactivate once
        await repo.DeactivateAsync(id, today);

        // Deactivate again — must be a no-op, no exception
        var act = async () => await repo.DeactivateAsync(id, today);
        await act.Should().NotThrowAsync("re-deactivating an already-inactive row must be idempotent");

        // State must remain the same
        var entity = await repo.GetByIdAsync(id);
        entity!.IsActive.Should().BeFalse();
        entity.ValidTo.Should().Be(today);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.13 — ReactivateAsync: last closed row → returns reactivated entity
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ReactivateAsync_LastClosedRow_ReturnsReactivatedEntity()
    {
        var repo = BuildRepository();

        // Insert a row, then deactivate it — it becomes "last closed" for this symbol
        const string symbol = "≈";
        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         2.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var today = new DateOnly(2026, 4, 20);
        await repo.DeactivateAsync(id, today);

        // Reactivate — no posterior rows, no vigente
        var reactivated = await repo.ReactivateAsync(id);

        reactivated.Should().NotBeNull();
        reactivated.Id.Should().Be(id);
        reactivated.IsActive.Should().BeTrue("row must be active after reactivation");
        reactivated.ValidTo.Should().BeNull("ValidTo must be NULL after reactivation");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.14 — ReactivateAsync: already active → throws ALREADY_ACTIVE
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task ReactivateAsync_AlreadyActive_ThrowsAlreadyActive()
    {
        var repo = BuildRepository();

        // Insert an active row — do NOT deactivate it
        const string symbol = "≠";
        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         3.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        var act = async () => await repo.ReactivateAsync(id);

        await act.Should()
            .ThrowAsync<ChargeableCharConfigReactivationNotAllowedException>()
            .Where(e => e.Reason == "ALREADY_ACTIVE",
                "SP 50410 → ALREADY_ACTIVE reason");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.15 — DeleteAsync: row exists → deleted (0 rows after)
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task DeleteAsync_ExistingRow_RowIsGone()
    {
        var repo = BuildRepository();

        const string symbol = "∞";
        var id = await repo.InsertWithCloseAsync(
            productTypeId: (long?)_productTypeId,
            symbol:        symbol,
            category:      "Other",
            price:         1.0000m,
            validFrom:     new DateOnly(2026, 1, 1));

        await repo.DeleteAsync(id);

        // Row must be gone from current state
        var entity = await repo.GetByIdAsync(id);
        entity.Should().BeNull("deleted row must not appear in GetByIdAsync");
    }

    // ─────────────────────────────────────────────────────────────────────────
    // T4.16 — DeleteAsync: row not found → throws KeyNotFoundException
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task DeleteAsync_NotFound_ThrowsKeyNotFoundException()
    {
        var repo = BuildRepository();

        var act = async () => await repo.DeleteAsync(999_999_997L);

        await act.Should().ThrowAsync<KeyNotFoundException>(
            "non-existent Id must throw KeyNotFoundException");
    }

    // ── Helper ───────────────────────────────────────────────────────────────

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