﻿/*
 * Copyright (c) 2025 Bazz3l
 * 
 * Guarded Crate cannot be copied, edited and/or (re)distributed without the express permission of Bazz3l.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 * 
 */

using System.Collections.Generic;
using System.Globalization;
using System.Diagnostics;
using System.Collections;
using System.Text;
using System.Linq;
using System;
using Oxide.Core.Plugins;
using Oxide.Core;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using GuardedCrateExt;
using UnityEngine;
using Rust;

namespace Oxide.Plugins
{
    [Info("Guarded Crate", "Bazz3l", "2.0.15")]
    [Description("Spawn custom locked crate events guarded by scientists, eliminate the threats to gain high value loot before the time runs out.")]
    internal class GuardedCrate : RustPlugin
    {
        [PluginReference] Plugin NpcSpawn, Clans, ZoneManager, HackableLock, GUIAnnouncements = null;
        
        #region Fields

        private const string PERM_USE = "guardedcrate.use";
        private const string MARKER_PREFAB = "assets/prefabs/tools/map/genericradiusmarker.prefab";
        private const string CRATE_PREFAB = "assets/prefabs/deployable/chinooklockedcrate/codelockedhackablecrate.prefab";
        private const string PLANE_PREFAB = "assets/prefabs/npc/cargo plane/cargo_plane.prefab";
        
        private readonly object _objTrue = true;
        private StoredData _storedData;
        private static ConfigData _configData;
        private static GuardedCrate _plugin;
        private static Func<string, string, string> _getMessage = null;
        private static Func<ulong, string> _getClanTag = null;
        private static Func<Vector3, JObject, ScientistNPC> _trySpawnNpc = null;
        private static Func<BasePlayer, HackableLockedCrate, object> _tryLockHackable = null;
        private static Func<string, string, string, BasePlayer, float, object> _TrySendGuiNotification = null;

        #endregion

        #region Config

        protected override void LoadDefaultConfig()
        {
            _configData = ConfigData.DefaultConfig();
            _configData.Version = Version;
        }

        protected override void LoadConfig()
        {
            base.LoadConfig();

            try
            {
                _configData = Config.ReadObject<ConfigData>();
                if (_configData == null) throw new JsonException();

                bool hasChanged = false;
                if (_configData.Version == null || _configData.Version < new VersionNumber(2, 0, 14))
                {
                    LoadDefaultConfig();
                    hasChanged = true;
                }

                if (hasChanged)
                {
                    SaveConfig();
                    PrintWarning("Config has been updated.");
                }
            }
            catch (Exception e)
            {
                LoadDefaultConfig();
                PrintWarning("Loaded default config: {0}", e.Message);
            }
        }

        protected override void SaveConfig() => Config.WriteObject(_configData, true);

        private class ConfigData
        {
            [JsonProperty("command name - (default: gcrate)")]
            public string CommandName;
            
            [JsonProperty("auto start enabled - (default: true)")]
            public bool AutoStartEnabled;
            [JsonProperty("auto start length in seconds - (default: 3600)")]
            public float AutoStartLength;
            
            [JsonProperty("toast notification settings")]
            public ToastSetting ToastSetting;
            [JsonProperty("chat notification settings")]
            public ChatSetting ChatSetting;
            [JsonProperty("gui announcement notification settings - (https://umod.org/plugins/gui-announcements)")]
            public GuiaSetting GuiaSetting;
            
            [JsonProperty("dome settings")]
            public DomeSettings DomeSettings;
            
            [JsonProperty("zone settings - (https://umod.org/plugins/zone-manager)")]
            public ZoneSettings ZoneSettings;
            
            [JsonConverter(typeof(EnumCollectionStringConverter<TerrainBiome.Enum>))]
            [JsonProperty("blocked topologies - (default: Arctic, Jungle)")]
            public List<TerrainBiome.Enum> BlockedTopologies;
            
            [JsonProperty("current version number - (WARNING!!! do not change this value)")]
            public VersionNumber? Version;

            public static ConfigData DefaultConfig()
            {
                return new ConfigData
                {
                    CommandName = "gcrate",
                    AutoStartEnabled = true,
                    AutoStartLength = 3600,
                    ToastSetting = ToastSetting.CreateNew(),
                    ChatSetting = ChatSetting.CreateNew(),
                    GuiaSetting = GuiaSetting.CreateNew(),
                    DomeSettings = DomeSettings.CreateNew(),
                    ZoneSettings = ZoneSettings.CreateNew(),
                    BlockedTopologies = GetDefaultTopologies(),
                };
            }

            public static List<TerrainBiome.Enum> GetDefaultTopologies()
            {
                return new List<TerrainBiome.Enum>
                {
                    TerrainBiome.Enum.Arctic,
                    TerrainBiome.Enum.Jungle
                };
            }
        }

        private class ChatSetting
        {
            [JsonProperty("enabled - (default: true)")] 
            public bool Enabled;
            [JsonProperty("enable prefix - (default: true)")] 
            public bool EnablePrefix;
            [JsonProperty("chat icon (steam64) - (default: 76561199542824781)")]
            public ulong ChatIcon;
            
            public static ChatSetting CreateNew()
            {
                return new ChatSetting
                {
                    Enabled = true,
                    EnablePrefix = true,
                    ChatIcon = 76561199542824781,
                };
            }
        }
        
        private class ToastSetting
        {
            [JsonProperty("enabled - (default: false)")] 
            public bool Enabled;
            [JsonConverter(typeof(StringEnumConverter))]
            [JsonProperty("style - (default: Blue_Normal - options: Blue_Normal, Red_Normal, Server_Event)")] 
            public GameTip.Styles Style;
            
            public static ToastSetting CreateNew()
            {
                return new ToastSetting
                {
                    Enabled = false,
                    Style = GameTip.Styles.Blue_Normal
                };
            }
        }
        
        private class GuiaSetting
        {
            [JsonProperty("enabled - (default: false)")]
            public bool Enable;
            [JsonProperty("text color - (default: white)")]
            public string TextColor;
            [JsonProperty("background color - (default: purple)")]
            public string BackgroundColor;
            
            public static GuiaSetting CreateNew()
            {
                return new GuiaSetting
                {
                    Enable = false,
                    TextColor = "white",
                    BackgroundColor = "purple",
                };
            }
        }

        private class DomeSettings
        {
            [JsonProperty("enabled - (default: false)")]
            public bool Enabled;
            [JsonProperty("radius - (default: 0.25)")]
            public float Radius;
            [JsonProperty("amount - (default: 1)")]
            public float Amount;
            [JsonProperty("primary dome color - (default: br_sphere_red.prefab)")]
            public string PrimaryColor;
            [JsonProperty("default dome color - (default: sphere.prefab)")]
            public string DefaultColor;

            public static DomeSettings CreateNew()
            {
                return new DomeSettings
                {
                    Enabled = true,
                    Amount = 1,
                    Radius = 10,
                    DefaultColor = "assets/bundled/prefabs/modding/events/twitch/br_sphere_red.prefab",
                    PrimaryColor = "assets/prefabs/visualization/sphere.prefab"
                };
            }
        }

        private class ZoneSettings
        {
            [JsonProperty("enabled excluded zones - (default: false)")] 
            public bool Enabled;
            [JsonProperty("excluded zones")]
            public List<string> ExcludedZones;
            
            public static ZoneSettings CreateNew()
            {
                return new ZoneSettings
                {
                    Enabled = false,
                    ExcludedZones = new List<string>()
                };
            }
        }

        #endregion

        #region Storage

        private static (string Name, float HackSeconds, string MarkerColor, int Guards, string Weapon, float Health)[] DefaultEvents()
        {
            return new (string Name, float HackSeconds, string MarkerColor, int Guards, string Weapon, float Health)[]
            {
                ("Easy", 60f, "#32A844", 8, "smg.mp5", 200f),
                ("Medium", 120f, "#EDDF45", 10, "smg.mp5", 250f),
                ("Hard", 180f, "#3060D9", 12, "rifle.ak", 300f),
                ("Elite", 180f, "#3060D9", 20, "rifle.ak", 300f)
            };
        }

        private void LoadDefaultData()
        {
            _storedData = new StoredData();
            
            foreach (var defaultEvent in DefaultEvents())
            {
                _storedData.CrateEventEntries.Add(CreateDefaultEvent(
                    defaultEvent.Name, 
                    defaultEvent.HackSeconds, 
                    defaultEvent.MarkerColor, 
                    defaultEvent.Guards, 
                    defaultEvent.Weapon,
                    defaultEvent.Health)
                );
            }
            
            SaveData();
        }

        private void LoadData()
        {
            try
            {
                _storedData = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(Name);
                if (_storedData is not { IsValid: true }) throw new Exception();

                SaveData();
            }
            catch
            {
                LoadDefaultData();
                PrintWarning("Loaded default data.");
            }
        }

