using Theriapolis.Core.Items;
using Theriapolis.Core.Rules.Stats;

namespace Theriapolis.Core.Rules.Combat;

/// <summary>
/// Phase 5 M6: dispatches class-feature combat effects at hook points the
/// resolver and DerivedStats call into. Hand-coded switch-on-class-id; the
/// alternative would be a feature-registration system — overkill for the
/// half-dozen combat-touching level-1 features we actually ship.
///
/// Implemented features:
///   - Fangsworn fighting styles: Duelist (+2 dmg one-handed), Great Weapon (re-roll 1s/2s on dmg)
///   - Feral: Unarmored Defense (10 + DEX + CON when no body armor), Feral Rage (+2 dmg, resistance)
///   - Bulwark: Sentinel Stance (+2 AC), Guardian's Mark (UI hook only — full effect M6.5)
///   - Shadow-Pelt: Sneak Attack (+1d6 first hit per turn with finesse/ranged weapon)
///
/// Stubs (no combat effect at M6 — flagged for later wiring):
///   - Scent-Broker, Covenant-Keeper, Muzzle-Speaker, Claw-Wright level-1
///     features. They appear in level_table but don't alter dice yet.
/// </summary>
public static class FeatureProcessor
{
    /// <summary>
    /// Returns the raw AC for a character, factoring in class features.
    /// Called by <see cref="DerivedStats.ArmorClass"/> *after* the standard
    /// armor/shield/DEX computation so this layer can either replace
    /// (Unarmored Defense) or add (Sentinel Stance) to the base.
    ///
    /// Returns the *new* AC value to use; pass back <paramref name="baseAc"/>
    /// when no feature applies.
    /// </summary>
    public static int ApplyAcFeatures(Theriapolis.Core.Rules.Character.Character c, int baseAc)
    {
        int ac = baseAc;
        // Feral Unarmored Defense replaces base if no body armor.
        if (c.ClassDef.Id == "feral" && c.Inventory.GetEquipped(EquipSlot.Body) is null)
        {
            int dex = c.Abilities.ModFor(AbilityId.DEX);
            int con = c.Abilities.ModFor(AbilityId.CON);
            int unarmoredAc = 10 + dex + con;
            // Take whichever is higher — Feral may pick up a buckler offhand etc. that pushes baseAc higher.
            if (unarmoredAc > ac) ac = unarmoredAc;
        }
        return ac;
    }

    /// <summary>
    /// AC bonus from per-encounter combat-time features (Sentinel Stance, etc).
    /// Combat resolver adds this to the combatant's base AC at attack-resolution time.
    ///
    /// Phase 6.5 M2 layers in subclass passive AC bonuses — caller passes
    /// the encounter so the resolver can consult positional state for
    /// adjacency-driven features (Herd-Wall Interlock Shields, Lone Fang
    /// Isolation Bonus).
    /// </summary>
    public static int ApplyAcBonus(Combatant target, Encounter? enc = null)
    {
        int bonus = 0;
        if (target.SentinelStanceActive) bonus += 2;

        // Phase 6.5 M2 subclass passives.
        var c = target.SourceCharacter;
        if (c is not null && enc is not null && !string.IsNullOrEmpty(c.SubclassId))
        {
            switch (c.SubclassId)
            {
                case "lone_fang":
                    if (HasLoneFangIsolation(target, enc)) bonus += 1;
                    break;
                case "herd_wall":
                    if (HasHerdWallAdjacentAlly(target, enc)) bonus += 1;
                    break;
            }
        }
        return bonus;
    }

    /// <summary>
    /// Phase 6.5 M2 — to-hit bonus from subclass features that boost
    /// attack rolls (e.g. Lone Fang Isolation Bonus). Resolver adds this
    /// to <c>attackTotal</c> alongside the base attack bonus.
    /// </summary>
    public static int ApplyToHitBonus(Combatant attacker, Encounter enc)
    {
        int bonus = 0;
        var c = attacker.SourceCharacter;
        if (c is null || string.IsNullOrEmpty(c.SubclassId)) return 0;
        switch (c.SubclassId)
        {
            case "lone_fang":
                if (HasLoneFangIsolation(attacker, enc)) bonus += 2;
                break;
        }
        return bonus;
    }

