using PkmnLib.Dynamic.Models;
using PkmnLib.Plugin.Gen7.Scripts.Moves;
using PkmnLib.Static;
using PkmnLib.Static.Utils;

namespace PkmnLib.Plugin.Gen7.Tests.Scripts.Moves;

/// <summary>
/// Tests for the <see cref="ChangeTargetSpecialDefense"/> script, which implements the secondary effect of Acid.
/// Behavior is verified against the Bulbapedia page for Acid.
/// Gen VII behavior: "Acid inflicts damage to all adjacent opponents" and "has a 10% chance of lowering the
/// target's Special Defense stat by one stage". The 10% proc chance and the targeting are handled by the
/// generic secondary effect data (chance: 10 in Moves.jsonc); this script is responsible for applying the
/// stat change once the effect procs.
/// </summary>
public class ChangeTargetSpecialDefenseTests
{
    private static ChangeTargetSpecialDefense CreateInitializedScript(int amount = -1)
    {
        var script = new ChangeTargetSpecialDefense();
        script.OnInitialize(new Dictionary<StringKey, object?> { { "amount", amount } });
        return script;
    }

    /// <summary>
    /// Helper to extract the arguments of the ChangeStatBoost call received by a substitute target.
    /// </summary>
    private static object?[]? GetStatBoostCallArgs(IPokemon target)
    {
        var call = target.ReceivedCalls().FirstOrDefault(c => c.GetMethodInfo().Name == "ChangeStatBoost");
        return call?.GetArguments();
    }

    /// <summary>
    /// Bulbapedia: "Acid now has a 10% chance of lowering the target's Special Defense stat by one stage instead."
    /// (Generation IV onwards.) When the secondary effect procs, the target's Special Defense is lowered by one stage.
    /// </summary>
    [Test]
    public async Task OnSecondaryEffect_TargetIsOpponent_LowersSpecialDefenseByOneStage()
    {
        // Arrange
        var script = CreateInitializedScript();
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);
        var target = Substitute.For<IPokemon>();

        // Act
        script.OnSecondaryEffect(move, target, 0);

        // Assert
        var args = GetStatBoostCallArgs(target);
        await Assert.That(args).IsNotNull();
        await Assert.That((Statistic)args![0]!).IsEqualTo(Statistic.SpecialDefense);
        await Assert.That((sbyte)args[1]!).IsEqualTo((sbyte)-1);
    }

    /// <summary>
    /// Bulbapedia (Generation IV onwards): "lowering the target's Special Defense stat by one stage instead."
    /// The word "instead" marks the change from the Gen I/II Defense drop — the script must not touch
    /// the target's (physical) Defense stat.
    /// </summary>
    [Test]
    public async Task OnSecondaryEffect_TargetIsOpponent_DoesNotLowerDefense()
    {
        // Arrange
        var script = CreateInitializedScript();
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);
        var target = Substitute.For<IPokemon>();

        // Act
        script.OnSecondaryEffect(move, target, 0);

        // Assert - exactly one stat change, and it is not Defense
        var calls = target.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "ChangeStatBoost").ToList();
        await Assert.That(calls.Count).IsEqualTo(1);
        await Assert.That((Statistic)calls[0].GetArguments()[0]!).IsNotEqualTo(Statistic.Defense);
    }

    /// <summary>
    /// Bulbapedia: "lowering the target's Special Defense stat".
    /// The stat drop is inflicted on an opponent, so it must not be marked as self-inflicted
    /// (self-inflicted drops bypass effects that prevent stat lowering by opponents, e.g. Clear Body/Mist).
    /// </summary>
    [Test]
    public async Task OnSecondaryEffect_TargetIsOpponent_NotSelfInflicted()
    {
        // Arrange
        var script = CreateInitializedScript();
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);
        var target = Substitute.For<IPokemon>();

        // Act
        script.OnSecondaryEffect(move, target, 0);

        // Assert
        var args = GetStatBoostCallArgs(target);
        await Assert.That(args).IsNotNull();
        await Assert.That((bool)args![2]!).IsFalse();
    }

    /// <summary>
    /// Bulbapedia: "a 10% chance of lowering the target's Special Defense stat".
    /// The drop is an ordinary stat change, not a forced one — it must respect prevention effects,
    /// so the script may not pass force = true.
    /// </summary>
    [Test]
    public async Task OnSecondaryEffect_TargetIsOpponent_StatDropIsNotForced()
    {
        // Arrange
        var script = CreateInitializedScript();
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);
        var target = Substitute.For<IPokemon>();

        // Act
        script.OnSecondaryEffect(move, target, 0);

        // Assert
        var args = GetStatBoostCallArgs(target);
        await Assert.That(args).IsNotNull();
        await Assert.That((bool)args![3]!).IsFalse();
    }

    /// <summary>
    /// Technical test: the shared ChangeTargetStats base marks the stat change as self-inflicted
    /// when the target of the secondary effect is the move's own user.
    /// </summary>
    [Test]
    public async Task OnSecondaryEffect_TargetIsUser_SelfInflicted()
    {
        // Arrange
        var script = CreateInitializedScript();
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);

        // Act - the user is hit by its own move (e.g. redirected)
        script.OnSecondaryEffect(move, user, 0);

        // Assert
        var args = GetStatBoostCallArgs(user);
        await Assert.That(args).IsNotNull();
        await Assert.That((bool)args![2]!).IsTrue();
    }

    /// <summary>
    /// Technical test: the 'amount' parameter from the move data (amount: -1 for Acid in Moves.jsonc)
    /// is parsed by OnInitialize and passed through to the stat change unmodified.
    /// </summary>
    [Test, Arguments(-1), Arguments(-2)]
    public async Task OnInitialize_AmountParameter_PassedToStatBoost(int amount)
    {
        // Arrange
        var script = CreateInitializedScript(amount);
        var move = Substitute.For<IExecutingMove>();
        var user = Substitute.For<IPokemon>();
        move.User.Returns(user);
        var target = Substitute.For<IPokemon>();

        // Act
        script.OnSecondaryEffect(move, target, 0);

        // Assert
        var args = GetStatBoostCallArgs(target);
        await Assert.That(args).IsNotNull();
        await Assert.That((sbyte)args![1]!).IsEqualTo((sbyte)amount);
    }

    /// <summary>
    /// Technical test: OnInitialize rejects a null parameter dictionary.
    /// </summary>
    [Test]
    public async Task OnInitialize_NullParameters_ThrowsArgumentNullException()
    {
        // Arrange
        var script = new ChangeTargetSpecialDefense();

        // Act & Assert
        await Assert.That(() => script.OnInitialize(null)).ThrowsExactly<ArgumentNullException>();
    }

    /// <summary>
    /// Technical test: OnInitialize rejects parameters without the required 'amount' entry.
    /// </summary>
    [Test]
    public async Task OnInitialize_MissingAmount_ThrowsArgumentException()
    {
        // Arrange
        var script = new ChangeTargetSpecialDefense();

        // Act & Assert
        await Assert.That(() => script.OnInitialize(new Dictionary<StringKey, object?>()))
            .ThrowsExactly<ArgumentException>();
    }
}