// OG Sound Remaster - plugin entry point
// Author: StixsworldHD (StixsmasterHD4k)
// Target : Deadly Dozen Reloaded (DeadlyDozenReloaded.exe), BepInEx 6 Unity.IL2CPP x64
//
// Restores the complete original 2002 Deadly Dozen audio - weapons, voices, impacts, ambience,
// vehicles, mission music and briefing narration - over the Reloaded remake.
//
// Runs with no configuration file and no interface by design: everything self-tunes from the
// contents of the shipped OGSound folder and the machine it is running on.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using System.Text;
using BepInEx;
using BepInEx.Unity.IL2CPP;
using HarmonyLib;
using Il2CppInterop.Runtime.Injection;
using OGSoundRemaster.Audio;
using OGSoundRemaster.Binding;
using OGSoundRemaster.Mapping;
using OGSoundRemaster.Patches;
using UnityEngine;
using UnityEngine.SceneManagement;

namespace OGSoundRemaster
{
    [BepInPlugin(Guid, Name, Version)]
    [BepInProcess("DeadlyDozenReloaded.exe")]
    public sealed class Plugin : BasePlugin
    {
        public const string Guid = "com.stixsworldhd.ogsoundremaster";
        public const string Name = "OG Sound Remaster";
        public const string Version = "1.0.1";

        internal static Plugin Instance;
        internal static AudioBank Bank;
        internal static ClipStore Store;
        internal static SoundMap Map;
        internal static NameResolver Resolver;
        internal static RoleBinder Binder;

        internal static string BankRoot;
        internal static string ReportPath;

        internal static readonly HashSet<string> UnmatchedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        internal static readonly Dictionary<string, string> MatchedNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        private Harmony _harmony;

        public override void Load()
        {
            Instance = this;
            var sw = Stopwatch.StartNew();

            Log.LogInfo("=================================================================");
            Log.LogInfo(" " + Name + " v" + Version + "  -  StixsworldHD (StixsmasterHD4k)");
            Log.LogInfo(" ungodly madly vastly enhanced original audio for Deadly Dozen Reloaded");
            Log.LogInfo("=================================================================");

            BankRoot = LocateBank();
            if (BankRoot == null)
            {
                Log.LogError("could not find the OGSound folder. Expected it next to OGSoundRemaster.dll,");
                Log.LogError("for example BepInEx/plugins/OGSoundRemaster/OGSound/. Nothing will be replaced.");
                return;
            }
            Log.LogInfo("sound bank: " + BankRoot);

            Bank = new AudioBank();
            if (!Bank.Scan(BankRoot, m => Log.LogWarning(m)))
            {
                Log.LogError("the OGSound folder contains no readable audio. Nothing will be replaced.");
                return;
            }
            Log.LogInfo("indexed " + Bank.Describe());

            long budget = ComputeMemoryBudget();
            Log.LogInfo("audio memory budget: " + (budget / 1048576) + " MB (auto-tuned from " +
                        SafeSystemMemoryMb() + " MB system memory)");

            Store = new ClipStore(Bank, budget, m => Log.LogWarning(m));
            Map = new SoundMap(Bank);
            Resolver = new NameResolver(Bank);
            Binder = new RoleBinder(Bank, Store, Map, m => Log.LogInfo(m), m => Log.LogWarning(m));

            Remapper.Fallback = ResolveByName;

            _harmony = new Harmony(Guid);
            Hooks.Apply(_harmony, Binder, Resolver, m => Log.LogWarning(m));
            Log.LogInfo("applied " + Hooks.PatchesApplied + " hooks" +
                        (Hooks.PatchesFailed > 0 ? " (" + Hooks.PatchesFailed + " unavailable)" : ""));

            // Warm the sounds that are guaranteed to be needed within seconds of launch.
            Store.Prefetch(Map.MenuMusic);
            Store.Prefetch(Map.UiClick);
            Store.Prefetch(Map.Footsteps);
            Store.Prefetch(Map.HitDirt);
            Store.Prefetch(Map.HitFlesh);
            Store.Prefetch(Map.HitMetal);
            Store.Prefetch(Map.HitGlass);

            ReportPath = Path.Combine(Path.GetDirectoryName(BankRoot) ?? BankRoot, "OGSoundRemaster.report.txt");

            try
            {
                ClassInjector.RegisterTypeInIl2Cpp<Pump>();
                AddComponent<Pump>();
            }
            catch (Exception e)
            {
                Log.LogError("could not install the frame pump: " + e.Message);
                Log.LogError("short sounds will still be replaced, but long sounds may load late.");
            }

            Log.LogInfo("ready in " + sw.ElapsedMilliseconds + " ms");
        }