    /// <summary>
    /// True when the Lone Fang's "Isolation Bonus" applies — no allied
    /// combatant within 10 ft. <see cref="ReachAndCover.EdgeToEdgeChebyshev"/>
    /// returns the number of *empty tiles between* two footprints, so:
    /// 0 = touching (5 ft. away), 1 = one empty tile (10 ft.), etc.
    /// "Within 10 ft" means edge-to-edge ≤ 1.
    /// </summary>
    private static bool HasLoneFangIsolation(Combatant self, Encounter enc)
    {
        foreach (var c in enc.Participants)
        {
            if (c.Id == self.Id) continue;
            if (c.IsDown) continue;
            bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
                       && (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
            if (!sameSide) continue;
            if (ReachAndCover.EdgeToEdgeChebyshev(self, c) <= 1) return false;
        }
        return true;
    }

    /// <summary>
    /// True when the Herd-Wall has at least one allied combatant adjacent.
    /// "Adjacent" in the d20 sense = sharing an edge or corner; with the
    /// edge-to-edge "empty tiles between" metric that's distance 0.
    /// </summary>
    private static bool HasHerdWallAdjacentAlly(Combatant self, Encounter enc)
    {
        foreach (var c in enc.Participants)
        {
            if (c.Id == self.Id) continue;
            if (c.IsDown) continue;
            bool sameSide = (self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || self.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
                       && (c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || c.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
            if (!sameSide) continue;
            if (ReachAndCover.EdgeToEdgeChebyshev(self, c) == 0) return true;
        }
        return false;
    }

    /// <summary>
    /// Phase 6.5 M2 — Pack-Forged "Packmate's Howl". Called from the
    /// resolver when a Pack-Forged hits a target with a melee attack:
    /// marks the target so the next *ally* attack against it gains
    /// advantage (until the marker's next turn).
    /// </summary>
    public static void OnPackForgedHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
    {
        var c = attacker.SourceCharacter;
        if (c is null || c.SubclassId != "pack_forged") return;
        if (attack.IsRanged) return;       // melee only per the description
        target.HowlMarkRound = enc.RoundNumber;
        target.HowlMarkBy    = attacker.Id;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"  Packmate's Howl: {target.Name} marked — next ally attack has advantage.");
    }

    /// <summary>
    /// Phase 6.5 M2 — Pack-Forged consumption hook. If the target carries
    /// a Howl mark from one of the attacker's *allies* (not self), and the
    /// mark hasn't expired, returns true (advantage on this attack) and
    /// clears the mark. Resolver calls this before rolling the d20.
    /// </summary>
    public static bool ConsumeHowlAdvantage(Encounter enc, Combatant attacker, Combatant target)
    {
        if (target.HowlMarkRound is not int markRound) return false;
        if (target.HowlMarkBy    is not int markBy)    return false;
        if (markBy == attacker.Id) return false;           // can't consume your own mark
        // Mark expires once the marker's next turn begins. Approximation: a
        // mark placed on round N consumed on round N or N+1 (before marker
        // gets to act) is valid; round > markRound + 1 = expired.
        if (enc.RoundNumber > markRound + 1) return false;
        // Allies only: the marker must be on the same side as the attacker.
        var marker = enc.GetById(markBy);
        if (marker is null) return false;
        bool sameSide = (attacker.Allegiance == marker.Allegiance)
                     || (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          && marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
                     || (attacker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied
                          && marker.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player);
        if (!sameSide) return false;
        // Consume.
        target.HowlMarkRound = null;
        target.HowlMarkBy    = null;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"  {attacker.Name} consumes Packmate's Howl — advantage on this attack.");
        return true;
    }

    /// <summary>
    /// Phase 6.5 M2 — Blood Memory "Predatory Surge". Called by the
    /// resolver when a raging Feral with this subclass reduces a target
    /// to 0 HP with a melee attack. Sets the surge-pending flag; the HUD
    /// can offer the player a free bonus melee attack (M2 wires the flag;
    /// the bonus-action consumption is the player's job via the existing
    /// attack input).
    /// </summary>
    public static void OnBloodMemoryKill(Encounter enc, Combatant attacker, AttackOption attack)
    {
        var c = attacker.SourceCharacter;
        if (c is null || c.SubclassId != "blood_memory") return;
        if (!attacker.RageActive)  return;
        if (attack.IsRanged)       return;
        attacker.PredatorySurgePending = true;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"  Predatory Surge: {attacker.Name} can take a free melee attack.");
    }

    /// <summary>
    /// Damage bonus from feature effects (Fighting Style, Rage, Sneak Attack).
    /// Returns extra damage to add to the rolled total. Side effects: marks
    /// <see cref="Combatant.SneakAttackUsedThisTurn"/> when sneak attack fires.
    /// </summary>
    public static int ApplyDamageBonus(
        Encounter enc,
        Combatant attacker,
        Combatant target,
        AttackOption attack,
        bool isCrit)
    {
        int bonus = 0;
        var c = attacker.SourceCharacter;

        // Feral Rage — +2 damage on melee attacks while raging.
        if (attacker.RageActive && !attack.IsRanged) bonus += 2;

        // Fangsworn fighting styles.
        if (c is not null && c.ClassDef.Id == "fangsworn")
        {
            if (c.FightingStyle == "duelist" && IsOneHanded(c))
                bonus += 2;
            // Great Weapon and Natural Predator handled elsewhere (re-roll
            // and to-hit respectively).
        }

        // Shadow-Pelt Sneak Attack — once per turn, +1d6 with finesse/ranged.
        if (c is not null && c.ClassDef.Id == "shadow_pelt"
            && !attacker.SneakAttackUsedThisTurn
            && IsFinesseOrRanged(attacker, attack))
        {
            int d6 = enc.RollDie(6);
            if (isCrit) d6 += enc.RollDie(6);  // crit doubles the sneak attack die
            bonus += d6;
            attacker.SneakAttackUsedThisTurn = true;
            enc.AppendLog(CombatLogEntry.Kind.Note, $"  Sneak Attack: +{d6}");
        }

        // Phase 7 M0 — Ambush-Artist "Opening Strike". First melee attack of
        // round 1 in the encounter (the "ambush" round) deals +2d6 sneak
        // damage. Stacks with base Sneak Attack — opening strike represents
        // a different surprise mechanism.
        if (c is not null && c.SubclassId == "ambush_artist"
            && !attacker.OpeningStrikeUsed
            && enc.RoundNumber == 1
            && !attack.IsRanged)
        {
            int d6a = enc.RollDie(6);
            int d6b = enc.RollDie(6);
            int extra = d6a + d6b;
            if (isCrit) extra += enc.RollDie(6) + enc.RollDie(6);
            bonus += extra;
            attacker.OpeningStrikeUsed = true;
            enc.AppendLog(CombatLogEntry.Kind.Note, $"  Opening Strike: +{extra}");
        }

        // Phase 7 M0 — Stampede-Heart "Trampling Charge". First melee attack
        // each turn while raging deals +1d8 bludgeoning. Phase 7 simplifies
        // the JSON's "moved 20+ ft. straight" geometry constraint to "first
        // melee attack while raging" — captures the spirit of the charge
        // without requiring a movement-vector tracker the tactical layer
        // doesn't yet expose. Phase 8 / 9 polish can refine.
        if (c is not null && c.SubclassId == "stampede_heart"
            && attacker.RageActive
            && !attacker.TramplingChargeUsedThisTurn
            && !attack.IsRanged)
        {
            int d8 = enc.RollDie(8);
            if (isCrit) d8 += enc.RollDie(8);
            bonus += d8;
            attacker.TramplingChargeUsedThisTurn = true;
            enc.AppendLog(CombatLogEntry.Kind.Note, $"  Trampling Charge: +{d8}");
        }

        return bonus;
    }

    /// <summary>
    /// Phase 7 M0 — Antler-Guard "Retaliatory Strike". Called from
    /// <see cref="Resolver.AttemptAttack"/> after damage applies on a melee
    /// hit. If the target is an Antler-Guard Bulwark in Sentinel Stance,
    /// the attacker takes 1d8 + CON (the target's CON) automatic damage.
    /// Phase 7 contract: deterrence-style return-damage, no save, no roll —
    /// the attack itself is the trigger. Doesn't fire on ranged attacks
    /// (the JSON specifies "from a melee attack").
    /// </summary>
    public static int OnAntlerGuardHit(Encounter enc, Combatant attacker, Combatant target, AttackOption attack)
    {
        var c = target.SourceCharacter;
        if (c is null || c.SubclassId != "antler_guard") return 0;
        if (!target.SentinelStanceActive) return 0;
        if (attack.IsRanged) return 0;
        // 1d8 + CON-mod return damage; min 1.
        int d8  = enc.RollDie(8);
        int con = AbilityScores.Mod(target.Abilities.Get(AbilityId.CON));
        int retaliation = System.Math.Max(1, d8 + con);
        Resolver.ApplyDamage(attacker, retaliation);
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"  Retaliatory Strike: {target.Name} returns {retaliation} ({d8}+{con}) to {attacker.Name}.");
        return retaliation;
    }

