// OG Sound Remaster - deterministic role binding
// Author: StixsworldHD (StixsmasterHD4k)
//
// Reloaded exposes its entire audio graph as typed fields (WeaponBase.fireSound,
// SquadMemberVoicePack.followSound, GameManager.bulletHitFleshSound, ...). Binding those fields by
// meaning rather than by asset name is exact, needs no guesswork, and costs nothing at runtime -
// each field is written once when its owning component wakes up.
//
// Two safety rules govern everything in this file:
//
//   1. Never read an element out of an array the game owns. Element reads go through
//      Il2CppReferenceArray.WrapElement -> Il2CppObjectPool.Get -> il2cpp_object_get_class, and a
//      stale slot there faults the process with an AccessViolationException, which .NET Core
//      classifies as a Corrupted State Exception and refuses to let any catch block handle.
//      Clip arrays are therefore built complete and swapped in one assignment; the remake's own
//      array stays untouched until the replacement is ready.
//
//   2. Where a component or list element genuinely has to be read, read it in a tight loop
//      immediately after obtaining the container, with no il2cpp allocation in between.

using System;
using System.Collections.Generic;
using Il2CppInterop.Runtime;
using Il2CppInterop.Runtime.InteropTypes;
using Il2CppInterop.Runtime.InteropTypes.Arrays;
using OGSoundRemaster.Audio;
using OGSoundRemaster.Mapping;
using UnityEngine;

namespace OGSoundRemaster.Binding
{
    public sealed class RoleBinder
    {
        private readonly AudioBank _bank;
        private readonly ClipStore _store;
        private readonly SoundMap _map;
        private readonly Action<string> _log;
        private readonly Action<string> _warn;

        public int BoundFields;
        public int BoundArrays;
        public int SweepReplacements;
        public int DeferredResolved;
        public int DeferredSkipped;

        /// <summary>Players already claimed by a role binding, so the loose fallback leaves them alone.</summary>
        private readonly HashSet<IntPtr> _claimed = new HashSet<IntPtr>();

        public RoleBinder(AudioBank bank, ClipStore store, SoundMap map, Action<string> log, Action<string> warn)
        {
            _bank = bank; _store = store; _map = map; _log = log; _warn = warn;
        }

        // ---- low level helpers ----------------------------------------------

        private static bool Alive(UnityEngine.Object o)
        {
            try { return o != null && o.Pointer != IntPtr.Zero && !o.WasCollected; }
            catch { return false; }
        }

        private static IntPtr PtrOf(Il2CppObjectBase o)
        {
            try { return o == null ? IntPtr.Zero : o.Pointer; }
            catch { return IntPtr.Zero; }
        }

        private bool Claim(Il2CppObjectBase o)
        {
            IntPtr p = PtrOf(o);
            if (p == IntPtr.Zero) return false;
            _claimed.Add(p);
            return true;
        }

        private bool IsClaimed(Il2CppObjectBase o)
        {
            IntPtr p = PtrOf(o);
            return p != IntPtr.Zero && _claimed.Contains(p);
        }

        public void ClearClaims()
        {
            _claimed.Clear();
            _deferred.Clear();
        }

        private static int StableSeed(UnityEngine.Object o)
        {
            try { return o == null ? 0 : (o.GetInstanceID() & 0x7FFFFFFF); }
            catch { return 0; }
        }

        // ---- single-clip binding ---------------------------------------------

        /// <summary>Assign a single original sound to an AudioSource.clip field.</summary>
        public bool BindSource(AudioSource src, List<BankFile> options, int seed = 0)
        {
            if (!Alive(src) || options == null || options.Count == 0) return false;

            // Read the existing clip first, before anything can allocate in the il2cpp heap.
            AudioClip original = null;
            try { original = src.clip; } catch { original = null; }

            BankFile pick = options[options.Count == 1 ? 0 : (seed % options.Count)];
            _store.AddBinding(pick);

            var immediate = _store.GetImmediate(pick);
            if (immediate != null)
            {
                try
                {
                    src.clip = immediate;
                    if (original != null) Remapper.Pin(original, immediate);
                    BoundFields++;
                    return true;
                }
                catch (Exception e) { _warn?.Invoke("BindSource: " + e.Message); return false; }
            }

            _store.Request(pick, clip =>
            {
                if (clip == null || !Alive(src)) return;
                try
                {
                    bool wasPlaying = src.isPlaying;
                    src.clip = clip;
                    if (original != null) Remapper.Pin(original, clip);
                    if (wasPlaying) src.Play();
                    BoundFields++;
                }
                catch { }
            });

            return true;
        }