        private void SaveData()
        {
            if (_storedData is not { IsValid: true }) return;
            Interface.Oxide.DataFileSystem.WriteObject(Name, _storedData);
        }

        private class StoredData
        {
            public List<EventEntry> CrateEventEntries = new();

            [JsonIgnore] 
            private string[]? _eventNames;

            [JsonIgnore]
            public string[] EventNames
            {
                get
                {
                    if (_eventNames == null)
                    {
                        _eventNames = CrateEventEntries
                            .Select(x => x.EventName)
                            .ToArray();
                    }
                    
                    return _eventNames;
                }
            }

            [JsonIgnore] 
            public bool IsValid => CrateEventEntries?.Count > 0;

            public EventEntry? FindEventByName(string eventName)
            {
                return CrateEventEntries.Find(x => x.EventName.Equals(eventName, StringComparison.OrdinalIgnoreCase));
            }
        }

        private class EventEntry
        {
            [JsonProperty("event display name)")] 
            public string EventName;
            [JsonProperty("event duration")] 
            public float EventDuration;

            [JsonProperty("enable lock to player when completing the event")]
            public bool EnableLockToPlayer;

            [JsonProperty("enable clan tag")] 
            public bool EnableClanTag;

            [JsonProperty("enable auto hacking of crate when an event is finished")]
            public bool EnableAutoHack;

            [JsonProperty("hackable locked crate")]
            public float HackableCrateHackSeconds;

            [JsonProperty("hackable crate fall drag")]
            public float HackableCrateFallDrag;

            [JsonProperty("enable marker")] 
            public bool EnableMarker;
            [JsonProperty("marker color 1")] 
            public string MapMarkerColor1;
            [JsonProperty("marker color 2")] 
            public string MapMarkerColor2;
            [JsonProperty("marker radius")] 
            public float MapMarkerRadius;
            [JsonProperty("marker opacity")] 
            public float MapMarkerOpacity;

            [JsonProperty("enable loot table")] 
            public bool EnableLootTable;
            [JsonProperty("min loot items")] 
            public int LootMinAmount;
            [JsonProperty("max loot items")]
            public int LootMaxAmount;

            [JsonProperty("enable eliminate all guards before looting")]
            public bool EnableEliminateGuards;

            [JsonProperty("guard spawn amount")] 
            public int GuardAmount;
            [JsonProperty("guard spawn config")] 
            public GuardEntry GuardEntry;

            [JsonProperty("create loot items")] 
            public List<ItemEntry> LootTable;
        }

        private class ItemEntry
        {
            public string DisplayName;
            public string Shortname;
            public ulong SkinID = 0UL;
            public int MinAmount;
            public int MaxAmount;
            
            public static List<ItemEntry> SaveItems(ItemContainer container)
            {
                List<ItemEntry> items = new List<ItemEntry>();

                foreach (Item item in container.itemList)
                {
                    items.Add(new ItemEntry
                    {
                        DisplayName = item.name,
                        Shortname = item.info.shortname,
                        SkinID = item.skin,
                        MinAmount = item.amount,
                        MaxAmount = item.amount,
                    });
                }

                return items;
            }

            public Item CreateItem()
            {
                Item item = ItemManager.CreateByName(Shortname, UnityEngine.Random.Range(MinAmount, MaxAmount), SkinID);
                item.name = DisplayName;
                item.MarkDirty();
                return item;
            }
        }

        private class GuardEntry
        {
            public string Name;
            public List<WearEntry> WearItems;
            public List<BeltEntry> BeltItems;
            public string Kit;
            public float Health;
            public float RoamRange;
            public float ChaseRange;
            public float SenseRange;
            public float AttackRangeMultiplier;
            public bool CheckVisionCone;
            public float VisionCone;
            public float DamageScale;
            public float TurretDamageScale;
            public float AimConeScale;
            public float Speed;
            public float SleepDistance;
            public float MemoryDuration;
            public bool HostileTargetsOnly;
            public bool DisableRadio;
            public bool CanRunAwayWater;
            public bool CanSleep;
            public bool CanRaid;
            public bool Stationary;
            public bool AboveOrUnderGround;
            
            [JsonIgnore]
            public string[] Kits;

            [JsonIgnore] 
            public JObject Parsed;
            
            public class BeltEntry
            {
                public string ShortName;
                public ulong SkinID;
                public int Amount;
                public string Ammo;
                public List<string> Mods;

                public static List<BeltEntry> SaveItems(ItemContainer container)
                {
                    List<BeltEntry> items = new List<BeltEntry>();

                    foreach (Item item in container.itemList)
                    {
                        BeltEntry beltEntry = new BeltEntry
                        {
                            ShortName = item.info.shortname,
                            SkinID = item.skin,
                            Amount = item.amount,
                            Mods = new List<string>()
                        };

                        if (item.GetHeldEntity() is BaseProjectile projectile && projectile?.primaryMagazine != null &&
                            projectile.primaryMagazine.ammoType != null)
                            beltEntry.Ammo = projectile.primaryMagazine.ammoType.shortname;

                        if (item?.contents?.itemList != null)
                        {
                            foreach (Item itemContent in item.contents.itemList)
                                beltEntry.Mods.Add(itemContent.info.shortname);
                        }

                        items.Add(beltEntry);
                    }

                    return items;
                }
            }

            public class WearEntry
            {
                public string ShortName;
                public ulong SkinID;

                public static List<WearEntry> SaveItems(ItemContainer container)
                {
                    List<WearEntry> items = new List<WearEntry>();

                    foreach (Item item in container.itemList)
                    {
                        items.Add(new WearEntry
                        {
                            ShortName = item.info.shortname,
                            SkinID = item.skin
                        });
                    }

                    return items;
                }
            }

            public void CacheConfig()
            {
                Kits = Kit?.Split(',') ?? Array.Empty<string>();
                
                Parsed = new JObject
                {
                    ["Name"] = Name,
                    ["WearItems"] = new JArray
                    {
                        WearItems.Select(x => new JObject
                        {
                            ["ShortName"] = x.ShortName,
                            ["SkinID"] = x.SkinID
                        })
                    },
                    ["BeltItems"] = new JArray
                    {
                        BeltItems.Select(x => new JObject
                        {
                            ["ShortName"] = x.ShortName,
                            ["Amount"] = x.Amount,
                            ["SkinID"] = x.SkinID,
                            ["Mods"] = new JArray { x.Mods },
                            ["Ammo"] = x.Ammo
                        })
                    },
                    ["Kit"] = Kit,
                    ["Health"] = Health,
                    ["RoamRange"] = RoamRange,
                    ["ChaseRange"] = ChaseRange,
                    ["SenseRange"] = SenseRange,
                    ["ListenRange"] = SenseRange / 2f,
                    ["AttackRangeMultiplier"] = AttackRangeMultiplier,
                    ["CheckVisionCone"] = CheckVisionCone,
                    ["VisionCone"] = VisionCone,
                    ["HostileTargetsOnly"] = HostileTargetsOnly,
                    ["DamageScale"] = DamageScale,
                    ["TurretDamageScale"] = TurretDamageScale,
                    ["AimConeScale"] = AimConeScale,
                    ["DisableRadio"] = DisableRadio,
                    ["CanRunAwayWater"] = CanRunAwayWater,
                    ["CanSleep"] = CanSleep,
                    ["SleepDistance"] = SleepDistance,
                    ["Speed"] = Speed,
                    ["AreaMask"] = !AboveOrUnderGround ? 1 : 25,
                    ["AgentTypeID"] = !AboveOrUnderGround ? -1372625422 : 0,
                    ["HomePosition"] = string.Empty,
                    ["MemoryDuration"] = MemoryDuration,
                    ["States"] = new JArray
                    {
                        Stationary
                            ? new HashSet<string> { "IdleState", "CombatStationaryState" }
                            : CanRaid
                                ? new HashSet<string> { "RoamState", "CombatState", "ChaseState", "RaidState" }
                                : new HashSet<string> { "RoamState", "CombatState", "ChaseState" }
                    }
                };
            }
        }

        private static EventEntry CreateDefaultEvent(string eventName, float hackSeconds, string markerColor, int guardAmount, string weaponShortName, float guardHealth)
        {
            return new EventEntry
            {
                EventName = eventName,
                EventDuration = 1800f,
                EnableAutoHack = true,
                HackableCrateHackSeconds = hackSeconds,
                HackableCrateFallDrag = 1f,
                EnableLockToPlayer = true,
                EnableClanTag = true,
                EnableMarker = true,
                MapMarkerColor1 = markerColor,
                MapMarkerColor2 = "#000000",
                MapMarkerOpacity = 0.6f,
                MapMarkerRadius = 0.7f,
                EnableLootTable = false,
                LootMinAmount = 6,
                LootMaxAmount = 10,
                LootTable = new List<ItemEntry>(),
                EnableEliminateGuards = true,
                GuardAmount = guardAmount,
                GuardEntry = CreateDefaultGuard($"{eventName} Guard", weaponShortName, guardHealth)
            };
        }