    /// <summary>
    /// Re-roll 1s and 2s on damage dice for Fangsworn Great Weapon style.
    /// Called by DamageRoll.Roll only if the attacker has the style + a
    /// two-handed weapon. Returns the (possibly adjusted) dice value.
    /// </summary>
    public static int GreatWeaponReroll(Encounter enc, Combatant attacker, AttackOption attack, int rolledDie, int sides)
    {
        var c = attacker.SourceCharacter;
        if (c is null || c.ClassDef.Id != "fangsworn" || c.FightingStyle != "great_weapon") return rolledDie;
        if (!IsTwoHanded(c)) return rolledDie;
        if (rolledDie > 2) return rolledDie;
        // Re-roll once and take the new value (even if also 1 or 2).
        int rerolled = enc.RollDie(sides);
        return rerolled;
    }

    /// <summary>
    /// True if the damage type is fully resisted (half-damage). Phase 5 M6:
    /// Feral Rage gives resistance to bludgeoning/piercing/slashing while active.
    /// </summary>
    public static bool IsResisted(Combatant target, DamageType damageType)
    {
        if (target.RageActive)
        {
            return damageType == DamageType.Bludgeoning
                || damageType == DamageType.Piercing
                || damageType == DamageType.Slashing;
        }
        return false;
    }