        // ---- clip array binding ------------------------------------------------

        /// <summary>
        /// Tracks a clip set that is still decoding. The remake's own array remains in place until
        /// every slot has resolved, at which point the whole set is swapped in one assignment - so
        /// the game never sees a half-populated array and never a null element.
        /// </summary>
        private sealed class PendingSet
        {
            public Action<Il2CppReferenceArray<AudioClip>> Set;
            public AudioClip[] Clips;
            public int Remaining;
            public bool Done;
            public RoleBinder Owner;

            public void Complete(int slot, AudioClip clip)
            {
                if (Done) return;
                if ((uint)slot < (uint)Clips.Length) Clips[slot] = clip;
                if (--Remaining > 0) return;

                Done = true;
                Owner.Commit(Set, Clips);
            }
        }

        /// <summary>
        /// Build a fresh clip array from original-game sounds and hand it to the component. Nothing
        /// is ever read back out of the array, and the remake's array is only replaced once the
        /// full replacement set exists.
        /// </summary>
        public bool BindClipArray(Action<Il2CppReferenceArray<AudioClip>> set, List<BankFile> options)
        {
            if (set == null || options == null || options.Count == 0) return false;

            int n = options.Count;
            var resolved = new AudioClip[n];
            int missing = 0;

            for (int i = 0; i < n; i++)
            {
                var f = options[i];
                if (f == null) { missing++; continue; }

                _store.AddBinding(f);
                resolved[i] = _store.GetImmediate(f);
                if (resolved[i] == null) missing++;
            }

            if (missing == 0) return Commit(set, resolved);

            var pending = new PendingSet { Set = set, Clips = resolved, Remaining = missing, Owner = this };

            for (int i = 0; i < n; i++)
            {
                if (resolved[i] != null) continue;

                var f = options[i];
                int slot = i;

                if (f == null) { pending.Complete(slot, null); continue; }
                _store.Request(f, clip => pending.Complete(slot, clip));
            }

            return true;
        }

        /// <summary>Write-only: allocates the array, fills it, assigns it. No element is ever read.</summary>
        private bool Commit(Action<Il2CppReferenceArray<AudioClip>> set, AudioClip[] clips)
        {
            int live = 0;
            for (int i = 0; i < clips.Length; i++) if (clips[i] != null) live++;
            if (live == 0) return false;

            try
            {
                var arr = new Il2CppReferenceArray<AudioClip>(live);
                int k = 0;
                for (int i = 0; i < clips.Length; i++)
                    if (clips[i] != null) arr[k++] = clips[i];

                set(arr);
                BoundArrays++;
                return true;
            }
            catch (Exception e) { _warn?.Invoke("clip set commit: " + e.Message); return false; }
        }

        public bool BindRandomPlayer(RandomSoundPlayer p, List<BankFile> options)
        {
            if (!Alive(p)) return false;
            Claim(p);
            return BindClipArray(a => p.clips = a, options);
        }

        public bool BindLoopingPlayer(RandomLoopingSoundPlayer p, List<BankFile> options)
        {
            if (!Alive(p)) return false;
            Claim(p);
            return BindClipArray(a => p.clips = a, options);
        }

        // ---- per-component bindings ------------------------------------------

        public void BindWeapon(WeaponBase w)
        {
            if (!Alive(w)) return;
            try
            {
                string type = w.GetIl2CppType().Name;

                bool looping = false;
                var fire = w.fireSound;
                if (Alive(fire)) { try { looping = fire.loop; } catch { } }

                BindSource(fire, _map.WeaponFire(type, looping));
                BindSource(w.reloadSound1st, _map.WeaponReload(type));
                BindSource(w.reloadSound3rd, _map.WeaponReload(type));

                var rocket = w.TryCast<WeaponBaseRocketLauncher>();
                if (rocket != null) BindSource(rocket.fireSound1st, _map.WeaponFire(type, false));

                var garand = w.TryCast<WeaponGarand>();
                if (garand != null) BindSource(garand.pingSound, _map.GarandPing);
            }
            catch (Exception e) { _warn?.Invoke("BindWeapon: " + e.Message); }
        }

