using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Oxide.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Facepunch;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Spawn Heli", "SpooksAU", "3.2.0")]
    [Description("Allows players to spawn helicopters")]
    internal class SpawnHeli : CovalencePlugin
    {
        #region Fields

        private const string LegacyPluginName = "SpawnMini";
        private const string LegacyPermissionPrefix = "spawnmini.";

        private const string PermissionMinicopter = "minicopter";
        private const string PermissionScrapHeli = "scraptransport";
        private const string PermissionAttackHeli = "attackhelicopter";

        private const int SpawnPointLayerMask = Rust.Layers.Solid | Rust.Layers.Mask.Water;
        private const int SpaceCheckLayerMask = Rust.Layers.Solid;

        private const float VerticalSpawnOffset = 1;

        private SaveData _data;
        private Configuration _config;
        private readonly VehicleInfoManager _vehicleInfoManager;

        private readonly object True = true;
        private readonly object False = false;

        public SpawnHeli()
        {
            _vehicleInfoManager = new VehicleInfoManager(this);
        }

        #endregion

        #region Hooks

        private void Init()
        {
            _data = SaveData.Load();
            _config.Init();

            foreach (var perm in VehicleInfo.All.GetAllPermissions())
            {
                permission.RegisterPermission(perm, this);
            }

            _vehicleInfoManager.Init();

            if (!_vehicleInfoManager.AnyOwnerOnly)
            {
                Unsubscribe(nameof(CanMountEntity));
            }

            if (!_vehicleInfoManager.AnyDespawnOnDisconnect)
            {
                Unsubscribe(nameof(OnPlayerDisconnected));
                Unsubscribe(nameof(OnEntityDismounted));
            }

            if (!_vehicleInfoManager.AnyInstantTakeoff)
            {
                Unsubscribe(nameof(OnEngineStarted));
            }
        }

        private void OnServerInitialized()
        {
            if (plugins.PluginManager.GetPlugin(LegacyPluginName) != null)
            {
                LogWarning($"Detected conflicting plugin {LegacyPluginName}. Please remove that plugin to avoid issues.");
            }

            _vehicleInfoManager.OnServerInitialized();

            foreach (var networkable in BaseNetworkable.serverEntities)
            {
                var heli = networkable as PlayerHelicopter;
                if (heli == null)
                    continue;

                var vehicleInfo = _vehicleInfoManager.GetVehicleInfo(heli);
                if (vehicleInfo == null || !vehicleInfo.Data.HasVehicle(heli))
                    continue;

                SetupHeli(heli);

                if (heli.OwnerID != 0 && HasPermission(heli.OwnerID.ToString(), vehicleInfo.Permissions.UnlimitedFuel, VehicleInfo.All.UnlimitedFuel))
                {
                    EnableUnlimitedFuel(heli);
                }
            }

            _data.Clean();
        }

        private void Unload()
        {
            _data.SaveIfChanged();
        }

        private void OnServerSave()
        {
            _data.SaveIfChanged();
        }

        private void OnNewSave()
        {
            _data.Reset();
            _data.SaveIfChanged();
        }

        private void OnEntityKill(PlayerHelicopter heli)
        {
            var vehicleInfo = _vehicleInfoManager.GetVehicleInfo(heli);
            if (vehicleInfo == null)
                return;

            var ownerIdString = heli.OwnerID.ToString();
            var playerVehicle = vehicleInfo.Data.GetVehicle(ownerIdString);
            if (playerVehicle == null || playerVehicle != heli)
                return;

            _data.UnregisterVehicle(vehicleInfo, ownerIdString);

            var basePlayer = BasePlayer.FindByID(heli.OwnerID);
            if (basePlayer != null)
            {
                basePlayer.ChatMessage(GetMessage(basePlayer.UserIDString, vehicleInfo.Messages.Destroyed));
            }
        }

        private object OnEntityTakeDamage(PlayerHelicopter heli, HitInfo info)
        {
            if (heli == null || info == null || heli.OwnerID == 0)
                return null;

            if (!IsPlayerVehicle(heli, out var vehicleInfo))
                return null;

            if (info.damageTypes.Has(Rust.DamageType.Decay)
                && HasPermission(heli.OwnerID.ToString(), vehicleInfo.Permissions.NoDecay, VehicleInfo.All.NoDecay))
                return True;

            return null;
        }

        private object CanMountEntity(BasePlayer player, BaseVehicleMountPoint mountPoint)
        {
            if (player == null || mountPoint == null)
                return null;

            var heli = mountPoint.GetParentEntity() as PlayerHelicopter;
            if (heli == null
                || heli.OwnerID == 0
                || !IsPlayerVehicle(heli, out var vehicleInfo)
                || !vehicleInfo.Config.OnlyOwnerAndTeamCanMount)
                return null;

            // Vehicle owner is allowed to mount.
            if (heli.OwnerID == player.userID)
                return null;

            // Team members are allowed to mount.
            if (player.Team != null && player.Team.members.Contains(heli.OwnerID))
                return null;

            player.ChatMessage(GetMessage(player.UserIDString, LangEntry.ErrorCannotMount));
            return False;
        }

        private void OnPlayerDisconnected(BasePlayer player)
        {
            if (player == null)
                return;

            if (_config.Minicopter.DespawnOnDisconnect)
            {
                var heli = _data.Minicopter.GetVehicle(player.UserIDString);
                if (heli != null)
                {
                    ScheduleDespawnVehicleIfUnmounted(heli);
                }
            }

            if (_config.ScrapTransportHelicopter.DespawnOnDisconnect)
            {
                var heli = _data.ScrapTransportHelicopter.GetVehicle(player.UserIDString);
                if (heli != null)
                {
                    ScheduleDespawnVehicleIfUnmounted(heli);
                }
            }

            if (_config.AttackHelicopter.DespawnOnDisconnect)
            {
                var heli = _data.AttackHelicopter.GetVehicle(player.UserIDString);
                if (heli != null)
                {
                    ScheduleDespawnVehicleIfUnmounted(heli);
                }
            }
        }

        private void ScheduleDespawnVehicleIfUnmounted(PlayerHelicopter heli)
        {
            NextTick(() =>
            {
                // Despawn vehicle when the owner disconnects.
                // If mounted, we will despawn it later when all players dismount.
                if (heli == null || heli.AnyMounted())
                    return;

                heli.Kill();
            });
        }

        private void OnEntityDismounted(BaseVehicleSeat seat)
        {
            if (seat == null)
                return;

            var heli = seat.GetParentEntity() as PlayerHelicopter;
            if (heli == null
                || !heli.AnyMounted()
                || !IsPlayerVehicle(heli, out var vehicleInfo)
                || !vehicleInfo.Config.DespawnOnDisconnect)
                return;

            // Despawn minicopter when fully dismounted, if the owner player has disconnected.
            var ownerPlayer = BasePlayer.FindByID(heli.OwnerID);
            if (ownerPlayer != null && ownerPlayer.IsConnected)
                return;

            heli.Kill();
        }

        private void CanLootEntity(BasePlayer player, StorageContainer container)
        {
            if (container == null || !container.IsLocked())
                return;

            var heli = container.GetParentEntity() as PlayerHelicopter;
            if (heli == null || !IsPlayerVehicle(heli, out var vehicleInfo))
                return;

            if (!HasPermission(heli.OwnerID.ToString(), vehicleInfo.Permissions.UnlimitedFuel, VehicleInfo.All.UnlimitedFuel))
                return;

            player.ChatMessage(GetMessage(player.UserIDString, LangEntry.ErrorUnlimitedFuel));
        }

        private void OnEngineStarted(PlayerHelicopter heli, BasePlayer player)
        {
            if (!heli.engineController.IsStarting)
                return;

            if (!IsPlayerVehicle(heli, out var vehicleInfo))
                return;

            if (!vehicleInfo.Config.InstantTakeoff.Enabled)
                return;

            if (vehicleInfo.Config.InstantTakeoff.RequirePermission
                && !HasPermission(player, vehicleInfo.Permissions.InstantTakeoff, VehicleInfo.All.InstantTakeoff))
                return;

            heli.CancelInvoke(heli.engineController.FinishStartingEngine);
            heli.engineController.FinishStartingEngine();
        }

        #endregion

        #region API

        [HookMethod(nameof(API_GetMinicopter))]
        public PlayerHelicopter API_GetMinicopter(BasePlayer player)
        {
            return FindPlayerVehicle(_vehicleInfoManager.Minicopter, player);
        }

        [HookMethod(nameof(API_GetScrapTransportHelicopter))]
        public PlayerHelicopter API_GetScrapTransportHelicopter(BasePlayer player)
        {
            return FindPlayerVehicle(_vehicleInfoManager.ScrapTransportHelicopter, player);
        }

        [HookMethod(nameof(API_GetAttackHelicopter))]
        public PlayerHelicopter API_GetAttackHelicopter(BasePlayer player)
        {
            return FindPlayerVehicle(_vehicleInfoManager.AttackHelicopter, player);
        }

        [HookMethod(nameof(API_SpawnMinicopter))]
        public PlayerHelicopter API_SpawnMinicopter(BasePlayer player, Dictionary<string, object> rawOptions)
        {
            return SpawnHeliForApi(_vehicleInfoManager.Minicopter, player, rawOptions);
        }

        [HookMethod(nameof(API_SpawnScrapTransportHelicopter))]
        public PlayerHelicopter API_SpawnScrapTransportHelicopter(BasePlayer player, Dictionary<string, object> rawOptions)
        {
            return SpawnHeliForApi(_vehicleInfoManager.ScrapTransportHelicopter, player, rawOptions);
        }

        [HookMethod(nameof(API_SpawnAttackHelicopter))]
        public PlayerHelicopter API_SpawnAttackHelicopter(BasePlayer player, Dictionary<string, object> rawOptions)
        {
            return SpawnHeliForApi(_vehicleInfoManager.AttackHelicopter, player, rawOptions);
        }

        private PlayerHelicopter SpawnHeliForApi(VehicleInfo vehicleInfo, BasePlayer player, Dictionary<string, object> rawOptions)
        {
            SpawnOptions parsedOptions;
            if (rawOptions == null)
            {
                parsedOptions = default;
            }
            else if (!SpawnOptions.TryParseApiOptions(rawOptions, out parsedOptions))
            {
                return null;
            }

            return AttemptSpawnOrFetchHeli(vehicleInfo, player.IPlayer, player, parsedOptions);
        }

        private struct SpawnOptions
        {
            private static readonly string FieldPosition = "Position";
            private static readonly string FieldRotation = "Rotation";
            private static readonly string FieldCheckHooks = "CheckHooks";
            private static readonly string FieldCheckCooldown = "CheckCooldown";
            private static readonly string FieldCheckBuildingBlocked = "CheckBuildingBlocked";
            private static readonly string FieldCheckSpace = "CheckSpace";
            private static readonly string FieldAutoMount = "AutoMount";
            private static readonly string FieldStartCooldown = "StartCooldown";
            private static readonly string FieldAutoRepair = "AutoRepair";
            private static readonly string FieldAutoFetch = "AutoFetch";
            private static readonly string FieldEnforceHelicopterLimit = "EnforceHelicopterLimit";
            private static readonly string FieldAutoDespawnOtherHelicopterTypes = "AutoDespawnOtherHelicopterTypes";
            private static readonly string FieldAllowWhileOccupied = "AllowWhileOccupied";
            private static readonly string FieldMaxFetchDistance = "MaxFetchDistance";

            private static readonly HashSet<string> KnownFields = new()
            {
                FieldPosition, FieldRotation, FieldCheckHooks, FieldCheckCooldown,
                FieldCheckBuildingBlocked, FieldCheckSpace, FieldAutoMount, FieldStartCooldown,
                FieldAutoRepair, FieldAutoFetch, FieldEnforceHelicopterLimit,
                FieldAllowWhileOccupied, FieldMaxFetchDistance,
            };

            public static bool TryParseApiOptions(Dictionary<string, object> rawOptions, out SpawnOptions parsedOptions)
            {
                parsedOptions = new SpawnOptions();
                if (!TryGetOption(rawOptions, FieldPosition, out parsedOptions.Position)
                    || !TryGetOption(rawOptions, FieldRotation, out parsedOptions.Rotation)
                    || !TryGetOption(rawOptions, FieldCheckHooks, out parsedOptions.CheckHooks)
                    || !TryGetOption(rawOptions, FieldCheckCooldown, out parsedOptions.CheckCooldown)
                    || !TryGetOption(rawOptions, FieldCheckBuildingBlocked, out parsedOptions.CheckBuildingBlocked)
                    || !TryGetOption(rawOptions, FieldCheckSpace, out parsedOptions.CheckSpace)
                    || !TryGetOption(rawOptions, FieldAutoMount, out parsedOptions.AutoMount)
                    || !TryGetOption(rawOptions, FieldStartCooldown, out parsedOptions.StartCooldown)
                    || !TryGetOption(rawOptions, FieldAutoRepair, out parsedOptions.AutoRepair)
                    || !TryGetOption(rawOptions, FieldAutoFetch, out parsedOptions.AutoFetch)
                    || !TryGetOption(rawOptions, FieldEnforceHelicopterLimit, out parsedOptions.EnforceHelicopterLimit)
                    || !TryGetOption(rawOptions, FieldAutoDespawnOtherHelicopterTypes, out parsedOptions.AutoDespawnOtherHelicopterTypes)
                    || !TryGetOption(rawOptions, FieldAllowWhileOccupied, out parsedOptions.AllowWhileOccupied)
                    || !TryGetOption(rawOptions, FieldMaxFetchDistance, out parsedOptions.MaxFetchDistance))
                    return false;

                foreach (var key in rawOptions.Keys)
                {
                    if (!KnownFields.Contains(key))
                    {
                        LogWarning($"Unknown option '{key}' in API options. Known options are: {string.Join(", ", KnownFields)}");
                    }
                }

                return true;
            }

            private static bool TryGetOption<T>(Dictionary<string, object> dict, string key, out T result)
            {
                result = default;

                // Missing fields are OK since all are optional.
                if (!dict.TryGetValue(key, out var value))
                    return true;

                if (value is not T valueOfType)
                {
                    LogError($"Invalid type for option '{key}': expected {typeof(T).Name}, got {value.GetType().Name}");
                    return false;
                }

                result = valueOfType;
                return true;
            }

            public Vector3? Position;
            public Quaternion? Rotation;
            public bool? CheckHooks;
            public bool? CheckCooldown;
            public bool? CheckSpace;
            public bool? CheckBuildingBlocked;
            public bool? AutoMount;
            public bool? StartCooldown;
            public bool? AutoRepair;
            public bool? AutoFetch;
            public bool? EnforceHelicopterLimit;
            public bool? AutoDespawnOtherHelicopterTypes;
            public bool? AllowWhileOccupied;
            public float? MaxFetchDistance;
        }

        #endregion

        #region Commands

        private void CommandSpawnMinicopter(IPlayer player, string cmd, string[] args)
        {
            SpawnCommandInternal(_vehicleInfoManager.Minicopter, player, cmd, args);
        }

        private void CommandSpawnScrapTransportHelicopter(IPlayer player, string cmd, string[] args)
        {
            SpawnCommandInternal(_vehicleInfoManager.ScrapTransportHelicopter, player, cmd, args);
        }

        private void CommandSpawnAttackHelicopter(IPlayer player, string cmd, string[] args)
        {
            SpawnCommandInternal(_vehicleInfoManager.AttackHelicopter, player, cmd, args);
        }

        private PlayerHelicopter AttemptSpawnOrFetchHeli(VehicleInfo vehicleInfo, IPlayer player, BasePlayer basePlayer, SpawnOptions options = default)
        {
            var heli = FindPlayerVehicle(vehicleInfo, basePlayer);
            if (heli != null)
            {
                if ((options.AutoFetch ?? vehicleInfo.Config.AutoFetch)
                    && HasPermission(player, vehicleInfo.Permissions.Fetch, VehicleInfo.All.Fetch))
                {
                    if (TryFetchVehicle(vehicleInfo, player, basePlayer, heli, options))
                        return heli;
                }
                else
                {
                    player.Reply(GetMessage(player.Id, vehicleInfo.Messages.AlreadySpawned));
                }

                return null;
            }

            if (options.EnforceHelicopterLimit ?? _config.LimitPlayersToOneHelicopterType)
            {
                foreach (var otherVehicleInfo in _vehicleInfoManager.AllVehicles)
                {
                    if (otherVehicleInfo == vehicleInfo)
                        continue;

                    var otherVehicle = otherVehicleInfo.Data.GetVehicle(player.Id);
                    if (otherVehicle == null || otherVehicle.IsDestroyed)
                        continue;

                    if (!TryDespawnConflictingHeli(otherVehicleInfo, otherVehicle, basePlayer, options))
                    {
                        player.Reply(GetMessage(player.Id, LangEntry.ErrorConflictingHeli));
                        return null;
                    }

                    otherVehicle.Kill();
                }
            }

            if (options.CheckCooldown != false
                && !VerifyOffCooldown(vehicleInfo, basePlayer, vehicleInfo.Config.SpawnCooldowns, vehicleInfo.Data.SpawnCooldowns))
                return null;

            if ((options.CheckBuildingBlocked ?? !vehicleInfo.Config.CanSpawnBuildingBlocked)
                && !VerifyNotBuildingBlocked(player, basePlayer))
                return null;

            if (options.CheckHooks != false
                && SpawnWasBlocked(vehicleInfo, basePlayer))
                return null;

            if (!VerifyValidSpawnOrFetchPosition(vehicleInfo, basePlayer, out var position, out var rotation, options))
                return null;

            heli = SpawnVehicle(vehicleInfo, basePlayer, position, rotation, options);
            if (heli == null)
                return null;

            if (options.StartCooldown != false
                && !HasPermission(basePlayer, vehicleInfo.Permissions.NoCooldown, VehicleInfo.All.NoCooldown))
            {
                _data.StartSpawnCooldown(vehicleInfo, basePlayer);
            }

            return heli;
        }

        private void SpawnCommandInternal(VehicleInfo vehicleInfo, IPlayer player, string command, string[] args)
        {
            if (vehicleInfo == null
                || !VerifyPlayer(player, out var basePlayer)
                || !VerifyPermission(player, vehicleInfo.Permissions.Spawn, VehicleInfo.All.Spawn))
                return;

            AttemptSpawnOrFetchHeli(vehicleInfo, player, basePlayer);
        }

        private void CommandFetchMinicopter(IPlayer player, string cmd, string[] args)
        {
            FetchCommandInternal(_vehicleInfoManager.Minicopter, player, cmd, args);
        }

        private void CommandFetchScrapTransportHelicopter(IPlayer player, string cmd, string[] args)
        {
            FetchCommandInternal(_vehicleInfoManager.ScrapTransportHelicopter, player, cmd, args);
        }

        private void CommandFetchAttackHelicopter(IPlayer player, string cmd, string[] args)
        {
            FetchCommandInternal(_vehicleInfoManager.AttackHelicopter, player, cmd, args);
        }

        private void FetchCommandInternal(VehicleInfo vehicleInfo, IPlayer player, string command, string[] args)
        {
            if (vehicleInfo == null
                || !VerifyPlayer(player, out var basePlayer)
                || !VerifyPermission(player, vehicleInfo.Permissions.Fetch, VehicleInfo.All.Fetch)
                || !VerifyVehicleExists(player, basePlayer, vehicleInfo, out var heli))
                return;

            TryFetchVehicle(vehicleInfo, player, basePlayer, heli);
        }

        private void CommandDespawnMinicopter(IPlayer player, string cmd, string[] args)
        {
            DespawnCommandInternal(_vehicleInfoManager.Minicopter, player, cmd, args);
        }

        private void CommandDespawnScrapTransportHelicopter(IPlayer player, string cmd, string[] args)
        {
            DespawnCommandInternal(_vehicleInfoManager.ScrapTransportHelicopter, player, cmd, args);
        }

        private void CommandDespawnAttackHelicopter(IPlayer player, string cmd, string[] args)
        {
            DespawnCommandInternal(_vehicleInfoManager.AttackHelicopter, player, cmd, args);
        }

        private void DespawnCommandInternal(VehicleInfo vehicleInfo, IPlayer player, string command, string[] args)
        {
            if (vehicleInfo == null
                || !VerifyPlayer(player, out var basePlayer)
                || !VerifyPermission(player, vehicleInfo.Permissions.Despawn, VehicleInfo.All.Despawn)
                || !VerifyVehicleExists(player, basePlayer, vehicleInfo, out var heli))
                return;

            if (!vehicleInfo.Config.CanDespawnWhileOccupied && IsHeliOccupied(heli))
            {
                player.Reply(GetMessage(player.Id, LangEntry.ErrorHeliOccupied));
                return;
            }

            if (!VerifyVehicleWithinDistance(player, basePlayer, heli, vehicleInfo.Config.MaxDespawnDistance)
                || DespawnWasBlocked(vehicleInfo, basePlayer, heli))
                return;

            heli.Kill();
        }

        // Old command for backwards compatibility.
        [Command("spawnmini.give")]
        private void CommandGiveMinicopter(IPlayer player, string cmd, string[] args)
        {
            GiveCommandInternal(_vehicleInfoManager.Minicopter, player, cmd, args);
        }

        private void CommandGiveScrapTransportHelicopter(IPlayer player, string cmd, string[] args)
        {
            GiveCommandInternal(_vehicleInfoManager.ScrapTransportHelicopter, player, cmd, args);
        }

        private void CommandGiveAttackHelicopter(IPlayer player, string cmd, string[] args)
        {
            GiveCommandInternal(_vehicleInfoManager.AttackHelicopter, player, cmd, args);
        }

        private void GiveCommandInternal(VehicleInfo vehicleInfo, IPlayer player, string cmd, string[] args)
        {
            if (!player.IsServer)
                return;

            if (args.Length < 1)
            {
                PrintError($"Syntax: {cmd} <name or steamid>");
                return;
            }

            var recipientPlayer = BasePlayer.Find(args[0]);
            if (recipientPlayer == null)
            {
                PrintError($"{cmd}: No player found matching '{args[0]}'");
                return;
            }

            if (args.Length > 1)
            {
                if (args.Length < 4 ||
                    !float.TryParse(args[1], out var x) ||
                    !float.TryParse(args[2], out var y) ||
                    !float.TryParse(args[3], out var z))
                {
                    Puts($"Syntax: {cmd} <name or steamid> <x> <y> <z>");
                    return;
                }

                GiveVehicle(vehicleInfo, recipientPlayer, new Vector3(x, y, z));
                return;
            }

            GiveVehicle(vehicleInfo, recipientPlayer);
        }

        #endregion

        #region Helpers/Functions

        private static class StringUtils
        {
            public static string StripPrefix(string subject, string prefix)
            {
                return subject.StartsWith(prefix) ? subject[prefix.Length..] : subject;
            }

            public static string StripPrefixes(string subject, params string[] prefixes)
            {
                foreach (var prefix in prefixes)
                {
                    subject = StripPrefix(subject, prefix);
                }

                return subject;
            }
        }

        private static class NetworkUtils
        {
            public static void SendUpdateImmediateRecursive(BaseEntity entity)
            {
                entity.SendNetworkUpdateImmediate();

                foreach (var child in entity.children)
                {
                    SendUpdateImmediateRecursive(child);
                }
            }
        }

        private static class Ddraw
        {
            public static void Sphere(BasePlayer player, Vector3 origin, float radius, Color color, float duration) =>
                player.SendConsoleCommand("ddraw.sphere", duration, color, origin, radius);

            public static void Line(BasePlayer player, Vector3 origin, Vector3 target, Color color, float duration) =>
                player.SendConsoleCommand("ddraw.line", duration, color, origin, target);

            public static void Box(BasePlayer player, Vector3 center, Quaternion rotation, Vector3 extents, Color color, float duration)
            {
                var sphereRadius = 0.1f;

                var forwardUpperLeft = center + rotation * extents.WithX(-extents.x);
                var forwardUpperRight = center + rotation * extents;
                var forwardLowerLeft = center + rotation * extents.WithX(-extents.x).WithY(-extents.y);
                var forwardLowerRight = center + rotation * extents.WithY(-extents.y);

                var backLowerRight = center + rotation * -extents.WithX(-extents.x);
                var backLowerLeft = center + rotation * -extents;
                var backUpperRight = center + rotation * -extents.WithX(-extents.x).WithY(-extents.y);
                var backUpperLeft = center + rotation * -extents.WithY(-extents.y);

                Sphere(player, forwardUpperLeft, sphereRadius, color, duration);
                Sphere(player, forwardUpperRight, sphereRadius, color, duration);
                Sphere(player, forwardLowerLeft, sphereRadius, color, duration);
                Sphere(player, forwardLowerRight, sphereRadius, color, duration);

                Sphere(player, backLowerRight, sphereRadius, color, duration);
                Sphere(player, backLowerLeft, sphereRadius, color, duration);
                Sphere(player, backUpperRight, sphereRadius, color, duration);
                Sphere(player, backUpperLeft, sphereRadius, color, duration);

                Line(player, forwardUpperLeft, forwardUpperRight, color, duration);
                Line(player, forwardLowerLeft, forwardLowerRight, color, duration);
                Line(player, forwardUpperLeft, forwardLowerLeft, color, duration);
                Line(player, forwardUpperRight, forwardLowerRight, color, duration);

                Line(player, backUpperLeft, backUpperRight, color, duration);
                Line(player, backLowerLeft, backLowerRight, color, duration);
                Line(player, backUpperLeft, backLowerLeft, color, duration);
                Line(player, backUpperRight, backLowerRight, color, duration);

                Line(player, forwardUpperLeft, backUpperLeft, color, duration);
                Line(player, forwardLowerLeft, backLowerLeft, color, duration);
                Line(player, forwardUpperRight, backUpperRight, color, duration);
                Line(player, forwardLowerRight, backLowerRight, color, duration);
            }

            public static void Box(BasePlayer player, OBB obb, Color color, float duration)
            {
                Box(player, obb.position, obb.rotation, obb.extents, color, duration);
            }
        }

        public static void LogWarning(string message) => Interface.Oxide.LogWarning($"[Spawn Heli] {message}");
        public static void LogError(string message) => Interface.Oxide.LogError($"[Spawn Heli] {message}");

        private static bool VerifyPlayer(IPlayer player, out BasePlayer basePlayer)
        {
            if (player.IsServer)
            {
                basePlayer = null;
                return false;
            }

            basePlayer = player.Object as BasePlayer;
            return true;
        }

        private static void TryMountPlayer(BasePlayer player, PlayerHelicopter heli)
        {
            foreach (var mountPoint in heli.mountPoints)
            {
                if (mountPoint.isDriver)
                {
                    mountPoint.mountable.AttemptMount(player, doMountChecks: false);
                    break;
                }
            }
        }

        private bool HasPermission(string playerId, string perm1, string perm2 = null)
        {
            return permission.UserHasPermission(playerId, perm1)
                   || (perm2 != null && permission.UserHasPermission(playerId, perm2));
        }

        private bool HasPermission(BasePlayer player, string perm1, string perm2 = null)
        {
            return HasPermission(player.UserIDString, perm1, perm2);
        }

        private bool HasPermission(IPlayer player, string perm1, string perm2 = null)
        {
            return HasPermission(player.Id, perm1, perm2);
        }

        private bool VerifyPermission(IPlayer player, string perm1, string perm2 = null)
        {
            if (HasPermission(player, perm1, perm2))
                return true;

            player.Reply(GetMessage(player.Id, LangEntry.ErrorNoPermission));
            return false;
        }

        private bool VerifyVehicleExists(IPlayer player, BasePlayer basePlayer, VehicleInfo vehicleInfo, out PlayerHelicopter heli)
        {
            heli = FindPlayerVehicle(vehicleInfo, basePlayer);
            if (heli != null)
                return true;

            player.Reply(GetMessage(player.Id, vehicleInfo.Messages.NotFound));
            return false;
        }

        private bool VerifyVehicleWithinDistance(IPlayer player, BasePlayer basePlayer, PlayerHelicopter heli, float maxDistance)
        {
            if (maxDistance < 0 || Vector3.Distance(basePlayer.transform.position, heli.transform.position) < maxDistance)
                return true;

            player.Reply(GetMessage(player.Id, LangEntry.ErrorHeliDistance));
            return false;
        }

        private static bool CheckBox(OBB obb, int layerMask, BaseEntity ignoreEntity = null)
        {
            if (ignoreEntity == null)
                return Physics.CheckBox(obb.position, obb.extents, obb.rotation, layerMask, QueryTriggerInteraction.Ignore);

            var colliderList = Pool.Get<List<Collider>>();
            Vis.Colliders(obb, colliderList, layerMask, QueryTriggerInteraction.Ignore);
            var hitSomething = false;

            foreach (var collider in colliderList)
            {
                var hitEntity = collider.ToBaseEntity();
                if (hitEntity == ignoreEntity || hitEntity?.GetParentEntity() == ignoreEntity)
                    continue;

                hitSomething = true;
                break;
            }

            Pool.FreeUnmanaged(ref colliderList);
            return hitSomething;
        }

        private bool VerifyValidSpawnOrFetchPosition(VehicleInfo vehicleInfo, BasePlayer player, out Vector3 position, out Quaternion rotation, SpawnOptions options, PlayerHelicopter existingHeli = null)
        {
            position = Vector3.zero;
            rotation = Quaternion.identity;

            if (vehicleInfo.Config.FixedSpawnDistanceConfig.Enabled)
            {
                position = options.Position ?? GetFixedPositionForPlayer(vehicleInfo, player);
                rotation = options.Rotation ?? GetFixedRotationForPlayer(vehicleInfo, player);
            }
            else if (options.Position.HasValue)
            {
                position = options.Position.Value;
            }
            else
            {
                if (!Physics.Raycast(player.eyes.HeadRay(), out var hit, Mathf.Infinity, SpawnPointLayerMask))
                {
                    player.ChatMessage(GetMessage(player.UserIDString, LangEntry.ErrorNoSpawnLocationFound));
                    return false;
                }

                if (hit.distance > vehicleInfo.Config.MaxSpawnDistance)
                {
                    player.ChatMessage(GetMessage(player.UserIDString, LangEntry.ErrorSpawnDistance));
                    return false;
                }

                position = hit.point + Vector3.up * VerticalSpawnOffset;
            }

            var mainExtents = vehicleInfo.Bounds.extents;
            var mainCenter = position + rotation * vehicleInfo.Bounds.center;
            var mainObb = new OBB(mainCenter, 2 * mainExtents, rotation);

            var playerObb = new OBB(
                mainCenter - new Vector3(0, mainExtents.y + VerticalSpawnOffset / 2f, 0),
                2 * mainExtents.WithY(VerticalSpawnOffset / 2f),
                rotation
            );

            var tailCenter = mainCenter + rotation * new Vector3(0, 0, -mainExtents.z - vehicleInfo.TailLength / 2);
            tailCenter.y = position.y + vehicleInfo.TailYOffset;
            var tailExtents = new Vector3(vehicleInfo.TailThickness, vehicleInfo.TailThickness, vehicleInfo.TailLength / 2);
            var tailObb = new OBB(tailCenter, 2 * tailExtents, rotation);

            if (_config.AdminDebugBounds && player.IsAdmin)
            {
                Ddraw.Box(player, mainObb, Color.magenta, 5f);
                Ddraw.Box(player, tailObb, Color.magenta, 5f);
                Ddraw.Box(player, playerObb, Color.cyan, 5f);
            }

            if (options.CheckSpace != false && (
                    CheckBox(mainObb, SpaceCheckLayerMask, existingHeli)
                    || CheckBox(tailObb, SpaceCheckLayerMask, existingHeli)
                    || CheckBox(playerObb, Rust.Layers.Mask.Player_Server, player)))
            {
                player.ChatMessage(GetMessage(player.UserIDString, LangEntry.InsufficientSpace));
                return false;
            }

            return true;
        }

        private bool VerifyOffCooldown(VehicleInfo vehicleInfo, BasePlayer player, CooldownConfig cooldownConfig, Dictionary<string, DateTime> cooldownMap)
        {
            if (!cooldownMap.TryGetValue(player.UserIDString, out var cooldownStart)
                || HasPermission(player.UserIDString, vehicleInfo.Permissions.NoCooldown))
                return true;

            var timeRemaining = CeilingTimeSpan(cooldownStart.AddSeconds(GetPlayerCooldownSeconds(cooldownConfig, player)) - DateTime.Now);
            if (timeRemaining.TotalSeconds <= 0)
            {
                _data.RemoveCooldown(cooldownMap, player);
                return true;
            }

            player.ChatMessage(GetMessage(player.UserIDString, LangEntry.ErrorOnCooldown, timeRemaining.ToString("g")));
            return false;
        }

        private bool VerifyNotBuildingBlocked(IPlayer player, BasePlayer basePlayer)
        {
            if (!basePlayer.IsBuildingBlocked())
                return true;

            player.Reply(GetMessage(basePlayer.UserIDString, LangEntry.ErrorBuildingBlocked));
            return false;
        }

        private static bool SpawnWasBlocked(VehicleInfo vehicleInfo, BasePlayer player)
        {
            return Interface.CallHook(vehicleInfo.Hooks.Spawn, player) is false;
        }

        private static bool FetchWasBlocked(VehicleInfo vehicleInfo, BasePlayer player, PlayerHelicopter heli)
        {
            return Interface.CallHook(vehicleInfo.Hooks.Fetch, player, heli) is false;
        }

        private static bool DespawnWasBlocked(VehicleInfo vehicleInfo, BasePlayer player, PlayerHelicopter heli)
        {
            return Interface.CallHook(vehicleInfo.Hooks.Despawn, player, heli) is false;
        }

        private static TimeSpan CeilingTimeSpan(TimeSpan timeSpan)
        {
            return new TimeSpan((long)Math.Ceiling(1.0 * timeSpan.Ticks / 10000000) * 10000000);
        }

        private static Vector3 GetFixedPositionForPlayer(VehicleInfo vehicleInfo, BasePlayer player)
        {
            var forward = player.eyes.BodyForward();
            forward.y = 0;
            return player.transform.position + forward.normalized * vehicleInfo.Config.FixedSpawnDistanceConfig.Distance + Vector3.up * VerticalSpawnOffset;
        }

        private static Quaternion GetFixedRotationForPlayer(VehicleInfo vehicleInfo, BasePlayer player)
        {
            return Quaternion.Euler(0, player.eyes.rotation.eulerAngles.y - vehicleInfo.Config.FixedSpawnDistanceConfig.RotationAngle, 0);
        }

        private static void SetupHeli(PlayerHelicopter heli)
        {
            UnityEngine.Object.Destroy(heli.GetComponent<MagnetLiftable>());
        }

        private static void EnableUnlimitedFuel(PlayerHelicopter heli)
        {
            if (heli.GetFuelSystem() is not EntityFuelSystem fuelSystem)
                return;

            fuelSystem.cachedHasFuel = true;
            fuelSystem.nextFuelCheckTime = float.MaxValue;
            fuelSystem.GetFuelContainer().SetFlag(BaseEntity.Flags.Locked, true);
        }

        private static bool AnyParentedPlayers(PlayerHelicopter heli)
        {
            foreach (var entity in heli.children)
            {
                if (entity is BasePlayer)
                    return true;
            }

            return false;
        }

        private static bool IsHeliOccupied(PlayerHelicopter heli)
        {
            return heli.AnyMounted() || AnyParentedPlayers(heli);
        }

        private static void UnparentPlayers(PlayerHelicopter heli)
        {
            var tempList = Pool.Get<List<BasePlayer>>();

            try
            {
                foreach (var entity in heli.children)
                {
                    var player = entity as BasePlayer;
                    if (player == null)
                        continue;

                    tempList.Add(player);
                }

                foreach (var player in tempList)
                {
                    player.SetParent(null, worldPositionStays: true);
                }
            }
            finally
            {
                Pool.FreeUnmanaged(ref tempList);
            }
        }

        private bool IsPlayerVehicle(PlayerHelicopter heli, out VehicleInfo vehicleInfo)
        {
            vehicleInfo = _vehicleInfoManager.GetVehicleInfo(heli);
            return vehicleInfo != null && vehicleInfo.Data.HasVehicle(heli);
        }

        private PlayerHelicopter FindPlayerVehicle(VehicleInfo vehicleInfo, BasePlayer player)
        {
            if (!vehicleInfo.Data.Vehicles.TryGetValue(player.UserIDString, out var heliNetId))
                return null;

            var heli = BaseNetworkable.serverEntities.Find(new NetworkableId(heliNetId)) as PlayerHelicopter;
            if (heli == null)
            {
                _data.UnregisterVehicle(vehicleInfo, player.UserIDString);
            }

            return heli;
        }

        private bool ShouldAutoMount(VehicleInfo vehicleInfo, BasePlayer player)
        {
            var mountConfig = vehicleInfo.Config.AutoMount;
            if (!mountConfig.Enabled)
                return false;

            return !mountConfig.RequirePermission
                   || HasPermission(player, vehicleInfo.Permissions.AutoMount, VehicleInfo.All.AutoMount);
        }

        private void MaybeAutoMount(VehicleInfo vehicleInfo, BasePlayer player, PlayerHelicopter heli, SpawnOptions options)
        {
            if (!(options.AutoMount ?? ShouldAutoMount(vehicleInfo, player)))
                return;

            TryMountPlayer(player, heli);
        }

        private bool TryFetchVehicle(VehicleInfo vehicleInfo, IPlayer player, BasePlayer basePlayer, PlayerHelicopter heli, SpawnOptions options = default)
        {
            var isOccupied = IsHeliOccupied(heli);
            var canFetchWhileOccupied = options.AllowWhileOccupied ?? vehicleInfo.Config.CanFetchWhileOccupied;
            if (isOccupied && (!canFetchWhileOccupied
                               || basePlayer.GetMountedVehicle() == heli
                               || basePlayer.GetParentEntity() == heli))
            {
                basePlayer.ChatMessage(GetMessage(basePlayer.UserIDString, LangEntry.ErrorHeliOccupied));
                return false;
            }

            var maxFetchDistance = options.MaxFetchDistance ?? vehicleInfo.Config.MaxFetchDistance;
            if (!VerifyVehicleWithinDistance(player, basePlayer, heli, maxFetchDistance))
                return false;

            if (options.CheckCooldown != false
                && !VerifyOffCooldown(vehicleInfo, basePlayer, vehicleInfo.Config.FetchCooldowns, vehicleInfo.Data.FetchCooldowns))
                return false;

            if ((options.CheckBuildingBlocked ?? !vehicleInfo.Config.CanFetchBuildingBlocked)
                && !VerifyNotBuildingBlocked(player, basePlayer))
                return false;

            if (options.CheckHooks != false
                && FetchWasBlocked(vehicleInfo, basePlayer, heli))
                return false;

            if (!VerifyValidSpawnOrFetchPosition(vehicleInfo, basePlayer, out var position, out var rotation, options, heli))
                return false;

            if (isOccupied)
            {
                foreach (var mountPoint in heli.mountPoints)
                {
                    mountPoint.mountable?.DismountAllPlayers();
                }
            }

            if (AnyParentedPlayers(heli))
            {
                UnparentPlayers(heli);
            }

            if ((options.AutoRepair ?? vehicleInfo.Config.RepairOnFetch)
                && vehicleInfo.Config.SpawnHealth > 0)
            {
                heli.SetHealth(Math.Max(heli.Health(), vehicleInfo.Config.SpawnHealth));
            }

            // Terminate on client so that it doesn't animate from the previous location, since that can hinder stealth.
            heli.TerminateOnClient(BaseNetworkable.DestroyMode.None);

            heli.rigidBody.velocity = Vector3.zero;
            heli.transform.SetPositionAndRotation(position, rotation);
            heli.rigidBody.WakeUp();
            heli.timeSinceLastPush = 0f;
            heli.UpdateNetworkGroup();
            NetworkUtils.SendUpdateImmediateRecursive(heli);

            if (options.StartCooldown != false
                && !HasPermission(basePlayer, vehicleInfo.Permissions.NoCooldown, VehicleInfo.All.NoCooldown))
            {
                _data.StartFetchCooldown(vehicleInfo, basePlayer);
            }

            MaybeAutoMount(vehicleInfo, basePlayer, heli, options);
            return true;
        }

        private PlayerHelicopter SpawnVehicle(VehicleInfo vehicleInfo, BasePlayer player, Vector3 position, Quaternion rotation, SpawnOptions options)
        {
            var heli = GameManager.server.CreateEntity(vehicleInfo.PrefabPath, position, rotation) as PlayerHelicopter;
            if (heli == null)
                return null;

            heli.OwnerID = player.userID;
            if (vehicleInfo.Config.SpawnHealth > 0)
            {
                heli.startHealth = vehicleInfo.Config.SpawnHealth;
            }

            SetupHeli(heli);
            heli.Spawn();

            if (HasPermission(player, vehicleInfo.Permissions.UnlimitedFuel, VehicleInfo.All.UnlimitedFuel))
            {
                EnableUnlimitedFuel(heli);
            }
            else
            {
                AddInitialFuel(vehicleInfo, heli, player);
            }

            _data.RegisterVehicle(vehicleInfo, player.UserIDString, heli);
            MaybeAutoMount(vehicleInfo, player, heli, options);

            return heli;
        }

        private void GiveVehicle(VehicleInfo vehicleInfo, BasePlayer player, Vector3? customPosition = null)
        {
            // Note: The give command does not auto fetch, but that could be changed in the future.
            if (FindPlayerVehicle(vehicleInfo, player) != null)
            {
                player.ChatMessage(GetMessage(player.UserIDString, vehicleInfo.Messages.AlreadySpawned));
                return;
            }

            var position = customPosition ?? GetFixedPositionForPlayer(vehicleInfo, player);
            var rotation = customPosition.HasValue ? Quaternion.identity : GetFixedRotationForPlayer(vehicleInfo, player);
            SpawnVehicle(vehicleInfo, player, position, rotation, new SpawnOptions { AutoMount = false });
        }

        private bool TryDespawnConflictingHeli(VehicleInfo vehicleInfo, PlayerHelicopter heli, BasePlayer basePlayer, SpawnOptions options)
        {
            var autoDespawn = options.AutoDespawnOtherHelicopterTypes ?? _config.AutoDespawnOtherHelicopterTypes;
            if (!autoDespawn)
                return false;

            var allowWhileOccupied = options.AllowWhileOccupied ?? vehicleInfo.Config.CanDespawnWhileOccupied;
            if (!allowWhileOccupied && IsHeliOccupied(heli))
                return false;

            if (DespawnWasBlocked(vehicleInfo, basePlayer, heli))
                return false;

            return true;
        }

        private float GetPlayerCooldownSeconds(CooldownConfig cooldownConfig, BasePlayer player)
        {
            var profileList = cooldownConfig.CooldownProfiles;
            if (profileList != null)
            {
                for (var i = profileList.Length - 1; i >= 0; i--)
                {
                    var profile = profileList[i];
                    if (profile.Permission != null && HasPermission(player, profile.Permission))
                        return profile.CooldownSeconds;
                }
            }

            return cooldownConfig.DefaultCooldown;
        }

        private int GetPlayerAllowedFuel(VehicleInfo vehicleInfo, BasePlayer player)
        {
            var fuelConfig = vehicleInfo.Config.FuelConfig;
            var profileList = fuelConfig.FuelProfiles;
            if (profileList != null)
            {
                for (var i = profileList.Length - 1; i >= 0; i--)
                {
                    var profile = profileList[i];
                    if (profile.Permission != null && HasPermission(player, profile.Permission))
                        return profile.FuelAmount;
                }
            }

            return fuelConfig.DefaultFuelAmount;
        }

        private void AddInitialFuel(VehicleInfo vehicleInfo, PlayerHelicopter heli, BasePlayer player)
        {
            var fuelAmount = GetPlayerAllowedFuel(vehicleInfo, player);
            if (fuelAmount == 0)
                return;

            if (heli.GetFuelSystem() is not EntityFuelSystem fuelSystem)
                return;

            var fuelContainer = fuelSystem.GetFuelContainer();
            if (fuelAmount < 0)
            {
                // Value of -1 is documented to represent max stack size.
                fuelAmount = fuelContainer.allowedItem.stackable;
            }

            fuelContainer.inventory.AddItem(fuelContainer.allowedItem, fuelAmount);
        }

        #endregion

        #region Vehicle Info

        private class VehicleInfo
        {
            public static PermissionSet All = PermissionSet.ForVehicle("all");

            public class PermissionSet
            {
                public static PermissionSet ForVehicle(string vehicleName)
                {
                    return new PermissionSet
                    {
                        Spawn = BuildPermission(vehicleName, "spawn"),
                        Fetch = BuildPermission(vehicleName, "fetch"),
                        Despawn = BuildPermission(vehicleName, "despawn"),
                        UnlimitedFuel = BuildPermission(vehicleName, "unlimitedfuel"),
                        NoDecay = BuildPermission(vehicleName, "nodecay"),
                        NoCooldown = BuildPermission(vehicleName, "nocooldown"),
                        AutoMount = BuildPermission(vehicleName, "automount"),
                        InstantTakeoff = BuildPermission(vehicleName, "instanttakeoff"),
                    };
                }

                private static string BuildPermission(string vehicleName, string featureName)
                {
                    return $"{nameof(SpawnHeli)}.{vehicleName}.{featureName}".ToLower();
                }

                public string Spawn { get; private set; }
                public string Fetch { get; private set; }
                public string Despawn { get; private set; }
                public string UnlimitedFuel { get; private set; }
                public string NoDecay { get; private set; }
                public string NoCooldown { get; private set; }
                public string AutoMount { get; private set; }
                public string InstantTakeoff { get; private set; }

                private PermissionSet() {}

                public IEnumerable<string> GetAllPermissions()
                {
                    yield return Spawn;
                    yield return Fetch;
                    yield return Despawn;
                    yield return UnlimitedFuel;
                    yield return NoDecay;
                    yield return NoCooldown;
                    yield return AutoMount;
                    yield return InstantTakeoff;
                }
            }

            public class HookSet
            {
                public string Spawn;
                public string Fetch;
                public string Despawn;
            }

            public class MessageSet
            {
                public LangEntry Destroyed;
                public LangEntry AlreadySpawned;
                public LangEntry NotFound;
            }

            public string VehicleName { private get; set; }
            public string PrefabPath;
            public Bounds Bounds;
            public float TailLength;
            public float TailThickness;
            public float TailYOffset;
            public string GiveCommand  { get; private set; }
            public VehicleConfig Config;
            public VehicleData Data;
            public uint PrefabId { get; private set; }
            public PermissionSet Permissions;
            public HookSet Hooks;
            public MessageSet Messages;

            public void Init(SpawnHeli plugin)
            {
                GiveCommand = $"{nameof(SpawnHeli)}.{VehicleName}.give".ToLower();
                Permissions = PermissionSet.ForVehicle(VehicleName);

                foreach (var perm in Permissions.GetAllPermissions())
                {
                    plugin.permission.RegisterPermission(perm, plugin);
                }

                if (Config.FuelConfig.FuelProfiles != null)
                {
                    foreach (var profile in Config.FuelConfig.FuelProfiles)
                    {
                        if (profile.Permission != null)
                        {
                            plugin.permission.RegisterPermission(profile.Permission, plugin);
                        }
                    }
                }

                if (Config.SpawnCooldowns.CooldownProfiles != null)
                {
                    foreach (var profile in Config.SpawnCooldowns.CooldownProfiles)
                    {
                        if (profile.Permission != null)
                        {
                            plugin.permission.RegisterPermission(profile.Permission, plugin);
                        }
                    }
                }

                if (Config.FetchCooldowns.CooldownProfiles != null)
                {
                    foreach (var profile in Config.FetchCooldowns.CooldownProfiles)
                    {
                        if (profile.Permission != null)
                        {
                            plugin.permission.RegisterPermission(profile.Permission, plugin);
                        }
                    }
                }
            }

            public void OnServerInitialized()
            {
                PrefabId = GameManager.server.FindPrefab(PrefabPath)?.GetComponent<BaseEntity>()?.prefabID ?? 0;
            }
        }

        private class VehicleInfoManager
        {
            public VehicleInfo Minicopter { get; private set; }
            public VehicleInfo ScrapTransportHelicopter { get; private set; }
            public VehicleInfo AttackHelicopter { get; private set; }
            public VehicleInfo[] AllVehicles { get; private set; }

            private readonly SpawnHeli _plugin;
            private readonly Dictionary<uint, VehicleInfo> _prefabIdToVehicleInfo = new();

            public bool AnyOwnerOnly => AllVehicles.Any(vehicleInfo => vehicleInfo.Config.OnlyOwnerAndTeamCanMount);
            public bool AnyDespawnOnDisconnect => AllVehicles.Any(vehicleInfo => vehicleInfo.Config.DespawnOnDisconnect);
            public bool AnyInstantTakeoff => AllVehicles.Any(vehicleInfo => vehicleInfo.Config.InstantTakeoff.Enabled);

            private Configuration _config => _plugin._config;
            private SaveData _data => _plugin._data;

            public VehicleInfoManager(SpawnHeli plugin)
            {
                _plugin = plugin;
            }

            public void Init()
            {
                AllVehicles = new[]
                {
                    Minicopter = new VehicleInfo
                    {
                        VehicleName = PermissionMinicopter,
                        PrefabPath = "assets/content/vehicles/minicopter/minicopter.entity.prefab",
                        Config = _config.Minicopter,
                        Data = _data.Minicopter,
                        Hooks = new VehicleInfo.HookSet
                        {
                            Spawn = "OnMyMiniSpawn",
                            Fetch = "OnMyMiniFetch",
                            Despawn = "OnMyMiniDespawn",
                        },
                        Messages = new VehicleInfo.MessageSet
                        {
                            Destroyed = LangEntry.MiniDestroyed,
                            AlreadySpawned = LangEntry.ErrorMiniExists,
                            NotFound = LangEntry.ErrorMiniNotFound,
                        },
                        Bounds = new Bounds
                        {
                            center = new Vector3(0f, 1.11f, 0.5f),
                            extents = new Vector3(1.3f, 0.85f, 1.7f),
                        },
                        TailLength = 1.5f,
                        TailThickness = 0.35f,
                        TailYOffset = 1f,
                    },
                    ScrapTransportHelicopter = new VehicleInfo
                    {
                        VehicleName = PermissionScrapHeli,
                        PrefabPath = "assets/content/vehicles/scrap heli carrier/scraptransporthelicopter.prefab",
                        Config = _config.ScrapTransportHelicopter,
                        Data = _data.ScrapTransportHelicopter,
                        Hooks = new VehicleInfo.HookSet
                        {
                            Spawn = "OnMyScrapHeliSpawn",
                            Fetch = "OnMyScrapHeliFetch",
                            Despawn = "OnMyScrapHeliDespawn",
                        },
                        Messages = new VehicleInfo.MessageSet
                        {
                            Destroyed = LangEntry.ScrapHeliDestroyed,
                            AlreadySpawned = LangEntry.ErrorScrapHeliExist,
                            NotFound = LangEntry.ErrorScrapHeliNotFound,
                        },
                        Bounds = new Bounds
                        {
                            center = new Vector3(0, 2.25f, 0.65f),
                            extents = new Vector3(2.2f, 2.25f, 4f),
                        },
                        TailLength = 4.7f,
                        TailThickness = 0.5f,
                        TailYOffset = 2.9f,
                    },
                    AttackHelicopter = new VehicleInfo
                    {
                        VehicleName = PermissionAttackHeli,
                        PrefabPath = "assets/content/vehicles/attackhelicopter/attackhelicopter.entity.prefab",
                        Config = _config.AttackHelicopter,
                        Data = _data.AttackHelicopter,
                        Hooks = new VehicleInfo.HookSet
                        {
                            Spawn = "OnMyAttackHeliSpawn",
                            Fetch = "OnMyAttackHeliFetch",
                            Despawn = "OnMyAttackHeliDespawn",
                        },
                        Messages = new VehicleInfo.MessageSet
                        {
                            Destroyed = LangEntry.AttackHeliDestroyed,
                            AlreadySpawned = LangEntry.ErrorAttackHeliExists,
                            NotFound = LangEntry.ErrorAttackHeliNotFound,
                        },
                        Bounds = new Bounds
                        {
                            center = new Vector3(0, 1.65f, 0f),
                            extents = new Vector3(1.3f, 1.65f, 1.9f),
                        },
                        TailLength = 4.8f,
                        TailThickness = 0.6f,
                        TailYOffset = 1.9f,
                    },
                };

                foreach (var vehicleInfo in AllVehicles)
                {
                    vehicleInfo.Init(_plugin);
                }

                _plugin.AddCovalenceCommand(Minicopter.Config.SpawnCommands, nameof(CommandSpawnMinicopter));
                _plugin.AddCovalenceCommand(Minicopter.Config.FetchCommands, nameof(CommandFetchMinicopter));
                _plugin.AddCovalenceCommand(Minicopter.Config.DespawnCommands, nameof(CommandDespawnMinicopter));
                _plugin.AddCovalenceCommand(Minicopter.GiveCommand, nameof(CommandGiveMinicopter));

                _plugin.AddCovalenceCommand(ScrapTransportHelicopter.Config.SpawnCommands, nameof(CommandSpawnScrapTransportHelicopter));
                _plugin.AddCovalenceCommand(ScrapTransportHelicopter.Config.FetchCommands, nameof(CommandFetchScrapTransportHelicopter));
                _plugin.AddCovalenceCommand(ScrapTransportHelicopter.Config.DespawnCommands, nameof(CommandDespawnScrapTransportHelicopter));
                _plugin.AddCovalenceCommand(ScrapTransportHelicopter.GiveCommand, nameof(CommandGiveScrapTransportHelicopter));

                _plugin.AddCovalenceCommand(AttackHelicopter.Config.SpawnCommands, nameof(CommandSpawnAttackHelicopter));
                _plugin.AddCovalenceCommand(AttackHelicopter.Config.FetchCommands, nameof(CommandFetchAttackHelicopter));
                _plugin.AddCovalenceCommand(AttackHelicopter.Config.DespawnCommands, nameof(CommandDespawnAttackHelicopter));
                _plugin.AddCovalenceCommand(AttackHelicopter.GiveCommand, nameof(CommandGiveAttackHelicopter));
            }

            public void OnServerInitialized()
            {
                foreach (var vehicleInfo in AllVehicles)
                {
                    vehicleInfo.OnServerInitialized();

                    if (vehicleInfo.PrefabId != 0)
                    {
                        _prefabIdToVehicleInfo[vehicleInfo.PrefabId] = vehicleInfo;
                    }
                    else
                    {
                        LogError($"Unable to determine Prefab ID for prefab: {vehicleInfo.PrefabPath}");
                    }
                }
            }

            public VehicleInfo GetVehicleInfo(BaseEntity entity)
            {
                return _prefabIdToVehicleInfo.TryGetValue(entity.prefabID, out var vehicleInfo)
                    ? vehicleInfo
                    : null;
            }
        }

        #endregion

        #region Data

        [JsonObject(MemberSerialization.OptIn)]
        private class LegacySaveData
        {
            private const string Filename = LegacyPluginName;

            public static LegacySaveData LoadIfExists()
            {
                return Interface.Oxide.DataFileSystem.ExistsDatafile(Filename)
                    ? Interface.Oxide.DataFileSystem.ReadObject<LegacySaveData>(Filename)
                    : null;
            }

            [JsonProperty("playerMini")]
            public Dictionary<string, ulong> playerMini = new();

            [JsonProperty("spawnCooldowns")]
            public Dictionary<string, DateTime> spawnCooldowns = new();

            [JsonProperty("cooldown")]
            private Dictionary<string, DateTime> deprecatedCooldown
            {
                set => spawnCooldowns = value;
            }

            [JsonProperty("fetchCooldowns")]
            public Dictionary<string, DateTime> fetchCooldowns = new();

            public void Delete()
            {
                Interface.Oxide.DataFileSystem.DeleteDataFile(Filename);
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class VehicleData
        {
            [JsonProperty("Vehicles")]
            public Dictionary<string, ulong> Vehicles = new();

            [JsonProperty("SpawnCooldowns")]
            public Dictionary<string, DateTime> SpawnCooldowns = new();

            [JsonProperty("FetchCooldowns")]
            public Dictionary<string, DateTime> FetchCooldowns = new();

            public PlayerHelicopter GetVehicle(string playerId)
            {
                return Vehicles.TryGetValue(playerId, out var netId)
                    ? BaseNetworkable.serverEntities.Find(new NetworkableId(netId)) as PlayerHelicopter
                    : null;
            }

            public bool HasVehicle(BaseEntity vehicle)
            {
                return Vehicles.ContainsValue(vehicle.net.ID.Value);
            }

            public bool RegisterVehicle(string playerId, ulong netId)
            {
                return Vehicles.TryAdd(playerId, netId);
            }

            public bool UnregisterVehicle(string playerId)
            {
                return Vehicles.Remove(playerId);
            }

            public void SetSpawnCooldown(string playerId, DateTime dateTime)
            {
                SpawnCooldowns[playerId] = dateTime;
            }

            public void SetFetchCooldown(string playerId, DateTime dateTime)
            {
                FetchCooldowns[playerId] = dateTime;
            }

            public bool Clean()
            {
                if (Vehicles.Count == 0)
                    return false;

                var changed = false;

                foreach (var entry in Vehicles.ToList())
                {
                    var entity = BaseNetworkable.serverEntities.Find(new NetworkableId(entry.Value)) as PlayerHelicopter;
                    if (entity != null)
                        continue;

                    Vehicles.Remove(entry.Key);
                    changed = true;
                }

                return changed;
            }

            public bool Reset()
            {
                var result = Vehicles.Count > 0 || SpawnCooldowns.Count > 0 || FetchCooldowns.Count > 0;
                Vehicles.Clear();
                SpawnCooldowns.Clear();
                FetchCooldowns.Clear();
                return result;
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class SaveData
        {
            private const string Filename = nameof(SpawnHeli);

            public static SaveData Load()
            {
                var exists = Interface.Oxide.DataFileSystem.ExistsDatafile(Filename);
                var data = Interface.Oxide.DataFileSystem.ReadObject<SaveData>(Filename) ?? new SaveData();
                if (!exists)
                {
                    var legacyData = LegacySaveData.LoadIfExists();
                    if (legacyData != null)
                    {
                        data.Minicopter.Vehicles = legacyData.playerMini;
                        data.Minicopter.SpawnCooldowns = legacyData.spawnCooldowns;
                        data.Minicopter.FetchCooldowns = legacyData.fetchCooldowns;
                        data.SaveIfChanged();
                        legacyData.Delete();
                    }
                }

                return data;
            }

            private bool _dirty;

            [JsonProperty("Minicopter")]
            public VehicleData Minicopter = new();

            [JsonProperty("ScrapTransportHelicopter")]
            public VehicleData ScrapTransportHelicopter = new();

            [JsonProperty("AttackHelicopter")]
            public VehicleData AttackHelicopter = new();

            public void Clean()
            {
                _dirty |= Minicopter.Clean();
                _dirty |= ScrapTransportHelicopter.Clean();
                _dirty |= AttackHelicopter.Clean();
            }

            public void Reset()
            {
                _dirty |= Minicopter.Reset();
                _dirty |= ScrapTransportHelicopter.Reset();
                _dirty |= AttackHelicopter.Reset();
            }

            public void SaveIfChanged()
            {
                if (!_dirty)
                    return;

                Interface.Oxide.DataFileSystem.WriteObject(Filename, this);
                _dirty = false;
            }

            public void StartSpawnCooldown(VehicleInfo vehicleInfo, BasePlayer player)
            {
                vehicleInfo.Data.SetSpawnCooldown(player.UserIDString, DateTime.Now);
                _dirty = true;
            }

            public void StartFetchCooldown(VehicleInfo vehicleInfo, BasePlayer player)
            {
                vehicleInfo.Data.SetFetchCooldown(player.UserIDString, DateTime.Now);
                _dirty = true;
            }

            public void RegisterVehicle(VehicleInfo vehicleInfo, string playerId, PlayerHelicopter heli)
            {
                vehicleInfo.Data.RegisterVehicle(playerId, heli.net.ID.Value);
                _dirty = true;
            }

            public void UnregisterVehicle(VehicleInfo vehicleInfo, string playerId)
            {
                vehicleInfo.Data.UnregisterVehicle(playerId);
                _dirty = true;
            }

            public void RemoveCooldown(Dictionary<string, DateTime> cooldownMap, BasePlayer player)
            {
                cooldownMap.Remove(player.UserIDString);
                _dirty = true;
            }
        }

        #endregion

        #region Configuration

        [JsonObject(MemberSerialization.OptIn)]
        private class BasePermissionAmount
        {
            [JsonProperty("Permission suffix")]
            protected string PermissionSuffix;

            [JsonIgnore]
            public string Permission { get; protected set; }

            public void Init(string permissionInfix)
            {
                if (!string.IsNullOrWhiteSpace(PermissionSuffix))
                {
                    Permission = $"{nameof(SpawnHeli)}.{permissionInfix}.{PermissionSuffix}".ToLower();
                }
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class FuelProfile : BasePermissionAmount
        {
            [JsonProperty("Fuel amount")]
            public int FuelAmount;

            // Default constructor for JSON, necessary because there's another constructor.
            public FuelProfile() { }

            public FuelProfile(string permissionSuffix, int fuelAmount)
            {
                PermissionSuffix = permissionSuffix;
                FuelAmount = fuelAmount;
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class FuelConfig
        {
            [JsonProperty("Default fuel amount")]
            public int DefaultFuelAmount;

            [JsonProperty("Fuel profiles requiring permission")]
            public FuelProfile[] FuelProfiles =
            {
                new("100", 100),
                new("500", 500),
                new("1000", 1000),
            };

            public void Init(string vehicleName)
            {
                if (FuelProfiles != null)
                {
                    foreach (var profile in FuelProfiles)
                    {
                        profile.Init($"{vehicleName}.fuel");
                    }
                }
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class CooldownProfile : BasePermissionAmount
        {
            [JsonProperty("Cooldown (seconds)")]
            public float CooldownSeconds;

            // Default constructor for JSON, necessary because there's another constructor.
            public CooldownProfile() { }

            public CooldownProfile(string permissionSuffix, float cooldownSeconds)
            {
                PermissionSuffix = permissionSuffix;
                CooldownSeconds = cooldownSeconds;
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class CooldownConfig
        {
            [JsonProperty("Default cooldown (seconds)")]
            public float DefaultCooldown;

            [JsonProperty("Cooldown profiles requiring permission")]
            public CooldownProfile[] CooldownProfiles;

            public void Init(string vehicleName, string cooldownType)
            {
                if (CooldownProfiles != null)
                {
                    foreach (var profile in CooldownProfiles)
                    {
                        profile.Init($"{vehicleName}.cooldown.{cooldownType}");
                    }
                }
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class FixedSpawnDistanceConfig
        {
            [JsonProperty("Enabled")]
            public bool Enabled = true;

            [JsonProperty("Distance from player")]
            public float Distance = 3;

            [JsonProperty("Helicopter rotation angle")]
            public float RotationAngle = 90;
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class AutoMountConfig
        {
            [JsonProperty("Enabled")]
            public bool Enabled;

            [JsonProperty("Require permission")]
            public bool RequirePermission;
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class InstantTakeoffConfig
        {
            [JsonProperty("Enabled")]
            public bool Enabled;

            [JsonProperty("Require permission")]
            public bool RequirePermission;
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class VehicleConfig
        {
            [JsonProperty("Spawn commands")]
            public string[] SpawnCommands;

            [JsonProperty("Fetch commands")]
            public string[] FetchCommands;

            [JsonProperty("Despawn commands")]
            public string[] DespawnCommands;

            [JsonProperty("Can despawn while occupied")]
            public bool CanDespawnWhileOccupied;

            [JsonProperty("Can fetch while occupied")]
            public bool CanFetchWhileOccupied;

            [JsonProperty("Can spawn while building blocked")]
            public bool CanSpawnBuildingBlocked;

            [JsonProperty("Can fetch while building blocked")]
            public bool CanFetchBuildingBlocked;

            [JsonProperty("Auto fetch")]
            public bool AutoFetch;

            [JsonProperty("Repair on fetch")]
            public bool RepairOnFetch;

            [JsonProperty("Max spawn distance")]
            public float MaxSpawnDistance = 5f;

            [JsonProperty("Max fetch distance")]
            public float MaxFetchDistance = -1;

            [JsonProperty("Max despawn distance")]
            public float MaxDespawnDistance = -1;

            [JsonProperty("Fixed spawn distance")]
            public FixedSpawnDistanceConfig FixedSpawnDistanceConfig = new();

            [JsonProperty("Auto mount")]
            public AutoMountConfig AutoMount = new();

            [JsonProperty("Instant takeoff")]
            public InstantTakeoffConfig InstantTakeoff = new();

            [JsonProperty("Only owner and team can mount")]
            public bool OnlyOwnerAndTeamCanMount;

            [JsonProperty("Spawn health")]
            public float SpawnHealth;

            [JsonProperty("Destroy on disconnect")]
            public bool DespawnOnDisconnect;

            [JsonProperty("Fuel")]
            public FuelConfig FuelConfig = new();

            [JsonProperty("Spawn cooldowns")]
            public CooldownConfig SpawnCooldowns = new()
            {
                DefaultCooldown = 3600f,
                CooldownProfiles = new[]
                {
                    new CooldownProfile("1hr", 3600),
                    new CooldownProfile("10m", 600),
                    new CooldownProfile("10s", 10),
                },
            };

            [JsonProperty("Fetch cooldowns")]
            public CooldownConfig FetchCooldowns = new()
            {
                DefaultCooldown = 10f,
                CooldownProfiles = new[]
                {
                    new CooldownProfile("1hr", 3600),
                    new CooldownProfile("10m", 600),
                    new CooldownProfile("10s", 10),
                },
            };

            public void Init(string vehicleName)
            {
                FuelConfig?.Init(vehicleName);
                SpawnCooldowns?.Init(vehicleName, "spawn");
                FetchCooldowns?.Init(vehicleName, "fetch");
            }
        }

        [JsonObject(MemberSerialization.OptIn)]
        private class Configuration : SerializableConfiguration
        {
            [JsonProperty("Admin debug bounds", DefaultValueHandling = DefaultValueHandling.Ignore)]
            public bool AdminDebugBounds;

            [JsonProperty("Limit players to one helicopter type at a time")]
            public bool LimitPlayersToOneHelicopterType;

            [JsonProperty("Try to auto despawn other helicopter types")]
            public bool AutoDespawnOtherHelicopterTypes;

            [JsonProperty("Minicopter")]
            public VehicleConfig Minicopter = new()
            {
                SpawnCommands = new[] { "mymini" },
                FetchCommands = new[] { "fmini" },
                DespawnCommands = new[] { "nomini" },
                SpawnHealth = 750,
            };

            [JsonProperty("ScrapTransportHelicopter")]
            public VehicleConfig ScrapTransportHelicopter = new()
            {
                SpawnCommands = new[] { "myheli" },
                FetchCommands = new[] { "fheli" },
                DespawnCommands = new[] { "noheli" },
                SpawnHealth = 1000,
            };

            [JsonProperty("AttackHelicopter")]
            public VehicleConfig AttackHelicopter = new()
            {
                SpawnCommands = new[] { "myattack" },
                FetchCommands = new[] { "fattack" },
                DespawnCommands = new[] { "noattack" },
                SpawnHealth = 850,
            };

            public bool Migrate()
            {
                var changed = false;

                if (DeprecatedCanDespawnWhileOccupied)
                {
                    Minicopter.CanDespawnWhileOccupied = DeprecatedCanDespawnWhileOccupied;
                    DeprecatedCanDespawnWhileOccupied = false;
                    changed = true;
                }

                if (DeprecatedCanFetchWhileOccupied)
                {
                    Minicopter.CanFetchWhileOccupied = DeprecatedCanFetchWhileOccupied;
                    DeprecatedCanFetchWhileOccupied = false;
                    changed = true;
                }

                if (DeprecatedCanSpawnBuildingBlocked)
                {
                    Minicopter.CanSpawnBuildingBlocked = DeprecatedCanSpawnBuildingBlocked;
                    DeprecatedCanSpawnBuildingBlocked = DeprecatedCanFetchWhileOccupied;
                    changed = true;
                }

                if (!DeprecatedCanFetchBuildingBlocked)
                {
                    Minicopter.CanFetchBuildingBlocked = DeprecatedCanFetchBuildingBlocked;
                    DeprecatedCanFetchBuildingBlocked = true;
                    changed = true;
                }

                if (DeprecatedAutoFetch)
                {
                    Minicopter.AutoFetch = DeprecatedAutoFetch;
                    DeprecatedAutoFetch = false;
                    changed = true;
                }

                if (DeprecatedRepairOnFetch)
                {
                    Minicopter.RepairOnFetch = DeprecatedRepairOnFetch;
                    DeprecatedRepairOnFetch = false;
                    changed = true;
                }

                if (DeprecatedFuelAmount != 0)
                {
                    Minicopter.FuelConfig.DefaultFuelAmount = DeprecatedFuelAmount;
                    DeprecatedFuelAmount = 0;
                    changed = true;
                }

                if (DeprecatedFuelAmountsRequiringPermission != null)
                {
                    Minicopter.FuelConfig.FuelProfiles = DeprecatedFuelAmountsRequiringPermission
                        .Select(amount => new FuelProfile(amount.ToString(), amount))
                        .ToArray();

                    DeprecatedFuelAmountsRequiringPermission = null;
                    changed = true;
                }

                if (DeprecatedNoMiniDistance != 0)
                {
                    Minicopter.MaxDespawnDistance = DeprecatedNoMiniDistance;
                    Minicopter.MaxFetchDistance = DeprecatedNoMiniDistance;
                    DeprecatedNoMiniDistance = 0;
                    changed = true;
                }

                if (DeprecatedMaxSpawnDistance != 0)
                {
                    Minicopter.MaxSpawnDistance = DeprecatedMaxSpawnDistance;
                    DeprecatedMaxSpawnDistance = 0;
                    changed = true;
                }

                if (!DeprecatedUseFixedSpawnDistance)
                {
                    Minicopter.FixedSpawnDistanceConfig.Enabled = false;
                    DeprecatedUseFixedSpawnDistance = true;
                    changed = true;
                }

                if (DeprecatedFixedSpawnDistance != 0)
                {
                    Minicopter.FixedSpawnDistanceConfig.Distance = DeprecatedFixedSpawnDistance;
                    DeprecatedFixedSpawnDistance = 0;
                    changed = true;
                }

                if (DeprecatedFixedSpawnRotationAngle != 0)
                {
                    Minicopter.FixedSpawnDistanceConfig.RotationAngle = DeprecatedFixedSpawnRotationAngle;
                    DeprecatedFixedSpawnRotationAngle = 0;
                    changed = true;
                }

                if (DeprecatedOwnerOnly)
                {
                    Minicopter.OnlyOwnerAndTeamCanMount = DeprecatedOwnerOnly;
                    DeprecatedOwnerOnly = false;
                    changed = true;
                }

                if (DeprecatedDefaultSpawnCooldown != 0)
                {
                    Minicopter.SpawnCooldowns.DefaultCooldown = DeprecatedDefaultSpawnCooldown;
                    DeprecatedDefaultSpawnCooldown = 0;
                    changed = true;
                }

                if (DeprecatedSpawnPermissionCooldowns != null)
                {
                    Minicopter.SpawnCooldowns.CooldownProfiles = DeprecatedSpawnPermissionCooldowns
                        .Select(entry => new CooldownProfile(StringUtils.StripPrefixes(entry.Key, LegacyPermissionPrefix), entry.Value))
                        .ToArray();

                    DeprecatedSpawnPermissionCooldowns = null;
                    changed = true;
                }

                if (DeprecatedDefaultFetchCooldown != 0)
                {
                    Minicopter.FetchCooldowns.DefaultCooldown = DeprecatedDefaultFetchCooldown;
                    DeprecatedDefaultFetchCooldown = 0;
                    changed = true;
                }

                if (DeprecatedFetchPermissionCooldowns != null)
                {
                    Minicopter.FetchCooldowns.CooldownProfiles = DeprecatedFetchPermissionCooldowns
                        .Select(entry => new CooldownProfile(StringUtils.StripPrefixes(entry.Key, LegacyPermissionPrefix, "fetch."), entry.Value))
                        .ToArray();

                    DeprecatedFetchPermissionCooldowns = null;
                    changed = true;
                }

                if (DeprecatedSpawnHealth != 0)
                {
                    Minicopter.SpawnHealth = DeprecatedSpawnHealth;
                    DeprecatedSpawnHealth = 0;
                    changed = true;
                }

                if (DeprecatedDespawnOnDisconnect)
                {
                    Minicopter.DespawnOnDisconnect = DeprecatedDespawnOnDisconnect;
                    DeprecatedDespawnOnDisconnect = false;
                    changed = true;
                }

                return changed;
            }

            [JsonProperty("CanDespawnWhileOccupied", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedCanDespawnWhileOccupied;

            [JsonProperty("CanFetchWhileOccupied", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedCanFetchWhileOccupied;

            [JsonProperty("CanSpawnBuildingBlocked", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedCanSpawnBuildingBlocked;

            [JsonProperty("CanFetchBuildingBlocked", DefaultValueHandling = DefaultValueHandling.Ignore)]
            [DefaultValue(true)]
            private bool DeprecatedCanFetchBuildingBlocked = true;

            [JsonProperty("AutoFetch", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedAutoFetch;

            [JsonProperty("RepairOnFetch", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedRepairOnFetch;

            [JsonProperty("FuelAmount", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private int DeprecatedFuelAmount;

            [JsonProperty("FuelAmountsRequiringPermission", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private int[] DeprecatedFuelAmountsRequiringPermission;

            [JsonProperty("MaxNoMiniDistance", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedNoMiniDistance;

            [JsonProperty("MaxSpawnDistance", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedMaxSpawnDistance;

            [JsonProperty("UseFixedSpawnDistance", DefaultValueHandling = DefaultValueHandling.Ignore)]
            [DefaultValue(true)]
            private bool DeprecatedUseFixedSpawnDistance = true;

            [JsonProperty("FixedSpawnDistance", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedFixedSpawnDistance;

            [JsonProperty("FixedSpawnRotationAngle", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedFixedSpawnRotationAngle;

            [JsonProperty("OwnerAndTeamCanMount", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedOwnerOnly;

            [JsonProperty("DefaultSpawnCooldown", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedDefaultSpawnCooldown;

            [JsonProperty("PermissionSpawnCooldowns", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private Dictionary<string, float> DeprecatedSpawnPermissionCooldowns;

            [JsonProperty("DefaultFetchCooldown", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedDefaultFetchCooldown;

            [JsonProperty("PermissionFetchCooldowns", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private Dictionary<string, float> DeprecatedFetchPermissionCooldowns;

            [JsonProperty("SpawnHealth", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private float DeprecatedSpawnHealth;

            [JsonProperty("DestroyOnDisconnect", DefaultValueHandling = DefaultValueHandling.Ignore)]
            private bool DeprecatedDespawnOnDisconnect;

            public void Init()
            {
                Minicopter.Init(PermissionMinicopter);
                ScrapTransportHelicopter.Init(PermissionScrapHeli);
                AttackHelicopter.Init(PermissionAttackHeli);
            }
        }

        private Configuration GetDefaultConfig() => new();

        #region Configuration Helpers

        internal class SerializableConfiguration
        {
            public string ToJson() => JsonConvert.SerializeObject(this);

            public Dictionary<string, object> ToDictionary() => JsonHelper.Deserialize(ToJson()) as Dictionary<string, object>;
        }

        internal static class JsonHelper
        {
            public static object Deserialize(string json) => ToObject(JToken.Parse(json));

            private static object ToObject(JToken token)
            {
                switch (token.Type)
                {
                    case JTokenType.Object:
                        return token.Children<JProperty>()
                                    .ToDictionary(prop => prop.Name,
                                                  prop => ToObject(prop.Value));

                    case JTokenType.Array:
                        return token.Select(ToObject).ToList();

                    default:
                        return ((JValue)token).Value;
                }
            }
        }

        private bool MaybeUpdateConfig(SerializableConfiguration config)
        {
            var currentWithDefaults = config.ToDictionary();
            var currentRaw = Config.ToDictionary(x => x.Key, x => x.Value);
            return MaybeUpdateConfigDict(currentWithDefaults, currentRaw);
        }

        private bool MaybeUpdateConfigDict(Dictionary<string, object> currentWithDefaults, Dictionary<string, object> currentRaw)
        {
            var changed = false;

            foreach (var key in currentWithDefaults.Keys)
            {
                if (currentRaw.TryGetValue(key, out var currentRawValue))
                {
                    var currentDictValue = currentRawValue as Dictionary<string, object>;
                    if (currentWithDefaults[key] is Dictionary<string, object> defaultDictValue)
                    {
                        // Don't update nested keys since the cooldown tiers might be customized
                        if (currentDictValue == null)
                        {
                            currentRaw[key] = currentWithDefaults[key];
                            changed = true;
                        }
                    }
                }
                else
                {
                    currentRaw[key] = currentWithDefaults[key];
                    changed = true;
                }
            }

            return changed;
        }

        protected override void LoadDefaultConfig() => _config = GetDefaultConfig();

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                _config = Config.ReadObject<Configuration>();
                if (_config == null)
                    throw new JsonException();

                if (MaybeUpdateConfig(_config) | _config.Migrate())
                {
                    PrintWarning("Configuration appears to be outdated; updating and saving");
                    SaveConfig();
                }
            }
            catch (Exception e)
            {
                PrintError(e.Message);
                PrintWarning($"Configuration file {Name}.json is invalid; using defaults");
                LoadDefaultConfig();
            }
        }

        protected override void SaveConfig()
        {
            Puts($"Configuration changes saved to {Name}.json");
            Config.WriteObject(_config, true);
        }

        #endregion

        #endregion

        #region Localization

        private class LangEntry
        {
            public enum Lang
            {
                en,
            }

            public static readonly List<LangEntry> AllLangEntries = new();

            public static readonly LangEntry ErrorNoPermission = new("error_no_permission", new Dictionary<Lang, string>
            {
                [Lang.en] = "You do not have permission to use this command.",
            });
            public static readonly LangEntry ErrorBuildingBlocked = new("error_building_blocked", new Dictionary<Lang, string>
            {
                [Lang.en] = "Cannot do that while building blocked.",
            });
            public static readonly LangEntry ErrorOnCooldown = new("error_on_cooldown", new Dictionary<Lang, string>
            {
                [Lang.en] = "You have <color=red>{0}</color> until your cooldown ends.",
            });
            public static readonly LangEntry InsufficientSpace = new("error_insufficient_space", new Dictionary<Lang, string>
            {
                [Lang.en] = "Not enough space.",
            });
            public static readonly LangEntry ErrorConflictingHeli = new("error_conflicting_heli", new Dictionary<Lang, string>
            {
                [Lang.en] = "You must first destroy your other helicopter(s) before you can spawn a new one.",
            });

            public static readonly LangEntry ErrorSpawnDistance = new("error_spawn_distance", new Dictionary<Lang, string>
            {
                [Lang.en] = "You cannot spawn the helicopter that far away.",
            });
            public static readonly LangEntry ErrorNoSpawnLocationFound = new("error_spawn_location", new Dictionary<Lang, string>
            {
                [Lang.en] = "No suitable spawn location found.",
            });
            public static readonly LangEntry ErrorHeliOccupied = new("error_heli_occupied", new Dictionary<Lang, string>
            {
                [Lang.en] = "The helicopter is currently occupied.",
            });
            public static readonly LangEntry ErrorHeliDistance = new("error_heli_distance", new Dictionary<Lang, string>
            {
                [Lang.en] = "The helicopter is too far away.",
            });
            public static readonly LangEntry ErrorCannotMount = new("error_cannot_mount", new Dictionary<Lang, string>
            {
                [Lang.en] = "You are not the owner of this helicopter or in the owner's team.",
            });
            public static readonly LangEntry ErrorUnlimitedFuel = new("error_unlimited_fuel", new Dictionary<Lang, string>
            {
                [Lang.en] = "That helicopter doesn't need fuel.",
            });

            public static readonly LangEntry MiniDestroyed = new("info_mini_destroyed", new Dictionary<Lang, string>
            {
                [Lang.en] = "Your Minicopter has been destroyed.",
            });
            public static readonly LangEntry ScrapHeliDestroyed = new("info_scrap_heli_destroyed", new Dictionary<Lang, string>
            {
                [Lang.en] = "Your Scrap Heli has been destroyed.",
            });
            public static readonly LangEntry AttackHeliDestroyed = new("info_attack_heli_destroyed", new Dictionary<Lang, string>
            {
                [Lang.en] = "Your Attack Heli has been destroyed.",
            });

            public static readonly LangEntry ErrorMiniExists = new("error_mini_exists", new Dictionary<Lang, string>
            {
                [Lang.en] = "You already have a Minicopter.",
            });
            public static readonly LangEntry ErrorScrapHeliExist = new("error_scrap_heli_exists", new Dictionary<Lang, string>
            {
                [Lang.en] = "You already have a Scrap Heli.",
            });
            public static readonly LangEntry ErrorAttackHeliExists = new("error_attack_heli_exists", new Dictionary<Lang, string>
            {
                [Lang.en] = "You already have an Attack Heli.",
            });

            public static readonly LangEntry ErrorMiniNotFound = new("error_mini_not_found", new Dictionary<Lang, string>
            {
                [Lang.en] = "You do not have a Minicopter.",
            });
            public static readonly LangEntry ErrorScrapHeliNotFound = new("error_scrap_heli_not_found", new Dictionary<Lang, string>
            {
                [Lang.en] = "You do not have a Scrap Heli.",
            });
            public static readonly LangEntry ErrorAttackHeliNotFound = new("error_attack_heli_not_found", new Dictionary<Lang, string>
            {
                [Lang.en] = "You do not have an Attack Heli.",
            });

            public readonly string Name;
            public readonly Dictionary<Lang, string> PhrasesByLanguage;

            private LangEntry(string name, Dictionary<Lang, string> phrasesByLanguage)
            {
                Name = name;
                PhrasesByLanguage = phrasesByLanguage;

                AllLangEntries.Add(this);
            }
        }

        private string GetMessage(string playerId, LangEntry langEntry)
        {
            return lang.GetMessage(langEntry.Name, this, playerId);
        }

        private string GetMessage(string playerId, LangEntry langEntry, object arg1)
        {
            return string.Format(GetMessage(playerId, langEntry), arg1);
        }

        protected override void LoadDefaultMessages()
        {
            var langKeysByLanguage = new Dictionary<string, Dictionary<string, string>>();

            foreach (var langEntry in LangEntry.AllLangEntries)
            {
                foreach (var phraseEntry in langEntry.PhrasesByLanguage)
                {
                    var langName = phraseEntry.Key.ToString();
                    if (!langKeysByLanguage.TryGetValue(langName, out var langKeys))
                    {
                        langKeys = new Dictionary<string, string>();
                        langKeysByLanguage[langName] = langKeys;
                    }

                    langKeys[langEntry.Name] = phraseEntry.Value;
                }
            }

            foreach (var langKeysEntry in langKeysByLanguage)
            {
                lang.RegisterMessages(langKeysEntry.Value, this, langKeysEntry.Key);
            }
        }

        #endregion
    }
}