    /// <summary>
    /// Activate Feral Rage. Returns true if the rage started (had uses
    /// remaining); false if the character has no uses left.
    /// </summary>
    public static bool TryActivateRage(Encounter enc, Combatant attacker)
    {
        var c = attacker.SourceCharacter;
        if (c is null || c.ClassDef.Id != "feral") return false;
        if (attacker.RageActive) return false;
        if (c.RageUsesRemaining <= 0) return false;
        attacker.RageActive = true;
        c.RageUsesRemaining--;
        enc.AppendLog(CombatLogEntry.Kind.Note, $"{attacker.Name} enters a rage. ({c.RageUsesRemaining} use(s) left)");
        return true;
    }

    /// <summary>Toggle Bulwark Sentinel Stance.</summary>
    public static bool ToggleSentinelStance(Encounter enc, Combatant attacker)
    {
        var c = attacker.SourceCharacter;
        if (c is null || c.ClassDef.Id != "bulwark") return false;
        attacker.SentinelStanceActive = !attacker.SentinelStanceActive;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{attacker.Name} {(attacker.SentinelStanceActive ? "enters" : "leaves")} Sentinel Stance.");
        return true;
    }

    // ── Phase 6.5 M1: level-1 active class features ──────────────────────

    /// <summary>
    /// Claw-Wright <c>field_repair</c>. Action; heals <c>1d8 + INT mod</c>
    /// HP to the target. Hybrid heal-target effectiveness (75%) applies if
    /// the target is a hybrid PC (Phase 6.5 M5 schema-stub for now — no
    /// hybrids exist yet, so the multiplier is gated by future data).
    /// </summary>
    public static bool TryFieldRepair(Encounter enc, Combatant healer, Combatant target)
    {
        var c = healer.SourceCharacter;
        if (c is null || c.ClassDef.Id != "claw_wright") return false;
        if (c.FieldRepairUsesRemaining <= 0)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Field Repair exhausted (rest to recover).");
            return false;
        }
        if (target.IsDown) return false;