        public void BindMG42(WeaponMG42 mg)
        {
            if (!Alive(mg)) return;
            try
            {
                bool looping = false;
                var fire = mg.fireSound;
                if (Alive(fire)) { try { looping = fire.loop; } catch { } }

                BindSource(fire, _map.WeaponFire("weaponmg42", looping));
                BindSource(mg.reloadSound, _map.WeaponReload("weaponmg42"));
            }
            catch (Exception e) { _warn?.Invoke("BindMG42: " + e.Message); }
        }

        public void BindVoicePack(SquadMemberVoicePack pack, int packIndex)
        {
            if (!Alive(pack)) return;
            try
            {
                BindRandomPlayer(pack.attackMyTargetSound,     _map.SquadVoice(packIndex, "attackMyTargetSound"));
                BindRandomPlayer(pack.fireAtWillSound,         _map.SquadVoice(packIndex, "fireAtWillSound"));
                BindRandomPlayer(pack.followSound,             _map.SquadVoice(packIndex, "followSound"));
                BindRandomPlayer(pack.holdFireSound,           _map.SquadVoice(packIndex, "holdFireSound"));
                BindRandomPlayer(pack.holdPositionSound,       _map.SquadVoice(packIndex, "holdPositionSound"));
                BindRandomPlayer(pack.switchToSound,           _map.SquadVoice(packIndex, "switchToSound"));
                BindRandomPlayer(pack.killSound,               _map.SquadVoice(packIndex, "killSound"));
                BindRandomPlayer(pack.damagedSound,            _map.SquadVoice(packIndex, "damagedSound"));
                BindRandomPlayer(pack.deathSound,              _map.SquadVoice(packIndex, "deathSound"));
                BindRandomPlayer(pack.friendlyFireSound,       _map.SquadVoice(packIndex, "friendlyFireSound"));
                BindRandomPlayer(pack.objectivesCompleteSound, _map.SquadVoice(packIndex, "objectivesCompleteSound"));
                BindRandomPlayer(pack.noTargetSound,           _map.SquadVoice(packIndex, "noTargetSound"));
            }
            catch (Exception e) { _warn?.Invoke("BindVoicePack: " + e.Message); }
        }

        /// <summary>Controllers carry damaged/death lines; which bank they come from depends on the side.</summary>
        public void BindController(ControllerBase c)
        {
            if (!Alive(c)) return;
            try
            {
                bool isDog = c.TryCast<GermanShepherd>() != null;
                if (!isDog)
                {
                    try
                    {
                        var go = c.gameObject;
                        if (Alive(go) && go.GetComponent<GermanShepherd>() != null) isDog = true;
                    }
                    catch { }
                }

                bool isEnemy = !isDog && c.TryCast<AIController>() != null;

                // Read both component references up front, before anything allocates.
                RandomSoundPlayer damaged = null, death = null;
                try { damaged = c.damagedSound; } catch { }
                try { death = c.deathSound; } catch { }

                if (isDog)
                {
                    BindRandomPlayer(damaged, _map.DogDamaged);
                    BindRandomPlayer(death, _map.DogDeath);
                }
                else if (isEnemy)
                {
                    BindRandomPlayer(damaged, _map.EnemyDamaged);
                    BindRandomPlayer(death, _map.EnemyDeath);
                }
                else
                {
                    // Player-side controller: keep it consistent with the squad voice packs.
                    int seed = StableSeed(c) % SoundMap.VoicePackFolders.Length;
                    BindRandomPlayer(damaged, _map.SquadVoice(seed, "damagedSound"));
                    BindRandomPlayer(death, _map.SquadVoice(seed, "deathSound"));
                }

                var ai = c.TryCast<AIController>();
                if (ai != null)
                {
                    RandomSoundPlayer alert = null;
                    try { alert = ai.alertSound; } catch { }
                    BindRandomPlayer(alert, isDog ? _map.DogAlert : _map.EnemyAlert);
                }
            }
            catch (Exception e) { _warn?.Invoke("BindController: " + e.Message); }
        }

        public void BindNpc(NPC npc)
        {
            if (!Alive(npc)) return;
            try
            {
                RandomSoundPlayer killed = null;
                try { killed = npc.killedPlayerSound; } catch { }
                BindRandomPlayer(killed, _map.EnemyKilledPlayer);
            }
            catch (Exception e) { _warn?.Invoke("BindNpc: " + e.Message); }
        }

        public void BindDog(GermanShepherd dog)
        {
            if (!Alive(dog)) return;
            try
            {
                RandomSoundPlayer attack = null;
                try { attack = dog.attackSound; } catch { }
                BindRandomPlayer(attack, _map.DogAttack);
            }
            catch (Exception e) { _warn?.Invoke("BindDog: " + e.Message); }
        }

