// OG Sound Remaster - identity tracking for clips we own, plus the play-time remapper
// Author: StixsworldHD (StixsmasterHD4k)

using System;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEngine;

namespace OGSoundRemaster.Audio
{
    /// <summary>
    /// Tracks every AudioClip this plugin created. Two jobs:
    ///  - lets the remapper short-circuit instantly when it sees one of its own clips, which is
    ///    what keeps the play-time hook idempotent and free of feedback loops;
    ///  - gives the memory governor a cheap, allocation-free "might still be audible" test so a
    ///    track is never destroyed out from under a playing AudioSource.
    /// </summary>
    public static class ClipRegistry
    {
        private struct Info
        {
            public float Length;
            public float LastPlay;
        }

        private static readonly Dictionary<IntPtr, Info> _ours = new Dictionary<IntPtr, Info>(512);
        private static readonly object _lock = new object();

        public static void Register(AudioClip clip)
        {
            if (clip == null) return;
            IntPtr p = clip.Pointer;
            if (p == IntPtr.Zero) return;

            float len;
            try { len = clip.length; } catch { len = 0f; }

            lock (_lock) _ours[p] = new Info { Length = len, LastPlay = float.NegativeInfinity };
        }

        public static void Unregister(AudioClip clip)
        {
            if (clip == null) return;
            IntPtr p = clip.Pointer;
            if (p == IntPtr.Zero) return;
            lock (_lock) _ours.Remove(p);
        }

        public static bool IsOurs(IntPtr clipPtr)
        {
            if (clipPtr == IntPtr.Zero) return false;
            lock (_lock) return _ours.ContainsKey(clipPtr);
        }

        public static void NotePlayed(AudioClip clip)
        {
            if (clip == null) return;
            IntPtr p = clip.Pointer;
            if (p == IntPtr.Zero) return;

            lock (_lock)
            {
                Info info;
                if (!_ours.TryGetValue(p, out info)) return;
                info.LastPlay = Now();
                _ours[p] = info;
            }
        }

        /// <summary>Conservative: true while the clip could still be sounding, plus a safety margin.</summary>
        public static bool IsAudible(AudioClip clip)
        {
            if (clip == null) return false;
            IntPtr p = clip.Pointer;
            if (p == IntPtr.Zero) return false;

            lock (_lock)
            {
                Info info;
                if (!_ours.TryGetValue(p, out info)) return false;
                if (float.IsNegativeInfinity(info.LastPlay)) return false;
                return Now() - info.LastPlay < info.Length + 2f;
            }
        }

        private static float Now() => (float)Stopwatch.GetTimestamp() / Stopwatch.Frequency;
    }

    /// <summary>
    /// The hot path. Every intercepted playback asks this class "should this clip become an
    /// original-game clip instead?". The answer is memoised per clip pointer so steady-state cost
    /// is one dictionary probe - no string work, no allocation, nothing that touches il2cpp
    /// metadata. The memo is dropped on scene changes because that is the only time the game's
    /// clip objects are torn down and reallocated.
    /// </summary>
    public static class Remapper
    {
        private static readonly Dictionary<IntPtr, AudioClip> _memo = new Dictionary<IntPtr, AudioClip>(1024);
        private static readonly HashSet<IntPtr> _noMatch = new HashSet<IntPtr>();
        private static readonly object _lock = new object();

        public static Func<AudioClip, AudioClip> Fallback;
        public static bool Enabled = true;

        public static long Hits;
        public static long Misses;
        public static long Lookups;

        /// <summary>Returns the replacement clip, or null when the original should be left alone.</summary>
        public static AudioClip Resolve(AudioClip original)
        {
            if (!Enabled || original == null) return null;

            IntPtr key = original.Pointer;
            if (key == IntPtr.Zero) return null;

            lock (_lock)
            {
                Lookups++;
                if (_noMatch.Contains(key)) return null;

                AudioClip cached;
                if (_memo.TryGetValue(key, out cached))
                {
                    if (cached != null && cached.Pointer != IntPtr.Zero) { Hits++; return cached; }
                    _memo.Remove(key);
                }
            }

            // Clips we created must never be remapped again.
            if (ClipRegistry.IsOurs(key))
            {
                lock (_lock) _noMatch.Add(key);
                return null;
            }

            AudioClip replacement = null;
            var fb = Fallback;
            if (fb != null)
            {
                try { replacement = fb(original); }
                catch { replacement = null; }
            }

            lock (_lock)
            {
                if (replacement != null && replacement.Pointer != IntPtr.Zero)
                {
                    _memo[key] = replacement;
                    Hits++;
                    return replacement;
                }
                _noMatch.Add(key);
                Misses++;
                return null;
            }
        }

        /// <summary>Force a specific mapping (used by the role binder so hooks agree with bindings).</summary>
        public static void Pin(AudioClip original, AudioClip replacement)
        {
            if (original == null || replacement == null) return;
            IntPtr k = original.Pointer;
            if (k == IntPtr.Zero) return;
            lock (_lock) { _noMatch.Remove(k); _memo[k] = replacement; }
        }

        public static void Forget()
        {
            lock (_lock) { _memo.Clear(); _noMatch.Clear(); }
        }
    }
}