        public override bool Unload()
        {
            try { WriteReport(); } catch { }
            try { Store?.Shutdown(); } catch { }
            try { _harmony?.UnpatchSelf(); } catch { }
            return true;
        }

        // ---- bank discovery ---------------------------------------------------

        private string LocateBank()
        {
            var candidates = new List<string>();

            string asmDir = null;
            try
            {
                string loc = Assembly.GetExecutingAssembly().Location;
                if (!string.IsNullOrEmpty(loc)) asmDir = Path.GetDirectoryName(loc);
            }
            catch { }

            if (!string.IsNullOrEmpty(asmDir))
            {
                candidates.Add(Path.Combine(asmDir, "OGSound"));
                candidates.Add(Path.Combine(asmDir, "OGSoundRemaster", "OGSound"));
            }

            try
            {
                string plugins = Paths.PluginPath;
                if (!string.IsNullOrEmpty(plugins))
                {
                    candidates.Add(Path.Combine(plugins, "OGSoundRemaster", "OGSound"));
                    candidates.Add(Path.Combine(plugins, "OGSound"));
                }
            }
            catch { }

            foreach (var c in candidates)
            {
                try { if (Directory.Exists(c)) return c; } catch { }
            }

            // Last resort: one level of search under the plugin folder.
            try
            {
                string plugins = Paths.PluginPath;
                if (!string.IsNullOrEmpty(plugins) && Directory.Exists(plugins))
                {
                    foreach (var dir in Directory.GetDirectories(plugins, "OGSound", SearchOption.AllDirectories))
                        return dir;
                }
            }
            catch { }

            return null;
        }

        private static int SafeSystemMemoryMb()
        {
            try { return SystemInfo.systemMemorySize; } catch { return 8192; }
        }

        /// <summary>
        /// Roughly six percent of system memory, clamped to a sane window. The full bank would need
        /// about 736 MB if every sound were resident at once, which is exactly why the store loads
        /// on demand and evicts long, idle, unreferenced clips.
        /// </summary>
        private static long ComputeMemoryBudget()
        {
            long systemMb = SafeSystemMemoryMb();
            long mb = systemMb * 6 / 100;
            if (mb < 96) mb = 96;
            if (mb > 384) mb = 384;
            return mb * 1048576L;
        }

        // ---- name-based fallback ------------------------------------------------

        private static AudioClip ResolveByName(AudioClip original)
        {
            if (original == null || Resolver == null || Store == null) return null;

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

            float confidence;
            BankFile file = Resolver.Resolve(name, out confidence);

            if (file == null)
            {
                lock (UnmatchedNames) UnmatchedNames.Add(name);
                return null;
            }

            lock (MatchedNames)
            {
                if (!MatchedNames.ContainsKey(name))
                    MatchedNames[name] = file.RelPath + "  (confidence " + confidence.ToString("F2", CultureInfo.InvariantCulture) + ")";
            }

            var clip = Store.GetImmediate(file);
            if (clip == null) Store.Request(file, null);       // ready for next time
            return clip;
        }

        // ---- diagnostics ----------------------------------------------------------

        internal static void WriteReport()
        {
            if (string.IsNullOrEmpty(ReportPath) || Bank == null) return;

            var sb = new StringBuilder(8192);
            sb.AppendLine("OG Sound Remaster " + Version + " - runtime report");
            sb.AppendLine("StixsworldHD (StixsmasterHD4k)");
            sb.AppendLine("generated " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture));
            sb.AppendLine();
            sb.AppendLine("This file is diagnostics only. Deleting it changes nothing; it is rewritten");
            sb.AppendLine("while the game runs so you can see exactly what was replaced and what was not.");
            sb.AppendLine();

            sb.AppendLine("--- sound bank ---");
            sb.AppendLine("root            : " + BankRoot);
            sb.AppendLine("indexed         : " + Bank.Describe());
            sb.AppendLine();

            if (Store != null)
            {
                sb.AppendLine("--- memory ---");
                sb.AppendLine("resident clips  : " + Store.LiveClips);
                sb.AppendLine("resident bytes  : " + (Store.ResidentBytes / 1048576) + " MB of " + (Store.BudgetBytes / 1048576) + " MB budget");
                sb.AppendLine("evictions       : " + Store.Evictions);
                sb.AppendLine("inline decodes  : " + Store.SyncDecodes);
                sb.AppendLine("decode failures : " + Store.DecodeFailures);
                sb.AppendLine("deferred to main: " + Store.CrossThreadDeferrals + " (callbacks moved off decode threads)");
                sb.AppendLine();
            }