        public void BindPlayer(PlayerController p)
        {
            if (!Alive(p)) return;
            try
            {
                BindSource(p.pickupGunSound, _map.PickupGun);
                BindSource(p.pickupItemSound, _map.PickupItem);
                BindSource(p.switchWeaponSound, _map.WeaponDraw);
            }
            catch (Exception e) { _warn?.Invoke("BindPlayer: " + e.Message); }
        }

        public void BindGameManager(GameManager gm)
        {
            if (!Alive(gm)) return;
            try
            {
                BindRandomPlayer(gm.bulletHitDirtSound, _map.HitDirt);
                BindRandomPlayer(gm.bulletHitMetalSound, _map.HitMetal);
                BindRandomPlayer(gm.bulletHitGlassSound, _map.HitGlass);
                BindRandomPlayer(gm.bulletHitFleshSound, _map.HitFlesh);

                // --- voice packs: snapshot the list in one tight pass, then bind
                SquadMemberVoicePack[] packSnapshot = null;
                try
                {
                    var packs = gm.squadMemberVoicePacks;
                    if (packs != null)
                    {
                        int count = packs.Count;
                        if (count > 0 && count < 4096)
                        {
                            packSnapshot = new SquadMemberVoicePack[count];
                            for (int i = 0; i < count; i++) packSnapshot[i] = packs[i];
                        }
                    }
                }
                catch (Exception e) { _warn?.Invoke("voice pack list: " + e.Message); }

                if (packSnapshot != null)
                {
                    for (int i = 0; i < packSnapshot.Length; i++)
                    {
                        var pk = packSnapshot[i];
                        if (Alive(pk)) BindVoicePack(pk, i);
                    }
                    _log?.Invoke("bound " + packSnapshot.Length + " squad voice packs to the original AVoice sets");
                }

                // --- mission info: same discipline
                GameManager.MissionInfo[] infoSnapshot = null;
                try
                {
                    var infos = gm.missionInfo;
                    if (infos != null)
                    {
                        int count = infos.Length;
                        if (count > 0 && count < 4096)
                        {
                            infoSnapshot = new GameManager.MissionInfo[count];
                            for (int i = 0; i < count; i++) infoSnapshot[i] = infos[i];
                        }
                    }
                }
                catch (Exception e) { _warn?.Invoke("mission info array: " + e.Message); }

                if (infoSnapshot != null)
                {
                    int bound = 0;
                    for (int i = 0; i < infoSnapshot.Length; i++)
                    {
                        var mi = infoSnapshot[i];
                        if (mi == null) continue;
                        int idx = i;

                        if (BindMissionVo(c => mi.briefingVO = c, _map.BriefingVO(idx, 'b'))) bound++;
                        if (BindMissionVo(c => mi.debriefingSucceedVO = c, _map.BriefingVO(idx, 's'))) bound++;
                        if (BindMissionVo(c => mi.debriefingFailVO = c, _map.BriefingVO(idx, 'f'))) bound++;
                    }
                    _log?.Invoke("bound " + bound + " mission voice-over lines across " + infoSnapshot.Length + " missions");
                }
            }
            catch (Exception e) { _warn?.Invoke("BindGameManager: " + e.Message); }
        }

        private bool BindMissionVo(Action<AudioClip> set, BankFile file)
        {
            if (file == null) return false;
            _store.AddBinding(file);

            _store.Request(file, clip =>
            {
                if (clip == null) return;
                try { set(clip); } catch { }
            });
            return true;
        }

        public void BindSoundManager(SoundManager sm)
        {
            if (!Alive(sm)) return;
            try
            {
                if (BindClipArray(a => sm.battleMusicClips = a, _map.BattleMusic))
                    _log?.Invoke("battle music bound to the original Battle/Action tracks");
            }
            catch (Exception e) { _warn?.Invoke("BindSoundManager: " + e.Message); }
        }

        public void BindMissionManager(MissionManager mm)
        {
            if (!Alive(mm)) return;
            try
            {
                int idx = 0;
                try { idx = mm.missionIndex; } catch { }

                var tracks = _map.MissionMusic(idx);

                RandomLoopingSoundPlayer music = null;
                try { music = mm.missionMusicObject; } catch { }
                BindLoopingPlayer(music, tracks);

                if (tracks.Count > 0)
                {
                    var names = new List<string>(tracks.Count);
                    for (int i = 0; i < tracks.Count; i++) names.Add(tracks[i].Name);
                    _log?.Invoke("mission " + (idx + 1) + " music: " + string.Join(", ", names) + " (from the original songs.txt)");
                }
            }
            catch (Exception e) { _warn?.Invoke("BindMissionManager: " + e.Message); }
        }

