using Theriapolis.Core;
using Theriapolis.Core.Data;
using Theriapolis.Core.Entities.Ai;
using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Character;
using Theriapolis.Core.Rules.Combat;
using Theriapolis.Core.Rules.Stats;
using Theriapolis.Core.Util;
using Xunit;

namespace Theriapolis.Tests.Combat;

/// <summary>
/// Phase 6.5 M1 — level-1 class-feature catch-up: Field Repair (Claw-Wright),
/// Lay on Paws (Covenant-Keeper), Vocalization Dice (Muzzle-Speaker).
/// Scent Literacy is a UI-only feature in M1 and is exercised at the
/// integration level rather than here.
/// </summary>
public sealed class Phase65M1FeatureTests
{
    private readonly ContentResolver _content = new(new ContentLoader(TestHelpers.DataDirectory));

    // ── Field Repair ──────────────────────────────────────────────────────

    [Fact]
    public void FieldRepair_HealsTargetByOneD8PlusInt_AndConsumesUse()
    {
        var enc = MakeEncounter(out var healer, out var ally,
            healerClass: "claw_wright", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);

        // Damage the ally so the heal has somewhere to land.
        ally.CurrentHp = 5;
        int beforeUses = healer.SourceCharacter!.FieldRepairUsesRemaining;

        bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally);
        Assert.True(ok);
        Assert.Equal(beforeUses - 1, healer.SourceCharacter.FieldRepairUsesRemaining);
        Assert.True(ally.CurrentHp > 5, $"ally HP should rise; was 5, now {ally.CurrentHp}");
        // 1d8 + INT mod (Claw-Wright with INT 13 from default kit → +1) → ≥ 2.
        Assert.True(ally.CurrentHp - 5 >= 2);
    }

    [Fact]
    public void FieldRepair_RefusesWhenExhausted()
    {
        var enc = MakeEncounter(out var healer, out var ally,
            healerClass: "claw_wright", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        healer.SourceCharacter!.FieldRepairUsesRemaining = 0;
        ally.CurrentHp = 5;
        bool ok = FeatureProcessor.TryFieldRepair(enc, healer, ally);
        Assert.False(ok);
        Assert.Equal(5, ally.CurrentHp);
    }

    [Fact]
    public void FieldRepair_OnlyForClawWright()
    {
        var enc = MakeEncounter(out var notHealer, out var ally,
            healerClass: "fangsworn", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        ally.CurrentHp = 5;
        bool ok = FeatureProcessor.TryFieldRepair(enc, notHealer, ally);
        Assert.False(ok);
    }

    [Fact]
    public void EnsureFieldRepairReady_RestoresUseAfterEncounter()
    {
        var c = MakeChar("claw_wright", new AbilityScores(10, 12, 13, 14, 12, 8));
        c.FieldRepairUsesRemaining = 0;
        FeatureProcessor.EnsureFieldRepairReady(c);
        Assert.Equal(1, c.FieldRepairUsesRemaining);
    }

    // ── Lay on Paws ───────────────────────────────────────────────────────

    [Fact]
    public void LayOnPaws_SpendsPoolAndHealsTarget()
    {
        var enc = MakeEncounter(out var healer, out var ally,
            healerClass: "covenant_keeper", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        FeatureProcessor.EnsureLayOnPawsPoolReady(healer.SourceCharacter!);
        int poolBefore = healer.SourceCharacter!.LayOnPawsPoolRemaining;
        Assert.True(poolBefore >= 1);

        ally.CurrentHp = ally.MaxHp - 4;
        bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 4);
        Assert.True(ok);
        Assert.Equal(ally.MaxHp, ally.CurrentHp);
        Assert.Equal(poolBefore - 4, healer.SourceCharacter.LayOnPawsPoolRemaining);
    }

    [Fact]
    public void LayOnPaws_ClampsToPoolRemaining()
    {
        var enc = MakeEncounter(out var healer, out var ally,
            healerClass: "covenant_keeper", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        healer.SourceCharacter!.LayOnPawsPoolRemaining = 3;
        ally.CurrentHp = 1;
        bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 99);
        Assert.True(ok);
        Assert.Equal(0, healer.SourceCharacter.LayOnPawsPoolRemaining);
        Assert.Equal(4, ally.CurrentHp);
    }

    [Fact]
    public void LayOnPaws_RefusesWhenPoolEmpty()
    {
        var enc = MakeEncounter(out var healer, out var ally,
            healerClass: "covenant_keeper", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        healer.SourceCharacter!.LayOnPawsPoolRemaining = 0;
        ally.CurrentHp = 5;
        bool ok = FeatureProcessor.TryLayOnPaws(enc, healer, ally, requestHp: 5);
        Assert.False(ok);
        Assert.Equal(5, ally.CurrentHp);
    }

    [Fact]
    public void EnsureLayOnPawsPool_ScalesWithCha()
    {
        var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 16));
        c.LayOnPawsPoolRemaining = 0;
        FeatureProcessor.EnsureLayOnPawsPoolReady(c);
        // CHA 16 → +3 mod → 5 × 3 = 15 pool.
        Assert.Equal(15, c.LayOnPawsPoolRemaining);
    }

    [Fact]
    public void EnsureLayOnPawsPool_LowChaStillGetsTokenPool()
    {
        var c = MakeChar("covenant_keeper", new AbilityScores(15, 10, 13, 10, 12, 8));
        c.LayOnPawsPoolRemaining = 0;
        FeatureProcessor.EnsureLayOnPawsPoolReady(c);
        // CHA 8 → -1 mod → minimum 5 pool.
        Assert.True(c.LayOnPawsPoolRemaining >= 1);
    }

    // ── Vocalization Dice ─────────────────────────────────────────────────

    [Fact]
    public void VocalizationDieSidesFor_FollowsLevelLadder()
    {
        Assert.Equal(6,  FeatureProcessor.VocalizationDieSidesFor(1));
        Assert.Equal(6,  FeatureProcessor.VocalizationDieSidesFor(4));
        Assert.Equal(8,  FeatureProcessor.VocalizationDieSidesFor(5));
        Assert.Equal(10, FeatureProcessor.VocalizationDieSidesFor(9));
        Assert.Equal(12, FeatureProcessor.VocalizationDieSidesFor(15));
    }

    [Fact]
    public void TryGrantVocalizationDie_GivesAllyInspirationAndConsumesUse()
    {
        var enc = MakeEncounter(out var caster, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        int before = caster.SourceCharacter!.VocalizationDiceRemaining;

        bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
        Assert.True(ok);
        Assert.Equal(6, ally.InspirationDieSides);
        Assert.Equal(before - 1, caster.SourceCharacter.VocalizationDiceRemaining);
    }

    [Fact]
    public void TryGrantVocalizationDie_RefusesSelfTarget()
    {
        var enc = MakeEncounter(out var caster, out _,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, caster);
        Assert.False(ok);
        Assert.Equal(0, caster.InspirationDieSides);
    }

    [Fact]
    public void TryGrantVocalizationDie_RefusesAlreadyInspired()
    {
        var enc = MakeEncounter(out var caster, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        ally.InspirationDieSides = 6;
        int before = caster.SourceCharacter!.VocalizationDiceRemaining;
        bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
        Assert.False(ok);
        Assert.Equal(before, caster.SourceCharacter.VocalizationDiceRemaining);
    }

    [Fact]
    public void TryGrantVocalizationDie_RefusesOutOfRange()
    {
        var enc = MakeEncounter(out var caster, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied,
            allyPosition: new Vec2(20, 0));   // > 12 tactical tiles
        bool ok = FeatureProcessor.TryGrantVocalizationDie(enc, caster, ally);
        Assert.False(ok);
    }

    [Fact]
    public void ConsumeInspirationDie_ZeroesAndReturnsRoll()
    {
        var enc = MakeEncounter(out var caster, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        ally.InspirationDieSides = 6;
        int rolled = FeatureProcessor.ConsumeInspirationDie(enc, ally);
        Assert.InRange(rolled, 1, 6);
        Assert.Equal(0, ally.InspirationDieSides);
    }

    [Fact]
    public void ConsumeInspirationDie_NoOpWhenNoInspiration()
    {
        var enc = MakeEncounter(out var caster, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        Assert.Equal(0, ally.InspirationDieSides);
        Assert.Equal(0, FeatureProcessor.ConsumeInspirationDie(enc, ally));
    }

    [Fact]
    public void EnsureVocalizationDiceReady_RefillsToFour()
    {
        var c = MakeChar("muzzle_speaker", new AbilityScores(8, 14, 13, 10, 12, 16));
        c.VocalizationDiceRemaining = 0;
        FeatureProcessor.EnsureVocalizationDiceReady(c);
        Assert.Equal(4, c.VocalizationDiceRemaining);
    }

    // ── AiContext targeting helpers ───────────────────────────────────────

    [Fact]
    public void AiContext_FindClosestAlly_FindsAllyWhenPresent()
    {
        var enc = MakeEncounter(out var pc, out var ally,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        var ctx = new AiContext(enc);
        Assert.Same(ally, ctx.FindClosestAlly(pc));
    }

    [Fact]
    public void AiContext_FindClosestAlly_NullWhenAlone()
    {
        var enc = MakeEncounter(out var pc, out var hostile,
            healerClass: "muzzle_speaker", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Hostile);
        var ctx = new AiContext(enc);
        Assert.Null(ctx.FindClosestAlly(pc));
    }

    [Fact]
    public void AiContext_FindMostDamagedFriendly_PrefersWoundedAllyOverFullHpSelf()
    {
        var enc = MakeEncounter(out var pc, out var ally,
            healerClass: "covenant_keeper", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        // PC at full HP, ally damaged.
        ally.CurrentHp = 5;
        var ctx = new AiContext(enc);
        Assert.Same(ally, ctx.FindMostDamagedFriendly(pc));
    }

    [Fact]
    public void AiContext_FindMostDamagedFriendly_NullWhenAllAtFullHp()
    {
        var enc = MakeEncounter(out var pc, out _,
            healerClass: "covenant_keeper", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Allied);
        var ctx = new AiContext(enc);
        Assert.Null(ctx.FindMostDamagedFriendly(pc));
    }

    // ── Inspiration die end-to-end through Resolver ───────────────────────

    [Fact]
    public void Resolver_ConsumesInspirationDie_OnAttackRoll()
    {
        var enc = MakeEncounter(out var attacker, out var target,
            healerClass: "fangsworn", allyClass: "fangsworn",
            allyAllegiance: Allegiance.Hostile);
        attacker.InspirationDieSides = 6;
        var attack = attacker.AttackOptions[0];
        Resolver.AttemptAttack(enc, attacker, target, attack);
        // The die should have been consumed regardless of hit/miss.
        Assert.Equal(0, attacker.InspirationDieSides);
    }

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

    private Encounter MakeEncounter(
        out Combatant healer, out Combatant ally,
        string healerClass, string allyClass,
        Allegiance allyAllegiance,
        Vec2? allyPosition = null)
    {
        var hc = MakeChar(healerClass, new AbilityScores(10, 12, 13, 14, 12, 14));
        var ac = MakeChar(allyClass,    new AbilityScores(15, 12, 13, 10, 10, 8));
        healer = Combatant.FromCharacter(hc, 1, "Healer", new Vec2(0, 0), Allegiance.Player);
        ally   = Combatant.FromCharacter(ac, 2, "Ally",
            allyPosition ?? new Vec2(2, 0), allyAllegiance);
        return new Encounter(0xFEEDUL, 1, new[] { healer, ally });
    }

    private Theriapolis.Core.Rules.Character.Character MakeChar(string classId, AbilityScores a)
    {
        var b = new CharacterBuilder
        {
            Clade        = _content.Clades["canidae"],
            Species      = _content.Species["wolf"],
            ClassDef     = _content.Classes[classId],
            Background   = _content.Backgrounds["pack_raised"],
            BaseAbilities = a,
        };
        int n = b.ClassDef.SkillsChoose;
        foreach (var raw in b.ClassDef.SkillOptions)
        {
            if (b.ChosenClassSkills.Count >= n) break;
            try { b.ChosenClassSkills.Add(SkillIdExtensions.FromJson(raw)); } catch { }
        }
        return b.Build(_content.Items);
    }
}
