using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;

namespace SVSim.UnitTests.Services;

public class PackOpenServiceTests
{
    /// <summary>Deterministic RNG that returns the supplied doubles in order, cycling.</summary>
    private sealed class ScriptedRandom : IRandom
    {
        private readonly double[] _seq; private int _i;
        public ScriptedRandom(params double[] seq) { _seq = seq; }
        public double NextDouble() { var v = _seq[_i++ % _seq.Length]; return v; }
        public int Next(int maxExclusive) => (int)(NextDouble() * maxExclusive);
    }

    /// <summary>Simple in-memory pool keyed by rarity for slot-distribution tests.</summary>
    private sealed class StubPool : ICardPoolProvider
    {
        private readonly IReadOnlyList<ShadowverseCardEntry> _cards;
        public StubPool(IReadOnlyList<ShadowverseCardEntry> cards) { _cards = cards; }
        public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _cards;
        public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) => null;
    }

    /// <summary>
    /// Test stub that returns a single pre-built section. Only handles <see cref="PackRateConfig"/>
    /// (the type <see cref="PackOpenService"/> reads in its ctor); other section types throw so a
    /// future test that needs them must extend this stub explicitly.
    /// </summary>
    private sealed class StubConfig : IGameConfigService
    {
        private readonly PackRateConfig _rates;
        public StubConfig(PackRateConfig rates) { _rates = rates; }
        public T Get<T>() where T : class, new()
        {
            if (typeof(T) == typeof(PackRateConfig)) return (T)(object)_rates;
            throw new NotImplementedException($"StubConfig: unhandled section type {typeof(T)}.");
        }
    }

    private static PackOpenService MakeService(PackRateConfig rates) => new(new StubConfig(rates));

    private static List<ShadowverseCardEntry> MakeFourCards() => new()
    {
        new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze },
        new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Silver },
        new ShadowverseCardEntry { Id = 3, Rarity = Rarity.Gold },
        new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary },
    };

    private static PackConfigEntry StandardPack() => new()
    {
        Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
    };

    [Test]
    public void Draw_returns_eight_cards_for_one_pack()
    {
        var svc = MakeService(PackRateConfig.ShippedDefaults());
        var pool = new StubPool(MakeFourCards());
        var rng = new ScriptedRandom(0.5);  // anything in Bronze/Silver range for non-slot-8

        var result = svc.Draw(StandardPack(), pool, packNumber: 1, excludeCardIds: Array.Empty<long>(), rng: rng);

        Assert.That(result.Cards.Count, Is.EqualTo(8));
    }

    [Test]
    public void Draw_slot_8_never_returns_bronze_for_standard_pack()
    {
        // PackRateConfig.ShippedDefaults() includes the SV Classic slot-8 "Silver-or-better
        // guarantee" entry (PerSlot Bronze=0). Same shape the runtime seeder writes to GameConfigs.
        var svc = MakeService(PackRateConfig.ShippedDefaults());
        var pool = new StubPool(MakeFourCards());

        for (int trial = 0; trial < 1000; trial++)
        {
            var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
            Assert.That(result.Cards[7].Rarity, Is.Not.EqualTo(Rarity.Bronze),
                $"slot 8 must never be Bronze (trial {trial})");
        }
    }

    [Test]
    public void Draw_legendary_special_forces_slot_8_to_legendary()
    {
        var svc = MakeService(PackRateConfig.ShippedDefaults());
        var pool = new StubPool(MakeFourCards());
        var pack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };

        for (int trial = 0; trial < 100; trial++)
        {
            var result = svc.Draw(pack, pool, 1, Array.Empty<long>(), new SystemRandom(trial));
            Assert.That(result.Cards[7].Rarity, Is.EqualTo(Rarity.Legendary),
                $"legendary-special pack slot 8 must be Legendary (trial {trial})");
        }
    }

    [Test]
    public void Draw_distribution_over_10k_slots_1_to_7_matches_declared_rates_within_2_percent()
    {
        var svc = MakeService(PackRateConfig.ShippedDefaults());
        var pool = new StubPool(MakeFourCards());
        var counts = new Dictionary<Rarity, int>
        {
            { Rarity.Bronze, 0 }, { Rarity.Silver, 0 }, { Rarity.Gold, 0 }, { Rarity.Legendary, 0 }
        };

        var rng = new SystemRandom(seed: 42);
        const int packs = 10_000;
        for (int i = 0; i < packs; i++)
        {
            var r = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), rng);
            // Only look at slots 0..6 (the unrestricted rarity slots)
            for (int s = 0; s < 7; s++) counts[r.Cards[s].Rarity]++;
        }

        int total = packs * 7;
        double bronze = counts[Rarity.Bronze] / (double)total;
        double silver = counts[Rarity.Silver] / (double)total;
        double gold   = counts[Rarity.Gold]   / (double)total;
        double leg    = counts[Rarity.Legendary] / (double)total;

        Assert.That(bronze, Is.EqualTo(0.6744).Within(0.02), $"bronze rate {bronze:P}");
        Assert.That(silver, Is.EqualTo(0.2500).Within(0.02), $"silver rate {silver:P}");
        Assert.That(gold,   Is.EqualTo(0.0600).Within(0.01), $"gold rate {gold:P}");
        Assert.That(leg,    Is.EqualTo(0.0150).Within(0.01), $"legendary rate {leg:P}");
    }

    [Test]
    public void Draw_excludes_listed_card_ids()
    {
        var svc = MakeService(PackRateConfig.ShippedDefaults());
        // Pool with two bronze cards; exclude one — every Bronze slot must pick the other.
        var pool = new StubPool(new List<ShadowverseCardEntry>
        {
            new() { Id = 1, Rarity = Rarity.Bronze },
            new() { Id = 99, Rarity = Rarity.Bronze },
            new() { Id = 2, Rarity = Rarity.Silver },
        });

        var rng = new SystemRandom(seed: 7);
        var result = svc.Draw(StandardPack(), pool, 1, excludeCardIds: new long[] { 1 }, rng: rng);

        foreach (var c in result.Cards.Where(x => x.Rarity == Rarity.Bronze))
        {
            Assert.That(c.CardId, Is.EqualTo(99), "excluded card 1 must never appear in Bronze slot");
        }
    }

    [Test]
    public void Draw_per_slot_override_is_applied_for_that_slot_and_default_for_others()
    {
        // Config: slot 3 is forced to Legendary; everything else uses Default.
        // PerSlot is a List<SlotRarityWeights> with a Slot string key (no Dictionary<int,T> of
        // complex types under jsonb-friendly serialisation — see Task 5 notes).
        var rates = PackRateConfig.ShippedDefaults();
        rates.PerSlot.Add(new SlotRarityWeights
        {
            Slot = "3",
            Bronze = 0, Silver = 0, Gold = 0, Legendary = 1.0,
        });

        var svc = MakeService(rates);
        var pool = new StubPool(MakeFourCards());

        for (int trial = 0; trial < 50; trial++)
        {
            var result = svc.Draw(StandardPack(), pool, 1, Array.Empty<long>(), new SystemRandom(trial));
            Assert.That(result.Cards[2].Rarity, Is.EqualTo(Rarity.Legendary),
                $"slot 3 must be Legendary under PerSlot[3] override (trial {trial})");
        }
    }

    /// <summary>StubPool variant that also implements TryGetFoilTwin via the SAME id+1 convention
    /// as the DB-backed provider, but keyed off an injected dictionary so tests stay hermetic.</summary>
    private sealed class StubPoolWithFoils : ICardPoolProvider
    {
        private readonly IReadOnlyList<ShadowverseCardEntry> _pool;
        private readonly Dictionary<long, ShadowverseCardEntry> _foilsByBaseId;
        public StubPoolWithFoils(IReadOnlyList<ShadowverseCardEntry> pool, Dictionary<long, ShadowverseCardEntry> foilsByBaseId)
        {
            _pool = pool;
            _foilsByBaseId = foilsByBaseId;
        }
        public IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry _) => _pool;
        public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
            _foilsByBaseId.TryGetValue(baseCardId, out var f) ? f : null;
    }

    [Test]
    public void Draw_animated_rate_upgrades_about_8_percent_of_slots_within_tolerance()
    {
        // One bronze card with a foil twin; rate = 0.08; ~8% of 8000 slots should be foil.
        var bronze     = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
        var bronzeFoil = new ShadowverseCardEntry { Id = 2, Rarity = Rarity.Bronze, IsFoil = true  };
        var pools = new StubPoolWithFoils(
            new List<ShadowverseCardEntry> { bronze },
            new Dictionary<long, ShadowverseCardEntry> { [bronze.Id] = bronzeFoil });

        var svc = MakeService(PackRateConfig.ShippedDefaults());   // default AnimatedRate = 0.08

        const int packs = 1_000;            // 8000 slots
        int foilCount = 0;
        var rng = new SystemRandom(seed: 7);
        for (int i = 0; i < packs; i++)
        {
            var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), rng);
            foilCount += r.Cards.Count(c => c.CardId == bronzeFoil.Id);
        }
        double rate = foilCount / (double)(packs * 8);
        Assert.That(rate, Is.EqualTo(0.08).Within(0.015),
            $"observed animated rate {rate:P} outside the ±1.5% tolerance of 8%");
    }

    [Test]
    public void Draw_animated_upgrade_silently_keeps_base_when_no_foil_twin_exists()
    {
        var bronze = new ShadowverseCardEntry { Id = 1, Rarity = Rarity.Bronze, IsFoil = false };
        var pools = new StubPoolWithFoils(
            new List<ShadowverseCardEntry> { bronze },
            new Dictionary<long, ShadowverseCardEntry>());   // no foils

        // Force the animated roll to always hit by setting AnimatedRate = 1.0.
        var rates = PackRateConfig.ShippedDefaults();
        rates.AnimatedRate = 1.0;
        var svc = MakeService(rates);

        var r = svc.Draw(StandardPack(), pools, 1, Array.Empty<long>(), new SystemRandom(seed: 1));
        foreach (var c in r.Cards)
        {
            Assert.That(c.CardId, Is.EqualTo(bronze.Id),
                "no foil twin available; every slot must keep the base card despite 100% animated rate");
        }
    }

    [Test]
    public void Draw_animated_upgrade_applies_to_slot_8_including_legendary_specials()
    {
        var leg     = new ShadowverseCardEntry { Id = 4, Rarity = Rarity.Legendary, IsFoil = false };
        var legFoil = new ShadowverseCardEntry { Id = 5, Rarity = Rarity.Legendary, IsFoil = true  };
        var pools = new StubPoolWithFoils(
            new List<ShadowverseCardEntry> { leg },
            new Dictionary<long, ShadowverseCardEntry> { [leg.Id] = legFoil });

        var rates = PackRateConfig.ShippedDefaults();
        rates.AnimatedRate = 1.0;   // every slot upgrades
        var svc = MakeService(rates);

        var specialPack = new PackConfigEntry { Id = 92001, BasePackId = 90001, PackCategory = PackCategory.SpecialCardPack };
        var r = svc.Draw(specialPack, pools, 1, Array.Empty<long>(), new SystemRandom(seed: 3));

        // Slot 8 is forced Legendary by the structural rule; with AnimatedRate=1.0 it must be the foil legendary.
        Assert.That(r.Cards[7].CardId, Is.EqualTo(legFoil.Id),
            "legendary-special slot 8 must be the foil-legendary when animated rate is forced to 1.0");
    }

    /// <summary>
    /// Regression guard for the 2026-05-24 slot-8-ignores-PerSlot-override bug. Invariant: a
    /// freshly-constructed <see cref="PackRateConfig"/> has an EMPTY PerSlot list. The original
    /// trigger (EF Core 8's <c>OwnsMany</c>+<c>ToJson</c> jsonb materialisation appending rows
    /// onto whatever the parent's ctor produced — leaving two slot-8 entries where the seeded one
    /// silently won <see cref="PackOpenService.ResolveWeights"/>'s <c>FirstOrDefault</c>) is gone
    /// now (config goes through <c>IGameConfigService</c> + STJ, which replaces correctly). The
    /// invariant stays because any future config layer that hydrates into a pre-initialised
    /// collection (custom deserialiser, ORM, manual Add loop) would resurrect the same failure
    /// mode. Defaults for collections live in <see cref="PackRateConfig.ShippedDefaults"/>.
    /// </summary>
    [Test]
    public void PackRateConfig_PerSlot_defaults_to_empty_to_avoid_jsonb_append_bug()
    {
        Assert.That(new PackRateConfig().PerSlot, Is.Empty,
            "PackRateConfig.PerSlot must default to empty — see test docstring for why.");
    }
}