        public void BindHud(HUD hud)
        {
            if (!Alive(hud)) return;
            try
            {
                RandomLoopingSoundPlayer victory = null, defeat = null;
                try { victory = hud.victoryMusic; } catch { }
                try { defeat = hud.defeatMusic; } catch { }

                BindLoopingPlayer(victory, _map.VictoryCamMusic);
                BindLoopingPlayer(defeat, _map.DeathCamMusic);
            }
            catch (Exception e) { _warn?.Invoke("BindHud: " + e.Message); }
        }

        public void BindMainMenu(MainMenuScreen menu)
        {
            if (!Alive(menu)) return;
            try { BindSource(menu.menuMusic, _map.MenuMusic); }
            catch (Exception e) { _warn?.Invoke("BindMainMenu: " + e.Message); }
        }

        public void BindBriefingScreen(BriefingScreen b)
        {
            if (!Alive(b)) return;
            try
            {
                BindSource(b.briefingMusic, _map.BriefingMusic);

                int mission = 0;
                try { mission = BriefingScreen.missionToLoad; } catch { }
                var vo = _map.BriefingVO(mission, 'b');
                if (vo != null) BindSource(b.voSound, new List<BankFile>(1) { vo });
            }
            catch (Exception e) { _warn?.Invoke("BindBriefingScreen: " + e.Message); }
        }

        public void BindDebriefingScreen(DebriefingScreen d)
        {
            if (!Alive(d)) return;
            try
            {
                BindSource(d.victoryMusic, _map.VictoryDebrief);
                BindSource(d.defeatMusic, _map.FailDebrief);

                int mission = 0;
                bool succeeded = true;
                try { mission = DebriefingScreen.missionIndex; } catch { }
                try { succeeded = DebriefingScreen.missionStatus == MissionManager.MissionStatus.Complete; } catch { }

                var vo = _map.BriefingVO(mission, succeeded ? 's' : 'f');
                if (vo != null) BindSource(d.voSound, new List<BankFile>(1) { vo });
            }
            catch (Exception e) { _warn?.Invoke("BindDebriefingScreen: " + e.Message); }
        }

        public void BindTank(Tank t)
        {
            if (!Alive(t)) return;
            try
            {
                string objName = "";
                try { objName = t.gameObject.name; } catch { }

                BindSource(t.idleLoopSound, _map.TankIdle);
                BindSource(t.moveLoopSound, _map.TankMove);
                BindSource(t.turretLoopSound, _map.TankTurretLoop);

                RandomSoundPlayer fire = null, turret = null;
                try { fire = t.fireSound; } catch { }
                try { turret = t.turretStartStopSound; } catch { }

                BindRandomPlayer(fire, _map.TankFire(objName));
                BindRandomPlayer(turret, _map.TankTurretMove);
            }
            catch (Exception e) { _warn?.Invoke("BindTank: " + e.Message); }
        }

        public void BindFootsteps(FootStepSoundPlayer f)
        {
            if (!Alive(f)) return;
            try
            {
                Claim(f);
                BindClipArray(a => f.clips = a, _map.Footsteps);
            }
            catch (Exception e) { _warn?.Invoke("BindFootsteps: " + e.Message); }
        }