        private static GuardEntry CreateDefaultGuard(string guardName, string shortname, float health)
        {
            return new GuardEntry
            {
                Name = guardName,
                WearItems = new List<GuardEntry.WearEntry>
                {
                    new() { ShortName = "hazmatsuit_scientist_peacekeeper", SkinID = 0UL }
                },
                BeltItems = new List<GuardEntry.BeltEntry>
                {
                    new() { ShortName = shortname, Amount = 1, SkinID = 0UL, Mods = new List<string>() },
                    new() { ShortName = "syringe.medical", Amount = 10, SkinID = 0UL, Mods = new List<string>() }
                },
                Kit = "",
                Health = health,
                RoamRange = 5f,
                ChaseRange = 40f,
                SenseRange = 80f,
                AttackRangeMultiplier = 8f,
                VisionCone = 180f,
                CheckVisionCone = false,
                DamageScale = 1f,
                TurretDamageScale = 0.25f,
                AimConeScale = shortname == "rifle.ak" ? 0.15f : 0.25f,
                SleepDistance = 100f,
                Speed = 8.5f,
                MemoryDuration = 30f
            };
        }

        #endregion

        #region Lang

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                { LangKeys.NO_PERMISSION, "Sorry you don't have permission to do that." },
                { LangKeys.PREFIX, "<color=#8a916f>Guarded Crate</color>:<br>" },

                { LangKeys.FAILED_TO_START_EVENT, "Failed to start event." },
                { LangKeys.START_EVENT, "Event starting." },
                { LangKeys.CLEAR_EVENTS, "Cleaning up all running events." },

                { LangKeys.EVENT_START, "Special delivery is on its way to <color=#e7cf85>{0}</color> watch out it is heavily contested by guards, severity level <color=#e7cf85>{1}</color>.\nBe fast before the event ends in <color=#e7cf85>{2}</color>." },
                { LangKeys.EVENT_COMPLETED, "<color=#e7cf85>{1}</color> has cleared the event at <color=#e7cf85>{0}</color>." },
                { LangKeys.EVENT_ENDED, "Event ended at <color=#e7cf85>{0}</color>; You were not fast enough; better luck next time!" },
                { LangKeys.EVENT_NOT_FOUND, "Event not found, please make sure you have typed the correct name." },
                { LangKeys.EVENT_INTERSECTING, "Another event is intersecting this position." },
                { LangKeys.EVENT_POSITION_INVALID, "Event position invalid." },
                { LangKeys.ELIMINATE_GUARDS, "The crate is still contested eliminate all guards to gain access to high-valued loot." },
                { LangKeys.EVENT_UPDATED, "Event updated, please reload the plugin to take effect." },
                { LangKeys.INVALID_GUARD_AMOUNT, "Invalid guard amount must be between {0} - {1}." },