            sb.AppendLine("--- interception ---");
            sb.AppendLine("hooks applied   : " + Hooks.PatchesApplied);
            sb.AppendLine("hooks failed    : " + Hooks.PatchesFailed);
            for (int i = 0; i < Hooks.FailedPatches.Count; i++) sb.AppendLine("   ! " + Hooks.FailedPatches[i]);
            sb.AppendLine("remap lookups   : " + Remapper.Lookups);
            sb.AppendLine("remap hits      : " + Remapper.Hits);
            sb.AppendLine("remap misses    : " + Remapper.Misses);
            sb.AppendLine();

            if (Binder != null)
            {
                sb.AppendLine("--- role binding ---");
                sb.AppendLine("fields bound    : " + Binder.BoundFields);
                sb.AppendLine("clip sets bound : " + Binder.BoundArrays);
                sb.AppendLine("sweep swaps     : " + Binder.SweepReplacements);
                sb.AppendLine("loose players   : " + Binder.DeferredResolved + " name-matched, " + Binder.DeferredSkipped + " already owned or unmatched");
                sb.AppendLine();
            }

            lock (MatchedNames)
            {
                sb.AppendLine("--- matched by name (" + MatchedNames.Count + ") ---");
                var keys = new List<string>(MatchedNames.Keys);
                keys.Sort(StringComparer.OrdinalIgnoreCase);
                foreach (var k in keys) sb.AppendLine("   " + k + "  ->  " + MatchedNames[k]);
                sb.AppendLine();
            }

            lock (UnmatchedNames)
            {
                sb.AppendLine("--- left untouched (" + UnmatchedNames.Count + ") ---");
                sb.AppendLine("These remake clips had no confident original-game counterpart and were");
                sb.AppendLine("deliberately left as the remake shipped them.");
                var keys = new List<string>(UnmatchedNames);
                keys.Sort(StringComparer.OrdinalIgnoreCase);
                foreach (var k in keys) sb.AppendLine("   " + k);
            }

            try { File.WriteAllText(ReportPath, sb.ToString()); } catch { }
        }
    }

    /// <summary>
    /// The only per-frame code in the plugin. Drains finished decodes into AudioClips under a
    /// strict time budget, notices scene changes, and periodically refreshes the report.
    /// </summary>
    public sealed class Pump : MonoBehaviour
    {
        public Pump(IntPtr ptr) : base(ptr) { }

        private string _lastScene;
        private int _frame;
        private int _sweepCountdown = -1;
        private float _reportTimer;

        public void Update()
        {
            try { Plugin.Store?.PumpMainThread(); } catch { }

            _frame++;

            // Scene change detection. Polled every 15 frames rather than hooked, so nothing depends
            // on injecting a delegate into SceneManager.
            if ((_frame & 15) == 0)
            {
                string current = null;
                try { current = SceneManager.GetActiveScene().name; } catch { }

                if (current != null && current != _lastScene)
                {
                    _lastScene = current;
                    Remapper.Forget();
                    try { Plugin.Store?.ClearSceneBindings(); } catch { }
                    try { Plugin.Binder?.ClearClaims(); } catch { }
                    _sweepCountdown = 3;   // let the scene's own Awake/Start finish first
                }
            }

            if (_sweepCountdown > 0)
            {
                _sweepCountdown--;
                if (_sweepCountdown == 0)
                {
                    try
                    {
                        Plugin.Binder?.SweepScene(Plugin.Resolver);
                        Plugin.Binder?.BindVehicleSounds();
                    }
                    catch { }
                    _sweepCountdown = -1;
                }
            }

            // Unowned sound players fall back to name matching only after their owner has had a
            // chance to claim them.
            try { Plugin.Binder?.DrainDeferred(Time.frameCount, Plugin.Resolver); } catch { }

            _reportTimer += Time.unscaledDeltaTime;
            if (_reportTimer >= 30f)
            {
                _reportTimer = 0f;
                try { Plugin.WriteReport(); } catch { }
            }
        }

        public void OnApplicationQuit()
        {
            try { Plugin.WriteReport(); } catch { }
            try { Plugin.Store?.Shutdown(); } catch { }
        }
    }
}