        public void BindEquipment(EquipmentBase eq)
        {
            if (!Alive(eq)) return;
            try
            {
                var mine = eq.TryCast<EquipmentAPMine>();
                if (mine != null)
                {
                    BindSource(mine.useSound, _map.APMinePlace);
                    RandomSoundPlayer ex = null; try { ex = mine.explodeSound; } catch { }
                    BindRandomPlayer(ex, _map.APMineExplode);
                    return;
                }

                var tnt = eq.TryCast<EquipmentDynamite>();
                if (tnt != null)
                {
                    BindSource(tnt.useSound, _map.TntPlace);
                    BindSource(tnt.beepSound, _map.TntBeep);
                    BindSource(tnt.tictocSound, _map.TntTicToc);
                    RandomSoundPlayer ex = null; try { ex = tnt.explodeSound; } catch { }
                    BindRandomPlayer(ex, _map.TntExplode);
                    return;
                }

                var nade = eq.TryCast<EquipmentGrenade>();
                if (nade != null)
                {
                    RandomSoundPlayer bounce = null, ex = null;
                    try { bounce = nade.bounceSound; } catch { }
                    try { ex = nade.explodeSound; } catch { }
                    BindRandomPlayer(bounce, _map.GrenadeBounce);
                    BindRandomPlayer(ex, _map.GrenadeExplode);
                    return;
                }

                var coin = eq.TryCast<EquipmentCoin>();
                if (coin != null)
                {
                    BindSource(coin.useSound, _map.CoinThrow);
                    RandomSoundPlayer bounce = null; try { bounce = coin.bounceSound; } catch { }
                    BindRandomPlayer(bounce, _map.CoinBounce);
                    return;
                }

                var med = eq.TryCast<EquipmentMedkit>();
                if (med != null) { BindSource(med.useSound, _map.FirstAid); return; }

                var bino = eq.TryCast<EquipmentBinoculars>();
                if (bino != null)
                {
                    BindSource(bino.useSound, _map.Binoculars);
                    BindSource(bino.dismissSound, _map.DropItem);
                    return;
                }

                var weapon = eq.TryCast<WeaponBase>();
                if (weapon != null) BindWeapon(weapon);
            }
            catch (Exception e) { _warn?.Invoke("BindEquipment: " + e.Message); }
        }

        public void BindRocket(ProjectileRocket r)
        {
            if (!Alive(r)) return;
            try
            {
                BindSource(r.flySound, _map.RocketFly);
                RandomSoundPlayer ex = null; try { ex = r.explosionSound; } catch { }
                BindRandomPlayer(ex, _map.RocketExplode);
            }
            catch (Exception e) { _warn?.Invoke("BindRocket: " + e.Message); }
        }

        public void BindAttachmentHandler(AttachmentHandler a)
        {
            if (!Alive(a)) return;
            try
            {
                BindSource(a.carHitBodySound, _map.CarHitBody);
                BindSource(a.throwGrenadeSound, _map.GrenadeThrow);
            }
            catch (Exception e) { _warn?.Invoke("BindAttachmentHandler: " + e.Message); }
        }

        public void BindExplodeWhenDamaged(ExplodeWhenDamaged x)
        {
            if (!Alive(x)) return;
            try
            {
                RandomSoundPlayer ex = null; try { ex = x.explodeSound; } catch { }
                BindRandomPlayer(ex, _map.GenericExplode);
            }
            catch (Exception e) { _warn?.Invoke("BindExplodeWhenDamaged: " + e.Message); }
        }

        public void BindMineField(MineField m)
        {
            if (!Alive(m)) return;
            try
            {
                RandomSoundPlayer ex = null; try { ex = m.explodeSound; } catch { }
                BindRandomPlayer(ex, _map.MineExplode);
            }
            catch (Exception e) { _warn?.Invoke("BindMineField: " + e.Message); }
        }

        // ---- deferred fallback for unclaimed sound players ---------------------

        private struct Deferred
        {
            public RandomSoundPlayer Player;
            public RandomLoopingSoundPlayer Looper;
            public int DueFrame;
        }

        private readonly List<Deferred> _deferred = new List<Deferred>(64);
        private const int DeferFrames = 30;
        private const int MaxDeferred = 512;

        /// <summary>
        /// RandomSoundPlayer.Awake fires before the owning component's Start, so binding here
        /// immediately would fight with the role binder and churn the clip array twice. Instead the
        /// player is parked for half a second; if its owner claimed it in the meantime it is
        /// skipped, and only genuinely unowned players fall through to name matching.
        /// </summary>
        public void QueueLoose(RandomSoundPlayer p, int frame)
        {
            if (!Alive(p) || _deferred.Count >= MaxDeferred) return;
            _deferred.Add(new Deferred { Player = p, DueFrame = frame + DeferFrames });
        }

        public void QueueLooseLooping(RandomLoopingSoundPlayer p, int frame)
        {
            if (!Alive(p) || _deferred.Count >= MaxDeferred) return;
            _deferred.Add(new Deferred { Looper = p, DueFrame = frame + DeferFrames });
        }