                { LangKeys.HELP_START_EVENT, "<color=#e7cf85>/{0}</color> start \"<color=#e7cf85><{1}></color>\", start an event of a specified type." },
                { LangKeys.HELP_STOP_EVENT, "<color=#e7cf85>/{0}</color> stop, stop all currently running events.<br><br>" },
                { LangKeys.HELP_HERE_EVENT, "<color=#e7cf85>/{0}</color> here \"<color=#e7cf85><event-name></color>\", start an event at your position<br><br>" },
                { LangKeys.HELP_POSITION_EVENT, "<color=#e7cf85>/{0}</color> position \"<color=#e7cf85><event-name></color>\" \"<color=#e7cf85>x y z</color>\", start an event at a specified position.<br><br>" },
                { LangKeys.HELP_LOOT_EVENT, "<color=#e7cf85>/{0}</color> loot \"<color=#e7cf85><event-name></color>\", create loot items that you wish to spawn in the crate, add the items to your inventory and run the command.<br><br>" },
                { LangKeys.HELP_DRAG_EVENT, "<color=#e7cf85>/{0}</color> drag \"<color=#e7cf85><event-name></color>\", specify the amount of drag the crate should have while falling.<br><br>" },
                { LangKeys.HELP_GUARD_AMOUNT, "<color=#e7cf85>/{0}</color> amount \"<color=#e7cf85><event-name></color>\", specify the guard amount to spawn.<br><br>" },
                { LangKeys.HELP_GUARD_LOADOUT, "<color=#e7cf85>/{0}</color> loadout \"<color=#e7cf85><event-name></color>\", set guard loadout using items in your inventory." }
            }, this);
        }
        
        private static class LangKeys
        {
            public const string PREFIX = "PREFIX";
            public const string NO_PERMISSION = "NO_PERMISSION";

            public const string FAILED_TO_START_EVENT = "FAILED_TO_START_EVENT";
            public const string CLEAR_EVENTS = "CLEAR_EVENTS";
            public const string START_EVENT = "START_EVENT";

            public const string EVENT_COMPLETED = "EVENT_COMPLETED";
            public const string EVENT_START = "EVENT_START";
            public const string EVENT_ENDED = "EVENT_ENDED";
            public const string EVENT_NOT_FOUND = "EVENT_NOT_FOUND";
            public const string EVENT_POSITION_INVALID = "EVENT_POSITION_INVALID";
            public const string EVENT_INTERSECTING = "EVENT_INTERSECTING";
            public const string EVENT_UPDATED = "EVENT_UPDATED";
            public const string ELIMINATE_GUARDS = "ELIMINATE_GUARDS";
            public const string INVALID_GUARD_AMOUNT = "INVALID_GUARD_AMOUNT";

            public const string HELP_START_EVENT = "HELP_START_EVENTt";
            public const string HELP_STOP_EVENT = "HELP_STOP_EVENT";
            public const string HELP_HERE_EVENT = "HELP_HERE_EVENT";
            public const string HELP_POSITION_EVENT = "HELP_POSITION_EVENT";
            public const string HELP_LOOT_EVENT = "HELP_LOOT_EVENT";
            public const string HELP_DRAG_EVENT = "HELP_DRAG_EVENT";
            public const string HELP_GUARD_AMOUNT = "HELP_GUARD_AMOUNT";
            public const string HELP_GUARD_LOADOUT = "HELP_GUARD_LOADOUT";
        }

        #endregion

        #region Oxide Hooks

        private void OnServerInitialized()
        {
            SpawnManager.Instance.FindSpawnPoints();
            
            if (!string.IsNullOrEmpty(_configData.CommandName))
            {
                cmd.AddConsoleCommand(_configData.CommandName, this, nameof(EventConsoleCommands));
                cmd.AddChatCommand(_configData.CommandName, this, nameof(EventCommandCommands));
            }

            if (_configData.AutoStartEnabled && _configData.AutoStartLength > 0)
                timer.Every(_configData.AutoStartLength, () => TryStartEvent(null));
        }

        private void Init()
        {
            permission.RegisterPermission(PERM_USE, this);
            
            _plugin = this;
            _getMessage = (key, userId) => lang.GetMessage(key, this, userId);
            _getClanTag = (userID) => Clans?.Call<string>("GetClanOf", userID);
            _trySpawnNpc = (position, config) => (ScientistNPC)NpcSpawn?.Call("SpawnNpc", position, config);
            _tryLockHackable = (player, lockedCrate) => HackableLock?.Call("LockCrateToPlayer", player, lockedCrate);
            _TrySendGuiNotification = (message, bgColor, textColor, player, duration) => GUIAnnouncements?.Call("CreateAnnouncement", message, bgColor, textColor, player, duration); 
            
            LoadData();
            
            SpawnManager.Initialize(_configData.BlockedTopologies);
        }

        private void Unload()
        {
            try
            {
                EventManager.OnUnload();
                SpawnManager.OnUnload();
                EntityCache.OnUnload();
            }
            finally
            {
                _plugin = null;
                _configData = null;
            }
        }

        private void OnPlayerDeath(ScientistNPC npc, HitInfo info)
        {
            NetworkableId id = npc.net.ID;
            EntityCache.FindEventByEntity(id)?.OnGuardKilled(npc, info?.InitiatorPlayer);
            EntityCache.RemoveEntity(id);
        }
        
        private void OnEntityKill(ScientistNPC npc)
        {
            NetworkableId id = npc.net.ID;
            EntityCache.FindEventByEntity(id)?.OnGuardKilled(npc, null);
            EntityCache.RemoveEntity(id);
        }

        private void OnEntityKill(LootContainer container)
        {
            NetworkableId id = container.net?.ID ?? default;
            EntityCache.FindEventByEntity(id)?.OnCrateKilled(container);
            EntityCache.RemoveEntity(id);
        }

        private void OnEntityKill(CargoPlane plane)
        {
            NetworkableId id = plane.net?.ID ?? default;
            EntityCache.FindEventByEntity(id)?.OnPlaneKilled(plane);
            EntityCache.RemoveEntity(id);
        }

        private object? CanHackCrate(BasePlayer player, HackableLockedCrate crate)
        {
            NetworkableId id = crate.net?.ID ?? default;
            return EntityCache.FindEventByEntity(id)?.CanHackCrate(player);
        }

        #endregion

        #region Spawn Manager

        private class SpawnManager
        {
            public static SpawnManager Instance { get; private set; }
            
            private const int BLOCKED_TERRAIN_TOPOLOGIES = (int)(
                TerrainTopology.Enum.Cliff     | TerrainTopology.Enum.Cliffside  |
                TerrainTopology.Enum.Beach     | TerrainTopology.Enum.Beachside  |
                TerrainTopology.Enum.Ocean     | TerrainTopology.Enum.Oceanside  |
                TerrainTopology.Enum.Monument  | TerrainTopology.Enum.Building   |
                TerrainTopology.Enum.River     | TerrainTopology.Enum.Riverside  |
                TerrainTopology.Enum.Lake      | TerrainTopology.Enum.Lakeside
            );

            private const int BLOCKED_LAYER_MASKS = (int)(
                Layers.Mask.Construction | Layers.Mask.Prevent_Building |
                Layers.Mask.Vehicle_World | Layers.Mask.Vehicle_Large | 
                Layers.Mask.Vehicle_Detailed
                );
            
            private const float SPAWN_EXCLUSION_RADIUS = 100f;
            private const float SPAWN_COLLIDER_RADIUS = 5f;
            private const float SPAWN_RAYCAST_HEIGHT = 100f;
            private const float SPAWN_PLAYER_RADIUS = 100f;
            private const float SPAWN_BUILDING_RADIUS = 100f;
            
            private readonly List<ZoneInfo> _ignoreZones = new();
            private readonly Queue<Vector3> _spawnPoints = new();            
            private TerrainBiome.Enum _blockedTerrainBiomes = 0;
            private readonly Stopwatch _stopwatch = new();
            private bool _hasBlockedTerrainBiomes;
            private int _spawnPointAttempt;
            private int _spawnPointIndexed;
            private Coroutine _routine;
            
            public static void Initialize(List<TerrainBiome.Enum> blockedTerrainBiomes)
            {
                Instance = new SpawnManager();
                Instance._hasBlockedTerrainBiomes = blockedTerrainBiomes.Count > 0;
                
                foreach (TerrainBiome.Enum blockedTopology in blockedTerrainBiomes)
                    Instance._blockedTerrainBiomes |= blockedTopology;
                
                Instance.GetIgnoredZones();
            }

            public static void OnUnload()
            {
                Instance.Dispose();
                Instance = null;
            }

            public void Dispose()
            {
                CleanupSpawnsRoutine();
                
                _ignoreZones.Clear();
                _spawnPoints.Clear();
            }

            private IEnumerator GenerateSpawnsRoutine(int maxSpawnPoints = 100, int maxSpawnAttempts = 5000, float frameBudget = 0.10f)
            {
                yield return CoroutineEx.waitForEndOfFrame;
                
                Interface.Oxide.LogDebug("GuardedCrate: generating spawn points.");
                
                float mapSizeX = TerrainMeta.Size.x / 2;
                float mapSizeZ = TerrainMeta.Size.z / 2;
                Vector3 spawnPoint = Vector3.zero;
                
                _spawnPointAttempt = 0;
                _spawnPointIndexed = 0;
                
                while (_spawnPointIndexed < maxSpawnPoints)
                {
                    _stopwatch.Restart();
                    
                    for (int index = _spawnPointIndexed; index < maxSpawnPoints; ++index)
                    {
                        spawnPoint.x = UnityEngine.Random.Range(-mapSizeX, mapSizeX);
                        spawnPoint.z = UnityEngine.Random.Range(-mapSizeZ, mapSizeZ);
                        spawnPoint.y = TerrainMeta.HeightMap.GetHeight(spawnPoint);
                        
                        ++_spawnPointAttempt;
                        
                        if (IsValidSpawnPointTest(ref spawnPoint))
                        {
                            _spawnPoints.Enqueue(spawnPoint);
                            
                            ++_spawnPointIndexed;
                        }

                        if (_stopwatch.Elapsed.TotalMilliseconds >= frameBudget)
                            break;
                    }
                    
                    if (_spawnPointAttempt >= maxSpawnAttempts)
                        break;
                    
                    yield return CoroutineEx.waitForEndOfFrame;
                }
                
                _stopwatch.Stop();

                Interface.Oxide.LogDebug("GuardedCrate: finished generating spawn points ({0}) found.", _spawnPoints.Count);
                
                _routine = null;
            }

            private void CleanupSpawnsRoutine()
            {
                if (_routine == null) 
                    return;
                
                ServerMgr.Instance.StopCoroutine(_routine);
                
                _routine = null;
            }
            
            public void FindSpawnPoints()
            {
                if (_routine != null)
                    return;

                _routine = ServerMgr.Instance.StartCoroutine(GenerateSpawnsRoutine());
            }

            public Vector3 GetSpawnPoint()
            {
                Vector3 spawnPoint = Vector3.zero;
                
                while (_spawnPoints.Count > 0)
                {
                    Vector3 point = _spawnPoints.Dequeue();
                    if (IsValidSpawnPointPost(point))
                    {
                        spawnPoint = point;
                        break;                        
                    }
                }
                
                if (_routine == null && _spawnPoints.Count < 50)
                    FindSpawnPoints();
                
                return spawnPoint;
            }

            private void GetIgnoredZones()
            {
                if (!_configData.ZoneSettings.Enabled)
                    return;
                
                if (_plugin?.ZoneManager == null)
                    return;

                ZoneSettings zoneMangerSettings = _configData?.ZoneSettings;
                if (!(zoneMangerSettings?.ExcludedZones?.Count > 0))
                    return;
                
                if (_plugin.ZoneManager.Call("GetZoneIDs") is not string[] zoneIds || zoneIds.Length == 0)
                    return;
                
                foreach (string zoneId in zoneIds)
                {
                    if (!zoneMangerSettings.ExcludedZones.Contains(zoneId)) 
                        continue;
                    
                    if (_plugin.ZoneManager.Call("GetZoneLocation", zoneId) is not Vector3 position) 
                        continue;
                    
                    if (_plugin.ZoneManager.Call("GetZoneRadius", zoneId) is not float radius)
                        continue;
                    
                    _ignoreZones.Add(new ZoneInfo(position, radius));
                }
            }

            private bool IsValidSpawnPointTest(ref Vector3 spawnPoint)
            {
                Vector3 rayOrigin = spawnPoint.WithY(spawnPoint.y + SPAWN_RAYCAST_HEIGHT);
                if (!Physics.Raycast(rayOrigin, Vector3.down, out RaycastHit hit, SPAWN_RAYCAST_HEIGHT, 1218652417, QueryTriggerInteraction.Ignore))
                    return false;
                
                spawnPoint.y = hit.point.y;
                
                if (AntiHack.TestInsideTerrain(spawnPoint)) 
                    return false;
                
                if (AntiHack.IsInsideMesh(spawnPoint)) 
                    return false;
                
                if (IsTooCloseToExistingPoint(spawnPoint)) 
                    return false;
                
                if (IsInsideIgnoredZone(spawnPoint)) 
                    return false;
                
                if (IsBlockedTerrainTopology(spawnPoint)) 
                    return false;
                
                if (IsBlockedTerrainBiome(spawnPoint)) 
                    return false;

                return IsValidSpawnPointPost(spawnPoint);
            }

            private bool IsValidSpawnPointPost(Vector3 spawnPoint)
            {
                if (WaterLevel.Test(spawnPoint, false, false)) 
                    return false;
                
                if (!HasValidColliders(spawnPoint)) 
                    return false;
                
                if (HasPlayersNearby(spawnPoint)) 
                    return false;
                
                if (HasBuildingNearby(spawnPoint)) 
                    return false;
                
                return true;
            }

            private bool IsBlockedTerrainTopology(Vector3 spawnPoint)
            {
                return (TerrainMeta.TopologyMap.GetTopology(spawnPoint) & BLOCKED_TERRAIN_TOPOLOGIES) != 0;
            }
            
            private bool IsBlockedTerrainBiome(Vector3 spawnPoint) => _hasBlockedTerrainBiomes && TerrainMeta.BiomeMap.GetBiome(spawnPoint, (int)_blockedTerrainBiomes) != 0f;

            private bool IsInsideIgnoredZone(Vector3 spawnPoint)
            {
                foreach (ZoneInfo zone in _ignoreZones)
                {
                    if (zone.IsInBounds(spawnPoint))
                        return true;
                }

                return false;
            }

            private bool IsTooCloseToExistingPoint(Vector3 spawnPoint)
            {
                foreach (Vector3 existing in _spawnPoints)
                {
                    if (Vector3.Distance(existing, spawnPoint) <= SPAWN_EXCLUSION_RADIUS)
                        return true;
                }

                return false;
            }

            private static bool HasValidColliders(Vector3 spawnPoint)
            {
                List<Collider> colliders = Facepunch.Pool.Get<List<Collider>>();

                try
                {
                    Vis.Colliders<Collider>(spawnPoint, SPAWN_COLLIDER_RADIUS, colliders);

                    foreach (Collider col in colliders)
                    {
                        if ((1 << col.gameObject.layer & BLOCKED_LAYER_MASKS) > 0)
                            return false;

                        string name = col.name;

                        if (name.Contains("radiation", CompareOptions.IgnoreCase) ||
                            name.Contains("rock", CompareOptions.IgnoreCase) ||
                            name.Contains("cliff", CompareOptions.IgnoreCase) ||
                            name.Contains("road", CompareOptions.IgnoreCase) ||
                            name.Contains("train", CompareOptions.IgnoreCase) ||
                            name.Contains("fireball", CompareOptions.IgnoreCase) ||
                            name.Contains("iceberg", CompareOptions.IgnoreCase) ||
                            name.Contains("ice_sheet", CompareOptions.IgnoreCase))
                            return false;

                        if (col.HasComponent<TriggerSafeZone>())
                            return false;
                    }

                    return true;
                }
                finally
                {
                    // Use finally so the pool list is always returned, even on exception.
                    Facepunch.Pool.FreeUnmanaged(ref colliders);
                }
            }

            private static bool HasPlayersNearby(Vector3 spawnPoint)
            {
                List<BasePlayer> players = Facepunch.Pool.Get<List<BasePlayer>>();

                try
                {
                    Vis.Entities(spawnPoint, SPAWN_PLAYER_RADIUS, players, Layers.Mask.Player_Server);

                    foreach (BasePlayer player in players)
                    {
                        if (player.userID.IsSteamId() && !player.IsSleeping())
                            return true;
                    }

                    return false;
                }
                finally
                {
                    Facepunch.Pool.FreeUnmanaged(ref players);
                }
            }

            private static bool HasBuildingNearby(Vector3 spawnPoint)
            {
                List<BuildingPrivlidge> privileges = Facepunch.Pool.Get<List<BuildingPrivlidge>>();

                try
                {
                    Vis.Entities(spawnPoint, SPAWN_BUILDING_RADIUS, privileges, Layers.Mask.Prevent_Building);
                    return privileges.Count > 0;
                }
                finally
                {
                    Facepunch.Pool.FreeUnmanaged(ref privileges);
                }
            }
        }

        private struct ZoneInfo
        {
            public Vector3 Position;
            public float Radius;

            public ZoneInfo(Vector3 position, float radius)
            {
                Position = position;
                Radius = radius;
            }

            public bool IsInBounds(Vector3 position)
            {
                float dx = position.x - Position.x;
                float dy = position.y - Position.y;
                return (dx * dx + dy * dy) <= Radius;
            }
        }

        #endregion

        #region Event Manager

        private static class EventManager
        {
            private static readonly List<GuardedCrateInstance> Instances = new();

            public static void OnUnload() => CleanupInstances();

            public static bool HasIntersectingEvent(Vector3 position)
            {
                for (int i = 0; i < Instances.Count; i++)
                {
                    GuardedCrateInstance instance = Instances[i];
                    if (instance != null && Vector3Ex.Distance2D(instance.transform.position, position) < 80f)
                        return true;
                }

                return false;
            }

            public static void CleanupInstances()
            {
                for (int i = Instances.Count - 1; i >= 0; i--)
                {
                    GuardedCrateInstance instance = Instances[i];
                    if (instance != null) 
                        instance.EventEnded();
                }

                Instances.Clear();
            }

            public static void RegisterInstance(GuardedCrateInstance eventInstance)
            {
                Instances.Add(eventInstance);
                
                _plugin?.SubscribeToHooks(Instances.Count);
            }

            public static void UnregisterInstance(GuardedCrateInstance eventInstance)
            {
                Instances.Remove(eventInstance);
                
                _plugin?.SubscribeToHooks(Instances.Count);
            }
        }

        #endregion

        #region Event
        
        private enum EventState { Initialize, Running, Ended, Failed, Complete, }

        private bool TryStartEvent(string eventName = null)
        {
            if (!NpcSpawn.IsPluginReady())
            {
                PrintWarning("Failed to find NpcSpawn plugin, please download from https://codefling.com/extensions/npc-spawn");
                Interface.Oxide.UnloadPlugin(Name);
                return false;
            }
            
            Vector3 position = SpawnManager.Instance.GetSpawnPoint();
            if (position == Vector3.zero)
            {
                PrintWarning("Failed to find a valid spawn point.");
                return false;
            }

            EventEntry eventEntry = _storedData.FindEventByName(eventName) ?? _storedData.CrateEventEntries.GetRandom();
            if (eventEntry == null)
            {
                PrintWarning("Failed to find a valid event entry, please check your configuration.");
                return false;
            }

            GuardedCrateInstance.CreateInstance(eventEntry, position);
            return true;
        }

        private class GuardedCrateInstance : FacepunchBehaviour, IEventSpawnInstance
        {
            public List<BaseEntity> guardSpawnInstances = new();
            public MapMarkerGenericRadius markerSpawnInstance;
            public HackableLockedCrate crateSpawnInstance;
            public CargoPlane cargoPlaneInstance;
            public List<ItemEntry> LootTable;
            public GuardEntry GuardEntry;
            public int GuardSpawnInstanceCount => guardSpawnInstances.Count;

            public EventState currentState = EventState.Initialize;
            public float thinkEvery = 1f;
            public float lastThinkTime;
            public float timePassed;
            public bool timeEnded;

            public string eventName;
            public float eventSeconds = 120f;

            public bool enableHackableLock;
            public bool enableClanTag;

            public bool enableAutoHack;
            public float hackSeconds;
            public float hackableCrateFallDrag;

            public bool enableMarker;
            public Color markerColor1;
            public Color markerColor2;
            public float markerRadius;
            public float markerOpacity;

            public bool enableEliminateGuards;
            public int guardAmount;

            public bool enableLootTable;
            public int minLootAmount;
            public int maxLootAmount;

            private Coroutine _spawnRoutine;

            public static void CreateInstance(EventEntry eventEntry, Vector3 position)
            {
                GuardedCrateInstance instance = Helpers.CreateObjectWithComponent<GuardedCrateInstance>(position, Quaternion.identity, "Guarded_Create_Event");
                instance.ConfigureEvent(eventEntry);
                instance.EventStart();
                
                EventManager.RegisterInstance(instance);
            }

            private static void RemoveInstance(GuardedCrateInstance crateInstance)
            {
                GuardedCrateInstance instance = crateInstance;
                
                EventManager.UnregisterInstance(instance);
                UnityEngine.Object.Destroy(instance?.gameObject);
            }

            private void ConfigureEvent(EventEntry eventEntry)
            {
                if (eventEntry.GuardEntry.Parsed == null)
                    eventEntry.GuardEntry.CacheConfig();

                eventName = eventEntry.EventName;
                eventSeconds = eventEntry.EventDuration;

                enableHackableLock = eventEntry.EnableLockToPlayer;
                enableClanTag = eventEntry.EnableClanTag;

                enableAutoHack = eventEntry.EnableAutoHack;
                hackSeconds = eventEntry.HackableCrateHackSeconds;
                hackableCrateFallDrag = eventEntry.HackableCrateFallDrag;

                enableMarker = eventEntry.EnableMarker;
                markerColor1 = Helpers.GetColor(eventEntry.MapMarkerColor1);
                markerColor2 = Helpers.GetColor(eventEntry.MapMarkerColor2);
                markerRadius = eventEntry.MapMarkerRadius;
                markerOpacity = eventEntry.MapMarkerOpacity;

                enableLootTable = eventEntry.EnableLootTable;
                minLootAmount = eventEntry.LootMinAmount;
                maxLootAmount = eventEntry.LootMaxAmount;
                LootTable = eventEntry.LootTable;

                enableEliminateGuards = eventEntry.EnableEliminateGuards;
                GuardEntry = eventEntry.GuardEntry;
                guardAmount = eventEntry.GuardAmount;
            }

            private void FixedUpdate()
            {
                if (lastThinkTime < thinkEvery)
                {
                    lastThinkTime += UnityEngine.Time.deltaTime;
                    return;
                }

                if (timeEnded)
                    return;

                timePassed += lastThinkTime;

                if (timePassed >= eventSeconds)
                {
                    timeEnded = true;
                    EventFailed();
                    return;
                }

                lastThinkTime = 0.0f;
            }
            
            #region Event

            private void SwitchState(EventState state) => currentState = state;

            public void EventStart()
            {
                SwitchState(EventState.Running);
                SpawnPlane();
                Interface.Oxide.CallHook("OnGuardedCrateEventStart", transform.position);
                Notification.MessagePlayers(LangKeys.EVENT_START, MapHelper.PositionToString(transform.position), eventName, eventSeconds.ToStringTime());
            }
            
            public void EventEnded()
            {
                if (currentState == EventState.Complete)
                    crateSpawnInstance = null;
                
                SwitchState(EventState.Ended);
                
                ClearSpawning();
                ClearEntities();
                
                crateSpawnInstance = null;
                cargoPlaneInstance = null;
                
                GuardedCrateInstance.RemoveInstance(this);
            }
            
            public void EventComplete(BasePlayer player)
            {
                SwitchState(EventState.Complete);
                
                TryLockCrate(player);
                TryHackCrate();
                
                Interface.CallHook("OnGuardedCrateEventEnded", player, crateSpawnInstance);
                Notification.MessagePlayers(LangKeys.EVENT_COMPLETED, MapHelper.PositionToString(transform.position), GetWinnerName(player));
                EventEnded();
            }
            
            public void EventFailed()
            {
                SwitchState(EventState.Failed);
                
                Interface.Oxide.CallHook("OnGuardedCrateEventFailed", transform.position);
                Notification.MessagePlayers(LangKeys.EVENT_ENDED, MapHelper.PositionToString(transform.position));
                EventEnded();
            }

            private void TryLockCrate(BasePlayer player)
            {
                if (!enableHackableLock)
                    return;

                _tryLockHackable(player, crateSpawnInstance);
            }

            private void TryHackCrate()
            {
                if (crateSpawnInstance == null || crateSpawnInstance.IsDestroyed)
                    return;

                crateSpawnInstance.shouldDecay = true;

                if (enableAutoHack)
                {
                    crateSpawnInstance.StartHacking();
                    return;
                }
                
                crateSpawnInstance.RefreshDecay();
            }

            private string GetWinnerName(BasePlayer player)
            {
                const string defaultName = "Unknown";
                
                if (player == null)
                    return defaultName;

                string displayName = player.displayName;
                if (string.IsNullOrEmpty(displayName))
                    return player.UserIDString;

                if (enableClanTag)
                {
                    string clanTag = _getClanTag(player.userID);
                    if (!string.IsNullOrEmpty(clanTag)) return $"[{clanTag}]{displayName}";
                }

                return displayName;
            }

            #endregion

            #region Spawning

            public void StartSpawning()
            {
                _spawnRoutine = StartCoroutine(SpawnEntities());
            }

            public void ClearSpawning()
            {
                if (_spawnRoutine != null)
                    StopCoroutine(_spawnRoutine);

                _spawnRoutine = null;
            }
            
            private IEnumerator SpawnEntities()
            {
                yield return CoroutineEx.waitForEndOfFrame;
                yield return SpawnMarker();
                yield return SpawnGuards();
                yield return SpawnHackable();

                _spawnRoutine = null;
            }

            private void ClearEntities()
            {
                ClearHackable();
                ClearPlane();
                ClearMarker();
                ClearGuards();
            }

            #region Cargo Plane

            private void SpawnPlane()
            {
                cargoPlaneInstance = (CargoPlane)GameManager.server.CreateEntity(PLANE_PREFAB);
                cargoPlaneInstance.InitDropPosition(transform.position);
                cargoPlaneInstance.dropped = true;
                cargoPlaneInstance.Spawn();
                cargoPlaneInstance.secondsTaken = 0f;
                cargoPlaneInstance.secondsToTake = 30f;
                
                EventCargoPlane eventCargoPlane = cargoPlaneInstance.gameObject.AddComponent<EventCargoPlane>();
                eventCargoPlane.EventSpawnInstance = this;
                
                EntityCache.CreateEntity(cargoPlaneInstance.net.ID, this);
            }

            private void ClearPlane()
            {
                cargoPlaneInstance?.SafeKill();
                cargoPlaneInstance = null;
            }

            #endregion

            #region Guards

            private IEnumerator SpawnGuards()
            {
                yield return CoroutineEx.waitForEndOfFrame;
                
                for (int i = 0; i < guardAmount; i++)
                {
                    float angle = (360f / guardAmount) * i;
                    Vector3 position = transform.position.GetPointAround(10551297, 5f, angle);
                    yield return SpawnGuard(position);
                }
            }

            private IEnumerator SpawnGuard(Vector3 position)
            {
                yield return CoroutineEx.waitForEndOfFrame;
                
                GuardEntry.Parsed["HomePosition"] = position.ToString();
                GuardEntry.Parsed["Kit"] = GuardEntry.Kits.Length > 0 ? GuardEntry.Kits.GetRandom() : string.Empty;
                
                ScientistNPC entity = _trySpawnNpc(position, GuardEntry.Parsed);
                if (entity == null)
                    yield break;
                
                guardSpawnInstances.Add(entity);
                EntityCache.CreateEntity(entity.net.ID, this);
            }

            private void ClearGuards()
            {
                for (int i = guardSpawnInstances.Count - 1; i >= 0; i--)
                    guardSpawnInstances[i].SafeKill();

                guardSpawnInstances.Clear();
            }

            #endregion

            #region Marker

            private IEnumerator SpawnMarker()
            {
                yield return CoroutineEx.waitForEndOfFrame;

                if (!enableMarker)
                    yield break;

                markerSpawnInstance = Helpers.CreateEntity<MapMarkerGenericRadius>(MARKER_PREFAB, transform.position, Quaternion.identity);
                markerSpawnInstance.EnableSaving(false);
                markerSpawnInstance.color1 = markerColor1;
                markerSpawnInstance.color2 = markerColor2;
                markerSpawnInstance.radius = markerRadius;
                markerSpawnInstance.alpha = markerOpacity;
                markerSpawnInstance.Spawn();
                markerSpawnInstance.SendUpdate();
                markerSpawnInstance.InvokeRepeating("SendUpdate", 10.0f, 10.0f);
            }

            private void ClearMarker()
            {
                markerSpawnInstance.SafeKill();
                markerSpawnInstance = null;
            }

            #endregion

            #region Loot Crate

            private IEnumerator SpawnHackable()
            {
                yield return CoroutineEx.waitForEndOfFrame;

                crateSpawnInstance = Helpers.CreateEntity<HackableLockedCrate>(CRATE_PREFAB, transform.position + (Vector3.up * 100f), Quaternion.identity);
                crateSpawnInstance.EnableSaving(false);
                crateSpawnInstance.shouldDecay = false;
                crateSpawnInstance.hackSeconds = HackableLockedCrate.requiredHackSeconds - hackSeconds;
                crateSpawnInstance.Spawn();
                
                PostCrateSpawn(crateSpawnInstance);
                EntityCache.CreateEntity(crateSpawnInstance.net.ID, this);
            }

            private void ClearHackable()
            {
                if (currentState == EventState.Complete)
                    return;
                
                crateSpawnInstance.SafeKill();
                crateSpawnInstance = null;
            }
            
            private void PostCrateSpawn(HackableLockedCrate hackableCrate)
            {
                if (hackableCrate.TryGetComponent(out Rigidbody rigidbody))
                    rigidbody.drag = hackableCrateFallDrag;
                
                if (!enableLootTable)
                    return;
                
                hackableCrate.Invoke(() => TryPopulateCrate(hackableCrate), 2f);
            }

            #endregion

            #endregion

            #region Hackable

            public object? CanPopulateCrate()
            {
                return (!enableLootTable || LootTable is not { Count: > 0 }) ? null : (object)false;
            }

            private void TryPopulateCrate(LootContainer container)
            {
                if (LootTable == null || LootTable.Count == 0)
                    return;
                
                List<ItemEntry> entries = Facepunch.Pool.Get<List<ItemEntry>>();

                try
                {
                    if (container != null && !container.IsDestroyed)
                    {
                        container.inventory.onItemAddedRemoved = null;
                        container.inventory.SafeClear();
                        GenerateLootItems(ref entries, Mathf.Clamp(UnityEngine.Random.Range(minLootAmount, maxLootAmount), 1, 24));
                        container.inventory.capacity = entries.Count;
                        
                        foreach (ItemEntry lootItem in entries)
                        {
                            Item? item = lootItem?.CreateItem();
                            if (item != null && !item.MoveToContainer(container.inventory))
                                item.Remove();
                        }
                    }
                }
                finally
                {
                    Facepunch.Pool.FreeUnmanaged(ref entries);
                }
            }

            private void GenerateLootItems(ref List<ItemEntry> items, int maxItemCount)
            {
                List<ItemEntry> entries = Facepunch.Pool.Get<List<ItemEntry>>();

                try
                {
                    entries.AddRange(LootTable);
                    
                    for (int i = entries.Count - 1; i > 0; i--)
                    {
                        int j = UnityEngine.Random.Range(0, i + 1);
                        (entries[i], entries[j]) = (entries[j], entries[i]);
                    }

                    foreach (ItemEntry itemEntry in entries)
                    {
                        if (!items.Contains(itemEntry))
                        {
                            items.Add(itemEntry);
                            
                            if (items.Count >= maxItemCount)
                                break;
                        }
                    }
                }
                finally
                {
                    Facepunch.Pool.FreeUnmanaged(ref entries);
                }
            }

            #endregion

            #region Oxide Hooks

            public object? CanHackCrate(BasePlayer player)
            {
                if (!enableEliminateGuards)
                    return null;

                if (GuardSpawnInstanceCount > 0)
                {
                    Notification.MessagePlayer(player, LangKeys.ELIMINATE_GUARDS);
                    return true;
                }

                return null;
            }

            public void OnGuardKilled(ScientistNPC scientist, BasePlayer player)
            {
                if (currentState != EventState.Running)
                    return;
                
                guardSpawnInstances.Remove(scientist);
                
                timePassed = 0f;

                if (guardSpawnInstances.Count > 0)
                    return;
                
                EventComplete(player);
            }

            public void OnPlaneKilled(CargoPlane plane) => cargoPlaneInstance = null;

            public void OnCrateKilled(LootContainer container) => crateSpawnInstance = null;

            #endregion
        }
        
        private interface IEventSpawnInstance
        {
            void StartSpawning();
        }

        private class EventCargoPlane : MonoBehaviour
        {
            public IEventSpawnInstance EventSpawnInstance;
            public CargoPlane cargoPlane;
            public bool hasDropped;
            
            private void Awake()
            {
                cargoPlane = GetComponent<CargoPlane>();
                cargoPlane.dropped = true;
            }
            
            private void Update()
            {
                if (cargoPlane == null || cargoPlane.IsDestroyed)
                    return;
                
                float time = Mathf.InverseLerp(0.0f, cargoPlane.secondsToTake, cargoPlane.secondsTaken);
                if (!hasDropped && time >= 0.5f)
                {
                    hasDropped = true;
                    
                    if (!EventSpawnInstance.IsUnityNull())
                        EventSpawnInstance.StartSpawning();
                }
                
                if (time < 1.0f)
                    return;
                
                cargoPlane.Kill();
            }
        }
        
        #endregion

        #region Entity Cache

        private static class EntityCache
        {
            private static readonly Dictionary<NetworkableId, GuardedCrateInstance> Entities = new();

            public static void OnUnload() => Entities.Clear();

            public static GuardedCrateInstance? FindEventByEntity(NetworkableId id)
            {
                Entities.TryGetValue(id, out GuardedCrateInstance? instance);
                return instance;
            }
            
            public static bool HasEntity(ulong id)
            {
                foreach (NetworkableId networkableId in Entities.Keys)
                {
                    if (networkableId.Value == id)
                        return true;
                }
                
                return false;
            }
            
            public static bool HasEntity(NetworkableId id) => Entities.ContainsKey(id);

            public static void CreateEntity(NetworkableId id, GuardedCrateInstance component) => Entities.Add(id, component);

            public static void RemoveEntity(NetworkableId id) => Entities.Remove(id);
        }

        #endregion

        #region Notification

        private static class Notification
        {
            private static string GetMessage(string langKey, string? userId = null, params object[] args)
            {
                string message = userId != null ? _getMessage(langKey, userId) : _getMessage(langKey, null);
                return args?.Length > 0 ? string.Format(message, args) : message;
            }

            public static void MessagePlayer(ConsoleSystem.Arg arg, string langKey, params object[] args)
            {
                if (arg == null) return;
                arg.ReplyWith(GetMessage(langKey, null, args));
            }

            public static void MessagePlayer(BasePlayer player, string langKey, params object[] args)
            {
                if (player == null) return;
                player.ChatMessage(GetMessage(langKey, player.UserIDString, args));
            }

            public static void MessagePlayers(string langKey, params object[] args)
            {
                string prefix = _configData.ChatSetting.EnablePrefix ? GetMessage(LangKeys.PREFIX) : string.Empty;
                string message = GetMessage(langKey, null, args);
                if (_configData.ChatSetting.Enabled) ConsoleNetwork.BroadcastToAllClients("chat.add", 2, _configData.ChatSetting.ChatIcon, prefix + message);
                if (_configData.ToastSetting.Enabled) ConsoleNetwork.BroadcastToAllClients("gametip.showtoast_translated", (int)_configData.ToastSetting.Style, null, message, false);
                if (_configData.GuiaSetting.Enable) _TrySendGuiNotification(message, _configData.GuiaSetting.BackgroundColor, _configData.GuiaSetting.TextColor, null, 0.03f);
            }
        }

        #endregion

        #region Console Command

        private void EventConsoleCommands(ConsoleSystem.Arg arg)
        {
            if (arg == null || !arg.IsRcon)
                return;

            if (!arg.HasArgs(2))
            {
                DisplayHelpText(arg.Player());
                return;
            }

            string option = arg.GetString(0);
            if (option.Equals("start", StringComparison.OrdinalIgnoreCase))
            {
                bool started = TryStartEvent(arg.Args?.Length > 1 ? string.Join(" ", arg.Args.Skip(1)) : string.Empty);
                Notification.MessagePlayer(arg, (started ? LangKeys.START_EVENT : LangKeys.FAILED_TO_START_EVENT));
                return;
            }

            if (option.Equals("stop", StringComparison.OrdinalIgnoreCase))
            {
                EventManager.CleanupInstances();
                Notification.MessagePlayer(arg, LangKeys.CLEAR_EVENTS);
                return;
            }

            if (option.Equals("position", StringComparison.OrdinalIgnoreCase))
            {
                if (!arg.HasArgs(3))
                {
                    DisplayHelpText(arg.Player());
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(arg.GetString(1));
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(arg, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                Vector3 position = arg.GetVector3(2);
                if (position == Vector3.zero)
                {
                    Notification.MessagePlayer(arg, LangKeys.EVENT_POSITION_INVALID);
                    return;
                }

                if (EventManager.HasIntersectingEvent(position))
                {
                    Notification.MessagePlayer(arg, LangKeys.EVENT_INTERSECTING);
                    return;
                }

                GuardedCrateInstance.CreateInstance(eventEntry, position);
                return;
            }

            DisplayHelpText(arg.Player());
        }

        #endregion

        #region Chat Command

        private void EventCommandCommands(BasePlayer player, string command, string[] args)
        {
            if (!permission.UserHasPermission(player.UserIDString, PERM_USE))
            {
                Notification.MessagePlayer(player, LangKeys.NO_PERMISSION);
                return;
            }

            if (args.Length < 1)
            {
                DisplayHelpText(player);
                return;
            }

            string option = args[0];
            if (option.Equals("start", StringComparison.OrdinalIgnoreCase))
            {
                bool started = TryStartEvent(string.Join(" ", args.Skip(1).ToArray()));
                Notification.MessagePlayer(player, (started ? LangKeys.START_EVENT : LangKeys.FAILED_TO_START_EVENT));
                return;
            }

            if (option.Equals("stop", StringComparison.OrdinalIgnoreCase))
            {
                EventManager.CleanupInstances();
                Notification.MessagePlayer(player, LangKeys.CLEAR_EVENTS);
                return;
            }

            if (option.Equals("here", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 2)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                GuardedCrateInstance.CreateInstance(eventEntry, player.transform.position);
                return;
            }

            if (option.Equals("position", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 3)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                Vector3 position = args[2].ToVector3();
                if (position == Vector3.zero)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_POSITION_INVALID);
                    return;
                }

                if (EventManager.HasIntersectingEvent(position))
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_INTERSECTING);
                    return;
                }

                GuardedCrateInstance.CreateInstance(eventEntry, position);
                return;
            }

            if (option.Equals("loot", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 2)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                if (player.inventory == null)
                    return;

                eventEntry.LootTable.Clear();
                eventEntry.LootTable.AddRange(ItemEntry.SaveItems(player.inventory.containerMain));
                eventEntry.LootTable.AddRange(ItemEntry.SaveItems(player.inventory.containerBelt));
                eventEntry.EnableLootTable = eventEntry.LootTable.Count > 0;
                SaveData();

                Notification.MessagePlayer(player, LangKeys.EVENT_UPDATED);
                return;
            }

            if (option.Equals("amount", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 3)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                int.TryParse(args[2], out int amount);

                if (amount < 1)
                {
                    Notification.MessagePlayer(player, LangKeys.INVALID_GUARD_AMOUNT);
                    return;
                }

                eventEntry.GuardAmount = amount;
                SaveData();

                Notification.MessagePlayer(player, LangKeys.EVENT_UPDATED);
                return;
            }

            if (option.Equals("drag", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 3)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                float.TryParse(args[2], out float amount);
                eventEntry.HackableCrateFallDrag = amount;
                SaveData();

                Notification.MessagePlayer(player, LangKeys.EVENT_UPDATED);
                return;
            }

            if (option.Equals("loadout", StringComparison.OrdinalIgnoreCase))
            {
                if (args.Length < 2)
                {
                    DisplayHelpText(player);
                    return;
                }

                EventEntry eventEntry = _storedData.FindEventByName(args[1]);
                if (eventEntry == null)
                {
                    Notification.MessagePlayer(player, LangKeys.EVENT_NOT_FOUND);
                    return;
                }

                if (player.inventory == null)
                    return;

                eventEntry.GuardEntry.BeltItems.Clear();
                eventEntry.GuardEntry.WearItems.Clear();
                eventEntry.GuardEntry.BeltItems = GuardEntry.BeltEntry.SaveItems(player.inventory.containerBelt);
                eventEntry.GuardEntry.WearItems = GuardEntry.WearEntry.SaveItems(player.inventory.containerWear);
                eventEntry.GuardEntry.CacheConfig();
                SaveData();

                Notification.MessagePlayer(player, LangKeys.EVENT_UPDATED);
                return;
            }

            DisplayHelpText(player);
        }

        private void DisplayHelpText(BasePlayer player)
        {
            StringBuilder sb = Facepunch.Pool.Get<StringBuilder>();

            try
            {
                sb.Clear();
                sb.AppendFormat(lang.GetMessage(LangKeys.PREFIX, this, player.UserIDString))
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_START_EVENT, this, player.UserIDString), _configData.CommandName, string.Join("|", _storedData.EventNames))
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_STOP_EVENT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_HERE_EVENT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_POSITION_EVENT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_LOOT_EVENT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_DRAG_EVENT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_GUARD_AMOUNT, this, player.UserIDString), _configData.CommandName)
                    .AppendFormat(lang.GetMessage(LangKeys.HELP_GUARD_LOADOUT, this, player.UserIDString), _configData.CommandName);
                
                Notification.MessagePlayer(player, sb.ToString());
            }
            finally
            {
                sb.Clear();
                Facepunch.Pool.FreeUnmanaged(ref sb);
            }
        }

        #endregion

        #region Hook Subscribing

        private readonly HashSet<string> _hooks = new()
        {
            "OnEntityDeath",
            "OnEntityKill",
            "CanHackCrate"
        };

        private void SubscribeToHooks(int count)
        {
            if (count == 1)
            {
                foreach (string hook in _hooks)
                    Subscribe(hook);

                return;
            }

            if (count == 0)
            {
                foreach (string hook in _hooks)
                    Unsubscribe(hook);
            }
        }

        #endregion
        
        #region API Hooks

        private bool API_IsGuardedCrateCargoPlane(CargoPlane entity)
        {
            return EntityCache.FindEventByEntity(entity.net?.ID ?? default) != null;
        }

        private bool API_IsGuardedCrateEntity(BaseEntity entity)
        {
            return EntityCache.FindEventByEntity(entity.net?.ID ?? default) != null;
        }

        #endregion

        #region 3rd Party Hooks
        
        #region Alpha Loot

        private object? CanPopulateLoot(HackableLockedCrate crate)
        {
            return EntityCache.FindEventByEntity(crate.net?.ID ?? default)
                ?.CanPopulateCrate();
        }

        #endregion

        #region Better Loot

        private object? ShouldBLPopulate_Container(ulong lootContainerId)
        {
            return EntityCache.HasEntity(lootContainerId) ? _objTrue : null;
        }

        #endregion

        #region Rust Edit

        private object? OnNpcRustEdit(ScientistNPC npc)
        {
            return EntityCache.FindEventByEntity(npc.net?.ID ?? default) != null ? _objTrue : null;
        }

        #endregion
        
        #endregion
        
        #region Json Converters

        private class EnumCollectionStringConverter<TEnum> : JsonConverter where TEnum : struct, Enum
        {
            public override bool CanConvert(Type objectType) => objectType == typeof(List<TEnum>) || objectType == typeof(TEnum[]);
            
            public override object? ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
            {
                if (reader.TokenType == JsonToken.Null)
                    return null;

                List<string> strings = serializer.Deserialize<List<string>>(reader);
                if (strings is null)
                    return null;

                int count = strings.Count;
                if (objectType == typeof(TEnum[]))
                {
                    TEnum[] result = new TEnum[count];
                    int idx = 0;
                    for (int i = 0; i < count; i++)
                    {
                        if (Enum.TryParse(strings[i], ignoreCase: true, out TEnum parsed))
                            result[idx++] = parsed;
                    }
                    
                    if (idx == count)
                        return result;
                    
                    TEnum[] trimmed = new TEnum[idx];
                    Array.Copy(result, trimmed, idx);
                    return trimmed;
                }

                if (objectType == typeof(List<TEnum>))
                {
                    List<TEnum> result = new List<TEnum>(count);
                    for (int i = 0; i < count; i++)
                    {
                        if (Enum.TryParse(strings[i], ignoreCase: true, out TEnum parsed))
                            result.Add(parsed);
                    }
                    
                    return result;
                }

                throw new JsonSerializationException($"Cannot convert to '{objectType}'.");
            }

            public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
            {
                if (value is null)
                {
                    writer.WriteStartArray();
                    writer.WriteEndArray();
                    return;
                }

                if (value is IEnumerable<TEnum> collection)
                {
                    writer.WriteStartArray();
                    foreach (TEnum item in collection)
                        writer.WriteValue(item.ToString());
                    writer.WriteEndArray();
                    return;
                }

                throw new JsonSerializationException($"Unexpected value type: {value.GetType()}");
            }
        }
        
        #endregion
    }
}