        int intMod = c.Abilities.ModFor(AbilityId.INT);
        // Phase 7 M0 — Body-Wright "Combat Medic" rolls 2d8 + INT instead of
        // the base 1d8 + INT. The bonus-action treatment described in the
        // JSON is a HUD-side concern (the resource economy is unchanged);
        // this hook adjusts only the dice.
        int rolled;
        if (c.SubclassId == "body_wright")
        {
            rolled = enc.RollDie(8) + enc.RollDie(8);
        }
        else
        {
            rolled = enc.RollDie(8);
        }
        int healed = Math.Max(1, rolled + intMod);
        // Phase 6.5 M4 — Medical Incompatibility: hybrid recipients heal at
        // 75% effectiveness (round down, min 1). Non-hybrids pass through.
        int delivered = healed;
        if (target.SourceCharacter is { } targetCharacter)
            delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
                targetCharacter, healed);
        target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
        c.FieldRepairUsesRemaining--;
        string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != healed
            ? $" (hybrid → {delivered})"
            : "";
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{healer.Name} Field Repair on {target.Name}: rolled {rolled} + INT {intMod:+#;-#;0} = {healed} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp})");
        return true;
    }

    /// <summary>
    /// Covenant-Keeper <c>lay_on_paws</c>. Action; spend up to a fixed
    /// amount from a pool of <c>5 × CHA</c> HP per long rest (per-encounter
    /// at M1) to heal a target. Pool tops up via
    /// <see cref="EnsureLayOnPawsPoolReady"/>; spending one point cures
    /// disease — not modelled here yet (no disease subsystem).
    /// </summary>
    public static bool TryLayOnPaws(Encounter enc, Combatant healer, Combatant target, int requestHp)
    {
        var c = healer.SourceCharacter;
        if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
        if (target.IsDown) return false;
        if (requestHp <= 0) return false;

        int spend = Math.Min(requestHp, c.LayOnPawsPoolRemaining);
        if (spend <= 0)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note, $"{healer.Name}: Lay on Paws pool empty (rest to refill).");
            return false;
        }
        // Phase 6.5 M4 — Medical Incompatibility scales hybrid heal received,
        // but the *cost* to the pool is the requested amount. (Hybrid pays
        // the same cost; the inefficiency models the body resisting the
        // calibration, not the healer wasting effort.)
        int delivered = spend;
        if (target.SourceCharacter is { } targetCharacter)
            delivered = Theriapolis.Core.Rules.Character.HybridDetriments.ScaleHealForHybrid(
                targetCharacter, spend);
        target.CurrentHp = Math.Min(target.MaxHp, target.CurrentHp + delivered);
        c.LayOnPawsPoolRemaining -= spend;
        string scaledNote = (target.SourceCharacter?.IsHybrid ?? false) && delivered != spend
            ? $" (hybrid → {delivered})"
            : "";
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{healer.Name} channels Lay on Paws → {target.Name} +{spend} HP{scaledNote}. ({target.CurrentHp}/{target.MaxHp}, pool {c.LayOnPawsPoolRemaining})");
        return true;
    }

    /// <summary>
    /// Initialise / refresh the Lay on Paws pool to <c>5 × CHA mod</c> if
    /// the character has the <c>lay_on_paws</c> feature. Called at
    /// encounter start so M1 (no rest model) treats every encounter as
    /// fully rested. CHA mod ≤ 0 yields a 1-point minimum so a low-CHA
    /// Covenant-Keeper still has a token pool.
    /// </summary>
    public static void EnsureLayOnPawsPoolReady(Theriapolis.Core.Rules.Character.Character c)
    {
        if (c.ClassDef.Id != "covenant_keeper") return;
        int chaMod = c.Abilities.ModFor(AbilityId.CHA);
        int target = Math.Max(1, 5 * Math.Max(1, chaMod));
        if (c.LayOnPawsPoolRemaining < target)
            c.LayOnPawsPoolRemaining = target;
    }

    /// <summary>
    /// Refresh the Field Repair use to 1 if it's been spent. Encounter-rest
    /// equivalence per the Phase 5 contract.
    /// </summary>
    public static void EnsureFieldRepairReady(Theriapolis.Core.Rules.Character.Character c)
    {
        if (c.ClassDef.Id != "claw_wright") return;
        if (c.FieldRepairUsesRemaining < 1) c.FieldRepairUsesRemaining = 1;
    }

    /// <summary>
    /// Refresh Vocalization Dice to 4 if any have been spent. Encounter-rest
    /// equivalence per the Phase 5 contract.
    /// </summary>
    public static void EnsureVocalizationDiceReady(Theriapolis.Core.Rules.Character.Character c)
    {
        if (c.ClassDef.Id != "muzzle_speaker") return;
        if (c.VocalizationDiceRemaining < 4) c.VocalizationDiceRemaining = 4;
    }

    // ── Phase 6.5 M3: Pheromone Craft (Scent-Broker) ─────────────────────

    /// <summary>
    /// Pheromone Craft uses-per-encounter cap based on character level. The
    /// JSON ladder unlocks more uses at higher levels:
    /// L1–4 → 0 (feature not unlocked yet),
    /// L5–8 → 2 (pheromone_craft_2 from L2 entry retains here, but the L5
    ///            entry brings pheromone_craft_3),
    /// L9–12 → 4, L13+ → 5. The granted-at-each-level structure in
    /// <c>classes.json</c> uses the highest-tier feature unlocked.
    /// </summary>
    public static int PheromoneUsesAtLevel(int level) => level switch
    {
        >= 13 => 5,
        >=  9 => 4,
        >=  5 => 3,
        >=  2 => 2,
        _      => 0,
    };

    /// <summary>
    /// Refill the Scent-Broker's Pheromone Craft pool to the per-level cap.
    /// Encounter-rest equivalence; Phase 8 replaces with real short-rest.
    /// </summary>
    public static void EnsurePheromoneUsesReady(Theriapolis.Core.Rules.Character.Character c)
    {
        if (c.ClassDef.Id != "scent_broker") return;
        int cap = PheromoneUsesAtLevel(c.Level);
        if (c.PheromoneUsesRemaining < cap) c.PheromoneUsesRemaining = cap;
    }

    /// <summary>
    /// Scent-Broker <c>pheromone_craft_*</c>. Bonus action; emits a 10-ft
    /// (= 2 tactical tile) cloud centred on the caster. Every creature in
    /// range that the caster considers hostile must make a CON save vs.
    /// <c>DC = 8 + prof + WIS mod</c>; on failure, the pheromone-mapped
    /// <see cref="Theriapolis.Core.Rules.Stats.Condition"/> is applied
    /// (<see cref="PheromoneTypeExtensions.AppliedCondition"/>).
    /// Consumes one Pheromone Use.
    /// </summary>
    public static bool TryEmitPheromone(Encounter enc, Combatant caster, PheromoneType type)
    {
        var c = caster.SourceCharacter;
        if (c is null || c.ClassDef.Id != "scent_broker") return false;
        if (c.Level < 2)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note,
                $"{caster.Name}: Pheromone Craft unlocks at level 2.");
            return false;
        }
        if (c.PheromoneUsesRemaining <= 0)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note,
                $"{caster.Name}: Pheromone Craft exhausted (rest to recover).");
            return false;
        }

        int wisMod = c.Abilities.ModFor(Theriapolis.Core.Rules.Stats.AbilityId.WIS);
        int dc     = 8 + c.ProficiencyBonus + wisMod;
        var applied = type.AppliedCondition();

        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{caster.Name} emits {type.DisplayName()} pheromone (DC {dc}).");

        int affected = 0;
        foreach (var t in enc.Participants)
        {
            if (t.Id == caster.Id) continue;
            if (t.IsDown) continue;
            // 10 ft. cloud = within 1 empty tile (≤ 1 edge-to-edge).
            if (ReachAndCover.EdgeToEdgeChebyshev(caster, t) > 1) continue;
            // Only target hostiles for offensive pheromones; calm targets
            // hostiles too (charmed-toward-source is the desired effect).
            bool sameSide = (caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || caster.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied)
                       && (t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Player
                          || t.Allegiance == Theriapolis.Core.Rules.Character.Allegiance.Allied);
            if (sameSide) continue;

            // Roll CON save: 1d20 + CON mod.
            int conMod = Theriapolis.Core.Rules.Stats.AbilityScores.Mod(t.Abilities.CON);
            int saveRoll = enc.RollD20();
            int saveTotal = saveRoll + conMod;
            bool saved = saveTotal >= dc;
            enc.AppendLog(CombatLogEntry.Kind.Save,
                $"  {t.Name} CON save: {saveRoll}{conMod:+#;-#;0} = {saveTotal} vs DC {dc} → {(saved ? "saved" : "FAILED")}");
            if (!saved && applied != Theriapolis.Core.Rules.Stats.Condition.None)
            {
                Resolver.ApplyCondition(enc, t, applied);
                affected++;
            }
        }

        c.PheromoneUsesRemaining--;
        if (affected == 0)
            enc.AppendLog(CombatLogEntry.Kind.Note,
                $"  No hostiles affected by the pheromone.");
        return true;
    }

    // ── Phase 6.5 M3: Covenant Authority (Covenant-Keeper) ───────────────

    /// <summary>
    /// Covenant Authority uses-per-encounter cap based on level. JSON
    /// ladder: <c>covenants_authority_2/3/4/5</c> at L2/L9/L13/L17 →
    /// 2 / 3 / 4 / 5.
    /// </summary>
    public static int CovenantAuthorityUsesAtLevel(int level) => level switch
    {
        >= 17 => 5,
        >= 13 => 4,
        >=  9 => 3,
        >=  2 => 2,
        _      => 0,
    };

    /// <summary>
    /// Refill the Covenant-Keeper's Authority pool to the per-level cap.
    /// </summary>
    public static void EnsureCovenantAuthorityReady(Theriapolis.Core.Rules.Character.Character c)
    {
        if (c.ClassDef.Id != "covenant_keeper") return;
        int cap = CovenantAuthorityUsesAtLevel(c.Level);
        if (c.CovenantAuthorityUsesRemaining < cap) c.CovenantAuthorityUsesRemaining = cap;
    }

    /// <summary>
    /// Covenant-Keeper <c>covenants_authority_*</c>. Bonus action; declares
    /// an oath against a target hostile; for 10 rounds (1 minute), the
    /// oath-marked creature suffers -2 to attack rolls against the
    /// Covenant-Keeper. Consumes one use. The full three-option
    /// description (Compel Truth / Rebuke Predation / Shield the Innocent)
    /// is plan-deferred to Phase 8/9 dialogue + AoE polish; M3 ships the
    /// simple combat-marker mechanic per the Phase 6.5 plan §4.4.
    /// </summary>
    public static bool TryDeclareOath(Encounter enc, Combatant caster, Combatant target)
    {
        var c = caster.SourceCharacter;
        if (c is null || c.ClassDef.Id != "covenant_keeper") return false;
        if (c.Level < 2)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note,
                $"{caster.Name}: Covenant's Authority unlocks at level 2.");
            return false;
        }
        if (c.CovenantAuthorityUsesRemaining <= 0)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note,
                $"{caster.Name}: Covenant's Authority exhausted (rest to recover).");
            return false;
        }
        if (target.IsDown) return false;
        if (target.Id == caster.Id) return false;

        target.OathMarkRound = enc.RoundNumber;
        target.OathMarkBy    = caster.Id;
        c.CovenantAuthorityUsesRemaining--;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{caster.Name} pronounces an oath against {target.Name} — -2 attack vs caster for 1 minute.");
        return true;
    }

    /// <summary>
    /// Phase 6.5 M3 — to-hit penalty applied to a marked attacker rolling
    /// against the Covenant-Keeper who marked them. Returns 0 when no
    /// active oath, -2 when the marked attacker targets the marker, and 0
    /// for any other target (the oath is target-specific).
    /// </summary>
    public static int OathAttackPenalty(Encounter enc, Combatant attacker, Combatant defender)
    {
        if (attacker.OathMarkRound is not int markRound) return 0;
        if (attacker.OathMarkBy    is not int markBy)    return 0;
        // Expire after 10 rounds.
        if (enc.RoundNumber > markRound + 9)
        {
            attacker.OathMarkRound = null;
            attacker.OathMarkBy    = null;
            return 0;
        }
        if (markBy != defender.Id) return 0;     // penalty only when attacking the marker
        return -2;
    }

    /// <summary>
    /// Muzzle-Speaker Vocalization Dice (level-1 d6, scaling to d8/d10/d12
    /// at L5/L9/L15). Bonus action; consumes one die. The target combatant
    /// gains <see cref="Combatant.InspirationDieSides"/> = the current die
    /// size; the next attack/check/save they make rolls that bonus.
    /// </summary>
    public static bool TryGrantVocalizationDie(Encounter enc, Combatant caster, Combatant ally)
    {
        var c = caster.SourceCharacter;
        if (c is null || c.ClassDef.Id != "muzzle_speaker") return false;
        if (caster.Id == ally.Id) return false;             // can't inspire yourself
        if (c.VocalizationDiceRemaining <= 0)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: Vocalization Dice spent (rest to recover).");
            return false;
        }
        if (ally.InspirationDieSides > 0)
        {
            // Already inspired — overlapping inspirations don't stack at L1.
            enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} already inspired.");
            return false;
        }
        // Range gate: 60 ft. = 12 tactical tiles per the standard 5-ft tile.
        int dist = caster.DistanceTo(ally);
        if (dist > 12)
        {
            enc.AppendLog(CombatLogEntry.Kind.Note, $"{caster.Name}: {ally.Name} too far for Vocalization Dice ({dist}/12).");
            return false;
        }

        int sides = VocalizationDieSidesFor(c.Level);
        ally.InspirationDieSides = sides;
        c.VocalizationDiceRemaining--;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"{caster.Name} grants {ally.Name} a Vocalization Die (1d{sides}). ({c.VocalizationDiceRemaining} left)");
        return true;
    }

    /// <summary>
    /// Standard d20 Bardic Inspiration die ladder, mapped to Vocalization
    /// Dice per <c>classes.json</c> level table:
    /// 1–4 → d6; 5–8 → d8; 9–14 → d10; 15+ → d12.
    /// </summary>
    public static int VocalizationDieSidesFor(int level) => level switch
    {
        >= 15 => 12,
        >=  9 => 10,
        >=  5 =>  8,
        _      =>  6,
    };

    /// <summary>
    /// Consume an inspiration die (if any) on a d20 roll. Adds 1d&lt;sides&gt;
    /// to the d20 result and clears the field. Returns the bonus added (0 if
    /// no inspiration was active).
    /// </summary>
    public static int ConsumeInspirationDie(Encounter enc, Combatant roller)
    {
        if (roller.InspirationDieSides <= 0) return 0;
        int sides = roller.InspirationDieSides;
        int rolled = enc.RollDie(sides);
        roller.InspirationDieSides = 0;
        enc.AppendLog(CombatLogEntry.Kind.Note,
            $"  {roller.Name} adds Vocalization Die (1d{sides} = {rolled}).");
        return rolled;
    }

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

    private static bool IsOneHanded(Theriapolis.Core.Rules.Character.Character c)
    {
        var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
        if (main is null) return false;
        if (HasProp(main.Def, "two_handed")) return false;
        var off = c.Inventory.GetEquipped(EquipSlot.OffHand);
        // Duelist requires the off hand to be empty (shields don't count as another weapon, but the d20 spec says "no other weapon" — for M6 we treat shields as OK).
        if (off is null) return true;
        return string.Equals(off.Def.Kind, "shield", System.StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsTwoHanded(Theriapolis.Core.Rules.Character.Character c)
    {
        var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
        return main is not null && HasProp(main.Def, "two_handed");
    }

    private static bool IsFinesseOrRanged(Combatant attacker, AttackOption attack)
    {
        if (attack.IsRanged) return true;
        var c = attacker.SourceCharacter;
        if (c is null) return false;
        var main = c.Inventory.GetEquipped(EquipSlot.MainHand);
        if (main is null) return false;
        return HasProp(main.Def, "finesse");
    }

    private static bool HasProp(Theriapolis.Core.Data.ItemDef def, string prop)
    {
        foreach (var p in def.Properties)
            if (string.Equals(p, prop, System.StringComparison.OrdinalIgnoreCase))
                return true;
        return false;
    }
}