        public void DrainDeferred(int frame, NameResolver resolver)
        {
            if (_deferred.Count == 0 || resolver == null) return;

            for (int i = _deferred.Count - 1; i >= 0; i--)
            {
                var d = _deferred[i];
                if (d.DueFrame > frame) continue;
                _deferred.RemoveAt(i);

                if (d.Player != null)
                {
                    if (IsClaimed(d.Player) || !Alive(d.Player)) { DeferredSkipped++; continue; }
                    if (BindByObjectName(n => BindRandomPlayer(d.Player, n), d.Player.gameObject, resolver)) DeferredResolved++;
                    else DeferredSkipped++;
                }
                else if (d.Looper != null)
                {
                    if (IsClaimed(d.Looper) || !Alive(d.Looper)) { DeferredSkipped++; continue; }
                    if (BindByObjectName(n => BindLoopingPlayer(d.Looper, n), d.Looper.gameObject, resolver)) DeferredResolved++;
                    else DeferredSkipped++;
                }
            }
        }

        /// <summary>
        /// Name matching for an unowned player. Only the GameObject name is consulted - reading the
        /// component's existing clip array to sniff an asset name is exactly the element access
        /// that is unsafe, and the object name is the better signal anyway.
        /// </summary>
        private bool BindByObjectName(Func<List<BankFile>, bool> bind, GameObject go, NameResolver resolver)
        {
            try
            {
                if (!Alive(go)) return false;

                string name = null;
                try { name = go.name; } catch { }
                if (string.IsNullOrEmpty(name)) return false;

                float conf;
                var hit = resolver.Resolve(name, out conf);
                if (hit == null) return false;

                return bind(resolver.VariantsOf(hit));
            }
            catch { return false; }
        }

        // ---- whole-scene sweep -----------------------------------------------

        private readonly List<AudioSource> _sweepScratch = new List<AudioSource>(256);

        /// <summary>
        /// Catches everything the per-component hooks cannot: AudioSources with playOnAwake set in
        /// the editor (ambience loops, wind beds) never route through the managed Play() call, so
        /// they have to be found and swapped directly. Runs once per scene load.
        ///
        /// The source list is snapshotted in one tight pass before any work begins, so no il2cpp
        /// allocation ever happens between two element reads of the same array.
        /// </summary>
        public void SweepScene(NameResolver resolver)
        {
            int replaced = 0, examined = 0;
            _sweepScratch.Clear();

            try
            {
                var found = Resources.FindObjectsOfTypeAll(Il2CppType.Of<AudioSource>());
                if (found == null) return;

                int len = found.Length;
                if (len < 0 || len > 200000) return;

                for (int i = 0; i < len; i++)
                {
                    AudioSource src = null;
                    try { src = found[i].TryCast<AudioSource>(); } catch { src = null; }
                    if (src != null) _sweepScratch.Add(src);
                }
            }
            catch (Exception e) { _warn?.Invoke("SweepScene enumerate: " + e.Message); return; }

            for (int i = 0; i < _sweepScratch.Count; i++)
            {
                var src = _sweepScratch[i];
                if (!Alive(src)) continue;

                AudioClip clip = null;
                try { clip = src.clip; } catch { }
                if (clip == null) continue;

                examined++;

                var already = Remapper.Resolve(clip);
                if (already != null)
                {
                    try
                    {
                        bool playing = src.isPlaying;
                        src.clip = already;
                        if (playing) src.Play();
                        replaced++;
                    }
                    catch { }
                    continue;
                }

                string name = null;
                try { name = clip.name; } catch { }
                if (string.IsNullOrEmpty(name)) continue;

                float conf;
                var file = resolver.Resolve(name, out conf);
                if (file == null) continue;

                _store.AddBinding(file);

                var newClip = _store.GetImmediate(file);
                if (newClip == null)
                {
                    var captured = src;
                    var original = clip;
                    _store.Request(file, c =>
                    {
                        if (c == null || !Alive(captured)) return;
                        try
                        {
                            bool playing = captured.isPlaying;
                            captured.clip = c;
                            Remapper.Pin(original, c);
                            if (playing) captured.Play();
                        }
                        catch { }
                    });
                    continue;
                }

                try
                {
                    bool playing = src.isPlaying;
                    src.clip = newClip;
                    Remapper.Pin(clip, newClip);
                    if (playing) src.Play();
                    replaced++;
                }
                catch { }
            }

            _sweepScratch.Clear();
            SweepReplacements += replaced;

            if (examined > 0)
                _log?.Invoke("scene sweep: " + replaced + " of " + examined + " pre-assigned audio sources switched to original audio");
        }

        // ---- vehicle audio -----------------------------------------------------

        private static readonly string[] VehicleRootHints =
        {
            "jeep", "willys", "kubel", "kubelwagen", "car", "truck", "opel", "blitz", "lorry",
            "bike", "motorcycle", "motorbike", "bmw", "zundapp", "sidecar", "vehicle", "boat", "uboat"
        };