namespace GuardedCrateExt
{
    internal static class Helpers
    {
        public static T CreateObjectWithComponent<T>(Vector3 position, Quaternion rotation, string name) where T : MonoBehaviour
        {
            GameObject gameObject = new GameObject(name);
            gameObject.transform.SetPositionAndRotation(position, rotation);
            return gameObject.AddComponent<T>();
        }
        
        public static T CreateEntity<T>(string prefab, Vector3 position, Quaternion rotation) where T : BaseEntity
        {
            T baseEntity = (T)GameManager.server.CreateEntity(prefab, position, rotation);
            baseEntity.enableSaving = false;
            return baseEntity;
        }
        
        public static Color GetColor(string hex)
        {
            return ColorUtility.TryParseHtmlString(hex, out Color color) ? color : Color.yellow;
        }
    }

    internal static class ExtensionMethods
    {
        public static bool IsPluginReady(this Plugin plugin) => plugin != null && plugin.IsLoaded;
        
        public static Vector3 GetPointAround(this Vector3 origin, int layers, float radius, float angle)
        {
            Vector3 pointAround = Vector3.zero;
            pointAround.x = origin.x + radius * Mathf.Sin(angle * Mathf.Deg2Rad);
            pointAround.z = origin.z + radius * Mathf.Cos(angle * Mathf.Deg2Rad);
            pointAround.y = TerrainMeta.HeightMap.GetHeight(origin) + 100f;
            
            if (Physics.Raycast(pointAround, Vector3.down, out RaycastHit hit, 200f, layers, QueryTriggerInteraction.Ignore))
                pointAround.y = hit.point.y;
            
            pointAround.y += 0.25f;
            return pointAround;
        }
        
        public static void SafeKill(this BaseEntity entity)
        {
            if (entity != null && !entity.IsDestroyed)
                entity.Kill();
        }

        public static void SafeClear(this ItemContainer container)
        {
            for (int i = container.itemList.Count - 1; i >= 0; i--)
            {
                Item item = container.itemList[i];
                item.RemoveFromContainer();
                item.Remove();
            }
        }
        
        public static string ToStringTime(this float seconds) 
        {
            TimeSpan timeSpan = TimeSpan.FromSeconds(seconds);
            if (timeSpan.Days >= 1) return $"{timeSpan.Days} day{(timeSpan.Days != 1 ? "(s)" : "")}";
            if (timeSpan.Hours >= 1) return $"{timeSpan.Hours} hour{(timeSpan.Hours != 1 ? "(s)" : "")}";
            return timeSpan.Minutes >= 1 ? $"{timeSpan.Minutes} minute{(timeSpan.Minutes != 1 ? "(s)" : "")}" : $"{timeSpan.Seconds} second{(timeSpan.Seconds == 1 ? "(s)" : "")}";
        }
    }
}