        /// <summary>
        /// Wheeled vehicles are driven by NWH VehiclePhysics2, whose sound components are plain
        /// data objects rather than Unity components, so they cannot be found by type. What they do
        /// leave behind are ordinary AudioSources parented under the vehicle. Those are found here
        /// and matched by role, with the chassis family taken from the vehicle root's name so a
        /// truck gets truck recordings and a motorcycle gets motorcycle recordings.
        /// </summary>
        public void BindVehicleSounds()
        {
            int bound = 0;
            _sweepScratch.Clear();

            try
            {
                var found = Resources.FindObjectsOfTypeAll(Il2CppType.Of<AudioSource>());
                if (found == null) return;

                int len = found.Length;
                if (len < 0 || len > 200000) return;

                for (int i = 0; i < len; i++)
                {
                    AudioSource src = null;
                    try { src = found[i].TryCast<AudioSource>(); } catch { src = null; }
                    if (src != null) _sweepScratch.Add(src);
                }
            }
            catch (Exception e) { _warn?.Invoke("BindVehicleSounds enumerate: " + e.Message); return; }

            for (int i = 0; i < _sweepScratch.Count; i++)
            {
                var src = _sweepScratch[i];
                if (!Alive(src)) continue;

                string rootName, chain;
                if (!DescribeHierarchy(src, out rootName, out chain)) continue;
                if (!LooksLikeVehicle(rootName) && !LooksLikeVehicle(chain)) continue;

                string clipName = "";
                try { var c = src.clip; if (c != null) clipName = c.name; } catch { }

                string subject = (chain + " " + clipName).ToLowerInvariant();
                var family = SoundMap.FamilyOf(rootName + " " + chain);

                List<BankFile> set = VehicleRoleFor(subject, family);
                if (set == null || set.Count == 0) continue;

                if (BindSource(src, set)) bound++;
            }

            _sweepScratch.Clear();

            if (bound > 0)
                _log?.Invoke("bound " + bound + " vehicle audio sources to the original vehicle recordings");
        }

        private List<BankFile> VehicleRoleFor(string subject, SoundMap.VehicleFamily family)
        {
            if (Contains(subject, "horn"))                                   return _map.VehicleHorn;
            if (Contains(subject, "crash", "collision", "impact"))           return _map.VehicleCrash;
            if (Contains(subject, "gear", "shift", "transmission", "whine")) return _map.VehicleGearChange;
            if (Contains(subject, "skid", "tire", "tyre", "squeal"))         return _map.VehicleSkid;
            if (Contains(subject, "brake", "hiss"))                          return _map.VehicleBrake;
            if (Contains(subject, "suspension", "bump"))                     return _map.VehicleSuspension;
            if (Contains(subject, "start", "ignition"))                      return _map.VehicleStart(family);
            if (Contains(subject, "stop", "shutdown", "off"))                return _map.VehicleStop(family);
            if (Contains(subject, "idle"))                                   return _map.VehicleIdle(family);
            if (Contains(subject, "running", "engine", "rpm", "exhaust", "motor", "drive"))
                                                                             return _map.VehicleDriving(family);
            return null;
        }

        private static bool Contains(string haystack, params string[] needles)
        {
            for (int i = 0; i < needles.Length; i++)
                if (haystack.IndexOf(needles[i], StringComparison.Ordinal) >= 0) return true;
            return false;
        }

        private static bool LooksLikeVehicle(string s)
        {
            if (string.IsNullOrEmpty(s)) return false;
            string l = s.ToLowerInvariant();
            for (int i = 0; i < VehicleRootHints.Length; i++)
                if (l.IndexOf(VehicleRootHints[i], StringComparison.Ordinal) >= 0) return true;
            return false;
        }

        /// <summary>Root object name plus the full parent chain, for role and family detection.</summary>
        private static bool DescribeHierarchy(Component c, out string rootName, out string chain)
        {
            rootName = null; chain = null;
            try
            {
                var t = c.transform;
                if (t == null) return false;

                var parts = new List<string>(6);
                int guard = 0;
                while (t != null && guard++ < 24)
                {
                    parts.Add(t.name);
                    t = t.parent;
                }
                if (parts.Count == 0) return false;

                rootName = parts[parts.Count - 1];
                parts.Reverse();
                chain = string.Join("/", parts);
                return true;
            }
            catch { return false; }
        }
    }
}
