using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Oxide.Core;
using Oxide.Core.Plugins;
using Rust;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Vending Manager", "Whispers88", "0.2.2")]
    [Description("Improved vending machine control")]
    class VendingManager : RustPlugin
    {
        #region Variables

        [PluginReference]
        Plugin CustomVendingSetup, Economics, ServerRewards;

        // usage permission
        private const string PermCanUse = "vendingmanager.canuse";
        private const string PermCanEject = "vendingmanager.caneject";

        // valid commands
        private enum Command
        {
            add, clear, eject, info, list, load, reset, save, set, unset
        };

        // configuration options
        private enum Option
        {
            destroyOnUnload,    // destroy locks on unload
            ejectLocks,         // eject locks on unload/reload
            health,             // health
            lockable,           // allow attaching locks
            lockFailureMessage, // display message on lock attach failure
            saveLocks,          // save locks on unload
            setHealth,          // enable setting health
            noBroadcast,        // blocks broadcasting
            restricted,         // restrict panel access to owners
            useEconomics,       // use Economics
            useServerRewards,   // use ServerRewards
            transactionTimeout, // timeout to end transactions
            logTransSuccess,    // enable logging transaction success
            logTransFailure,    // enable logging transaction failures
            transMessages,      // enable transaction success messages
            currencyItem        // currency item shortname
        }

        // default configuration values
        object[] defaults = new object[] { false, false, defaultHealth, true, false, true, true, false, false, false, false, 300f, false, false, true, "blood" };

        // container for config/data
        VendingData data = new VendingData();
        Dictionary<ulong, VendingMachineInfo> vms = new Dictionary<ulong, VendingMachineInfo>();
        Dictionary<ulong, LockInfo> locks = new Dictionary<ulong, LockInfo>();
        const float defaultHealth = 500f;
        ProtectionProperties defaultProtection;
        ProtectionProperties customProtection;

        const string CodeLockPrefab = "assets/prefabs/locks/keypad/lock.code.prefab";
        const string KeyLockPrefab = "assets/prefabs/locks/keylock/lock.key.prefab";

        Dictionary<ulong, Timer> transactionTimers = new Dictionary<ulong, Timer>();
        Dictionary<ulong, Timer> timeoutTimers = new Dictionary<ulong, Timer>();

        bool isShuttingDown = false;
        bool useEconomics = false;
        bool useServerRewards = false;
        string currencyPlugin
        {
            get
            {
                if (useEconomics) return Economics.Name;
                if (useServerRewards) return ServerRewards.Name;
                return null;
            }
        }

        int currencyIndex = 24;
        ItemDefinition currencyItem;

        #endregion

        #region Lang

        // load default messages to Lang
        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                {"Prefix", "<color=orange>[ VendingManager ]</color> "},
                {"ClearSuccess", "Successfully cleared Vending Machine sell orders"},
                {"SaveSuccess", "Saved Vending Machine sell orders to \"{0}\""},
                {"LoadSuccess", "Loaded Vending Machine sell orders from \"{0}\""},
                {"ResetSuccess", "Successfully cleared and reset VendingManager configuration to defaults"},
                {"ConfirmReset", "To reset the VendingManager configuration and remove all saved templates, type: /vm reset confirm"},
                {"VMNotFound", "No Vending Machine found"},
                {"EmptyTemplate", "Template \"{0}\" is empty"},
                {"EmptyVM", "Vending Machine has no sell orders defined"},
                {"TemplateNotFound", "No sell order template found with name \"{0}\""},
                {"TemplateExists", "Template with name \"{0}\" already exists, add \"overwrite\" parameter to command to overwrite template"},
                {"InvalidCommand", "Invalid command: {0}"},
                {"InvalidParameter", "Invalid parameter"},
                {"NotAuthorized", "You cannot add a lock to that Vending Machine"},
                {"CommandList", "<color=cyan>Valid Commands:</color>" + Environment.NewLine + "{0}"},
                {"TemplateList", "<color=cyan>Templates:</color>" + Environment.NewLine + "{0}"},
                {"Ejected", "Ejected {0} locks from Vending Machines"},
                {"NoBroadcast", "Broadcasting is not allowed" },
                {"Restricted", "You do not have access to administrate that VendingMachine" },
                {"Information", "Vending Machine ID: <color=cyan>{0}</color>" + Environment.NewLine + "Has configuration? <color=cyan>{1}</color>" + Environment.NewLine + "Flags: <color=cyan>{2}</color>" },

                {"EconomicsNotEnoughMoney", "Transaction Cancelled (Economics): Not enough money" },
                {"EconomicsNotEnoughMoneyOwner", "Transaction Cancelled (Economics): Buyer doesn't have enough money" },
                {"EconomicsTransferFailed", "Transaction Cancelled (Economics): Money transfer failed" },
                {"EconomicsPurchaseSuccess", "Successfully purchased {0} {1} for {2:C}; Remaining balance: {3:C}" },
                {"EconomicsSellSuccess", "Successfully sold {0} {1} for {2:C}; New balance: {3:C}" },

                {"ServerRewardsNotEnoughMoney", "Transaction Cancelled (ServerRewards): Not enough RP" },
                {"ServerRewardsNotEnoughMoneyOwner", "Transaction Cancelled (ServerRewards): Buyer doesn't have enough RP" },
                {"ServerRewardsTransferFailed", "Transaction Cancelled (ServerRewards): RP transfer failed" },
                {"ServerRewardsPurchaseSuccess", "Successfully purchased {0} {1} for {2}RP; Remaining balance: {3}RP" },
                {"ServerRewardsSellSuccess", "Successfully sold {0} {1} for {2}RP; New balance: {3}RP" },

                {"SetSuccess", "Successfully set flag <color=cyan>{0}</color>" },
                {"UnsetSuccess", "Successfully removed flag <color=cyan>{0}</color>" },
                {"WarnEconAndSREnabled", "Economics and ServerRewards are both enabled as currency; ServerRewards has been forcibly disabled." },

                {"NotAllowed", "You are not allowed to use this command!"},
                {"CmdBase", "vm"}
            }, this);
        }

        // get message from Lang
        string GetMessage(string key, string userId = null) => lang.GetMessage(key, this, userId);

        #endregion

        #region Loading/Unloading

        // on load
        //void Loaded()
        void Init()
        {
            cmd.AddChatCommand(GetMessage("CmdBase"), this, "CommandDelegator");
            permission.RegisterPermission(PermCanUse, this);
            permission.RegisterPermission(PermCanEject, this);
            LoadData();
        }

        // on unload, reset all vending machines
        void Unload()
        {
            if (ConfigValue<bool>(Option.saveLocks) || isShuttingDown)
                SaveVMsAndLocks();
            SetAll(false);
            if (ConfigValue<bool>(Option.destroyOnUnload) || isShuttingDown)
                DestroyLocks();

            foreach (Timer t in transactionTimers.Values)
                t?.Destroy();
            foreach (Timer t in timeoutTimers.Values)
                t?.Destroy();
        }

        // server initialized
        void OnServerInitialized()
        {
            SetAll(ConfigValue<bool>(Option.lockable), ConfigValue<float>(Option.health));
            if (ConfigValue<bool>(Option.saveLocks))
                LoadLocks();
            currencyItem = ItemManager.FindItemDefinition(ConfigValue<string>(Option.currencyItem)) ?? ItemManager.FindItemDefinition("blood"); // assume blood if item missing
            useEconomics = ConfigValue<bool>(Option.useEconomics);
            useServerRewards = ConfigValue<bool>(Option.useServerRewards);
            Check(true);
        }

        void OnPluginLoaded(Plugin plugin)
        {
            if (plugin.Name == "Economics" && ConfigValue<bool>(Option.useEconomics))
                Check();
            else if (plugin.Name == "ServerRewards" && ConfigValue<bool>(Option.useServerRewards))
                Check();
        }

        void OnPluginUnloaded(Plugin plugin)
        {
            if (plugin.Name == "Economics" && ConfigValue<bool>(Option.useEconomics))
                Check();
            else if (plugin.Name == "ServerRewards" && ConfigValue<bool>(Option.useServerRewards))
                Check();
        }

        void Check(bool initial = false)
        {
            bool prev = useEconomics;
            useEconomics = ConfigValue<bool>(Option.useEconomics) && Economics != null;
            if (initial || prev != useEconomics)
                Puts("Economics " + (useEconomics ? "detected - money purchases enabled" : "not detected - money purchases disabled"));
            prev = useServerRewards;
            useServerRewards = ConfigValue<bool>(Option.useServerRewards) && ServerRewards != null;
            if (initial || prev != useServerRewards)
                Puts("ServerRewards " + (useServerRewards ? "detected - RP purchases enabled" : "not detected - RP purchases disabled"));
            if (useEconomics && useServerRewards)
            {
                useServerRewards = false;
                PrintWarning(GetMessage("WarnEconAndSREnabled"));
            }
        }

        // save/destroy locks on server shutdown to avoid NULL in saveList
        void OnServerShutdown()
        {
            isShuttingDown = true;
        }

        // save locks when server saves
        void OnServerSave()
        {
            // delayed save to fight the lag monster
            timer.In(5f, () => SaveVMsAndLocks());
        }

        #endregion

        #region Configuration

        // load default config
        bool LoadDefaultConfig()
        {
            data = new VendingData();
            CheckConfig();
            data.templates = new Dictionary<string, SellOrderTemplate>();
            return true;
        }

        void LoadData()
        {
            bool dirty = false;
            try
            {
                data = Config.ReadObject<VendingData>();
            }
            catch (Exception) { }
            dirty = CheckConfig();
            if (data.templates == null)
                dirty |= LoadDefaultConfig();
            if (dirty)
                SaveData();
            vms = Interface.GetMod()?.DataFileSystem?.ReadObject<Dictionary<ulong, VendingMachineInfo>>("VendingManagerVMs");
            locks = Interface.GetMod()?.DataFileSystem?.ReadObject<Dictionary<ulong, LockInfo>>("VendingManagerLocks");
        }

        // write data container to config
        void SaveData()
        {
            Config.WriteObject(data);
        }

        void SaveVendingMachineData()
        {
            foreach (ulong k in vms.Keys.ToList())
            {
                BaseNetworkable net = BaseNetworkable.serverEntities.Find(new NetworkableId(k));
                VendingMachine vm = net as VendingMachine;
                if (net == null || vm == null)
                    vms.Remove(k);
            }
            Interface.GetMod().DataFileSystem.WriteObject("VendingManagerVMs", vms);
        }

        // save locks data to file
        void SaveLocksData()
        {
            Interface.GetMod().DataFileSystem.WriteObject("VendingManagerLocks", locks);
        }

        // get value from config (handles type conversion)
        T GetConfig<T>(string group, string name, T value)
        {
            if (Config[group, name] == null)
            {
                Config[group, name] = value;
                SaveConfig();
            }
            return (T)Convert.ChangeType(Config[group, name], typeof(T));
        }

        // validate configuration
        bool CheckConfig()
        {
            bool dirty = false;
            foreach (Option option in Enum.GetValues(typeof(Option)))
                if (!data.config.ContainsKey(option))
                {
                    data.config[option] = defaults[(int)option];
                    dirty = true;
                }
            return dirty;
        }

        #endregion

        #region Hooks

        // set newly spawned vending machines to the value of lockable
        void OnEntitySpawned(BaseNetworkable entity)
        {
            if (entity.GetType() == typeof(VendingMachine))
                Set(entity as VendingMachine, ConfigValue<bool>(Option.lockable), ConfigValue<float>(Option.health));
        }

        // block unauthorized lock deployment onto vending machines
        // only allow attachment from the rear, except if player is
        // the owner of the vending machine
        void OnItemDeployed(Deployer deployer, BaseEntity entity)
        {
            if (!ConfigValue<bool>(Option.lockable)) return;
            if (deployer == null || entity == null) return;
            BasePlayer player = deployer.GetOwnerPlayer();
            VendingMachine vm = entity as VendingMachine;
            if (vm == null || player == null) return;
            if (deployer.GetDeployable().slot == BaseEntity.Slot.Lock && !(vm.CanPlayerAdmin(player) || player.userID == vm.OwnerID))
            {
                BaseLock lockEntity = vm.GetSlot(BaseEntity.Slot.Lock) as BaseLock;
                if (lockEntity == null) return;
                deployer.GetItem().amount++;
                lockEntity.Kill();
                if (ConfigValue<bool>(Option.lockFailureMessage))
                    SendMessage(player, "NotAuthorized");
            }
        }

        // handle blocking broadcasting
        void OnToggleVendingBroadcast(VendingMachine vm, BasePlayer player)
        {
            if (ConfigValue<bool>(Option.noBroadcast))
            {
                vm.SetFlag(BaseEntity.Flags.Reserved4, false, false);
                SendMessage(player, "NoBroadcast");
            }
        }

        object OnVendingTransaction(VendingMachine vm, BasePlayer player, int sellOrderId, int numTransactions)
        {
            var cvsResult = CustomVendingSetup?.Call("API_IsCustomized", vm);
            if (cvsResult is bool && (bool)cvsResult)
                return null;

            VendingMachineInfo i;
            vms.TryGetValue(vm.net.ID.Value, out i);
            bool bottomless = i == null ? false : i.HasFlag(VendingMachineInfo.VMFlags.Bottomless);

            bool log = ConfigValue<bool>(Option.logTransSuccess) || ConfigValue<bool>(Option.logTransFailure) || (i != null && i.HasFlag(VendingMachineInfo.VMFlags.LogTransactions));
            bool force = i != null && i.HasFlag(VendingMachineInfo.VMFlags.LogTransactions);
            ProtoBuf.VendingMachine.SellOrder sellOrder = vm.sellOrders.sellOrders[sellOrderId];

            bool isCurrencySellOrder = (useEconomics || useServerRewards) && sellOrder.currencyID == currencyItem.itemid;
            bool isCurrencyBuyOrder = (useEconomics || useServerRewards) && sellOrder.itemToSellID == currencyItem.itemid;

            LogEntry logEntry = new LogEntry();
            if (log)
            {
                logEntry.id = vm.net.ID;
                logEntry.playerID = player.userID;
                logEntry.playerName = player.displayName;
            }

            List<Item> items = vm.inventory.FindItemsByItemID(sellOrder.itemToSellID);
            if (sellOrder.itemToSellIsBP)
            {
                items = (
                    from x in vm.inventory.FindItemsByItemID(vm.blueprintBaseDef.itemid)
                    where x.blueprintTarget == sellOrder.itemToSellID
                    select x).ToList();
            }
            if (items == null || items.Count == 0)
            {
                return false;
            }
            int numberOfTransactions = Mathf.Clamp(numTransactions, 1, (!items[0].hasCondition ? 1000000 : 1));
            int sellCount = sellOrder.itemToSellAmount * numberOfTransactions;
            int buyCount = sellOrder.currencyAmountPerItem * numberOfTransactions;

            if (sellCount > items.Sum(x => x.amount))
                return false;

            int cost = 0;
            if (!isCurrencySellOrder)
            {
                int num2 = sellOrder.currencyAmountPerItem * numberOfTransactions;

                if (log) logEntry.cost = num2 + " " + ItemManager.FindItemDefinition(sellOrder.currencyID).displayName.translated + (sellOrder.currencyIsBP ? " (BP)" : "");

                List<Item> items1 = player.inventory.FindItemsByItemID(sellOrder.currencyID);
                if (sellOrder.currencyIsBP)
                {
                    items1 = (
                        from x in player.inventory.FindItemsByItemID(vm.blueprintBaseDef.itemid)
                        where x.blueprintTarget == sellOrder.currencyID
                        select x).ToList();
                }
                if (items1.Count == 0)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.NoItems;
                        LogTransaction(logEntry, force);
                    }
                    return false;
                }

                int num1 = items1.Sum(x => x.amount);
                if (num1 < num2)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.NoItems;
                        LogTransaction(logEntry, force);
                    }
                    return false;
                }

                vm.transactionActive = true;
                int num3 = 0;
                Item item;
                foreach (Item item2 in items1)
                {
                    int num4 = Mathf.Min(num2 - num3, item2.amount);
                    item = (item2.amount > num4 ? item2.SplitItem(num4) : item2);
                    if (bottomless)
                        item.Remove();
                    else
                        if (!item.MoveToContainer(vm.inventory, -1, true))
                    {
                        item.Drop(vm.inventory.dropPosition, Vector3.zero, new Quaternion());
                    }
                    num3 = num3 + num4;
                    if (num3 < num2)
                        continue;
                    break;
                }
            }
            else
            {
                cost = sellOrder.currencyAmountPerItem * numberOfTransactions;
                if (log)
                {
                    logEntry.isBuyOrder = true;
                    logEntry.cost = string.Format("{0:C}", cost);
                }
                double money = GetBalance(player.userID);
                if (money < 1.0)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.NoMoney;
                        LogTransaction(logEntry, force);
                    }
                    return false;
                }

                if (Mathf.FloorToInt((float)money) < cost)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.NoMoney;
                        LogTransaction(logEntry, force);
                    }
                    SendMessage(player, currencyPlugin + "NotEnoughMoney");
                    return false;
                }

                vm.transactionActive = true;
                bool success = false;
                if (bottomless)
                    success = Withdraw(player.userID, cost);
                else
                    success = Transfer(player.userID, vm.OwnerID, cost);

                if (!success)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.Unknown;
                        LogTransaction(logEntry, force);
                    }
                    SendMessage(player, currencyPlugin + "TransferFailed");
                    vm.transactionActive = false;
                    return false;
                }
            }
            int amount = 0;
            if (isCurrencyBuyOrder)
            {
                amount = sellOrder.itemToSellAmount * numberOfTransactions;
                if (log)
                {
                    logEntry.isBuyOrder = false;
                    logEntry.cost = string.Format("{0:C}", amount);
                    logEntry.bought = sellOrder.currencyAmountPerItem + " " + ItemManager.FindItemDefinition(sellOrder.currencyID).displayName.translated;
                }

                double money = GetBalance(vm.OwnerID);
                if (!bottomless)
                {
                    if (money < 1.0)
                    {
                        if (log)
                        {
                            logEntry.success = false;
                            logEntry.reason = LogEntry.FailureReason.NoMoney;
                            LogTransaction(logEntry, force);
                        }
                        return false;
                    }

                    if (Mathf.FloorToInt((float)money) < amount)
                    {
                        if (log)
                        {
                            logEntry.success = false;
                            logEntry.reason = LogEntry.FailureReason.NoMoney;
                            LogTransaction(logEntry, force);
                        }
                        SendMessage(player, currencyPlugin + "NotEnoughMoneyOwner");
                        return false;
                    }
                }

                vm.transactionActive = true;
                bool success = false;
                if (bottomless)
                    success = Deposit(player.userID, amount);
                else
                    success = Transfer(vm.OwnerID, player.userID, amount);

                if (!success)
                {
                    if (log)
                    {
                        logEntry.success = false;
                        logEntry.reason = LogEntry.FailureReason.Unknown;
                        LogTransaction(logEntry, force);
                    }
                    SendMessage(player, currencyPlugin + "TransferFailed");
                    vm.transactionActive = false;
                    return false;
                }
            }
            else
            {
                if (log) logEntry.bought = sellOrder.itemToSellAmount + " " + ItemManager.FindItemDefinition(sellOrder.itemToSellID).displayName.translated + (sellOrder.itemToSellIsBP ? " (BP)" : "");
                if (!bottomless)
                {
                    int num5 = 0;
                    Item item1 = null;
                    foreach (Item item3 in items)
                    {
                        item1 = (item3.amount > sellCount ? item3.SplitItem(sellCount) : item3);
                        num5 = num5 + item1.amount;
                        player.GiveItem(item1, BaseEntity.GiveItemReason.PickedUp);
                        if (num5 < sellCount)
                            continue;
                        break;
                    }
                }
                else
                {
                    Item item = null;
                    if (sellOrder.itemToSellIsBP)
                    {
                        item = ItemManager.CreateByItemID(vm.blueprintBaseDef.itemid, sellCount);
                        item.blueprintTarget = sellOrder.itemToSellID;
                    }
                    else
                        item = ItemManager.CreateByItemID(sellOrder.itemToSellID, sellCount, vm.inventory.FindItemsByItemID(sellOrder.itemToSellID).Select(e => e.skin).FirstOrDefault());
                    player.GiveItem(item, BaseEntity.GiveItemReason.PickedUp);
                }
            }

            vm.UpdateEmptyFlag();
            vm.transactionActive = false;

            if (ConfigValue<bool>(Option.transMessages) && isCurrencySellOrder && cost > 0)
            {
                double remaining = GetBalance(player.userID);
                SendMessage(player, currencyPlugin + "PurchaseSuccess", new object[] { sellCount, ItemManager.FindItemDefinition(sellOrder.itemToSellID).displayName.translated, cost, remaining });
            }
            else if (ConfigValue<bool>(Option.transMessages) && isCurrencyBuyOrder && amount > 0)
            {
                double balance = GetBalance(player.userID);
                SendMessage(player, currencyPlugin + "SellSuccess", new object[] { buyCount, ItemManager.FindItemDefinition(sellOrder.currencyID).displayName.translated, amount, balance });
            }
            if (log)
            {
                logEntry.success = true;
                LogTransaction(logEntry, force);
            }

            return true;
        }

        // override administration if restricted access on
        object CanAdministerVending(VendingMachine vm, BasePlayer player)
        {
            bool restricted = ConfigValue<bool>(Option.restricted);
            if (!restricted)
            {
                VendingMachineInfo i;
                if (vms.TryGetValue(vm.net.ID.Value, out i))
                    restricted = i.HasFlag(VendingMachineInfo.VMFlags.Restricted);
            }
            if (restricted && vm.OwnerID != player.userID && !player.IsAdmin)
            {
                SendMessage(player, "Restricted");
                return false;
            }
            return null;
        }

        // block damage for Immortal vending machines
        void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo hitinfo)
        {
            if (entity == null || hitinfo == null) return;
            if (entity is VendingMachine)
            {
                VendingMachineInfo i;
                if (vms.TryGetValue(entity.net.ID.Value, out i))
                {
                    if (i.HasFlag(VendingMachineInfo.VMFlags.Immortal))
                        hitinfo.damageTypes = new DamageTypeList();
                }
            }
        }

        // hack to show vending buttons - when VM shop opened, add currency items
        // to hidden inventory slot to represent current economics balance
        void OnVendingShopOpened(VendingMachine vm, BasePlayer player)
        {
            if (!useEconomics && !useServerRewards) return;
            if (vm.sellOrders.sellOrders.Count == 0) return;

            bool hasCurrencySellOrder = false;
            bool hasCurrencyBuyOrder = true;
            // create and add items to player inventory to prevent "Can't Afford" button
            foreach (ProtoBuf.VendingMachine.SellOrder so in vm.sellOrders.sellOrders)
            {
                if (so.currencyID == currencyItem.itemid)
                    hasCurrencySellOrder = true;
                else if (so.itemToSellID == currencyItem.itemid)
                    hasCurrencyBuyOrder = true;
            }
            if (!hasCurrencySellOrder && !hasCurrencyBuyOrder) return;

            int playerMoney = 0;
            if (hasCurrencyBuyOrder)
            {
                vm.inventory.capacity = currencyIndex + 1;
                VendingMachineInfo i;
                vms.TryGetValue(vm.net.ID.Value, out i);
                bool bottomless = i == null ? false : i.HasFlag(VendingMachineInfo.VMFlags.Bottomless);
                if (bottomless)
                {
                    Item money = ItemManager.CreateByItemID(currencyItem.itemid, 10000, 0);
                    money.MoveToContainer(vm.inventory, currencyIndex, true);
                }
                else
                {
                    playerMoney = Mathf.FloorToInt((float)GetBalance(vm.OwnerID));
                    if (playerMoney > 0)
                    {
                        Item money = ItemManager.CreateByItemID(currencyItem.itemid, playerMoney);
                        money.MoveToContainer(vm.inventory, currencyIndex, true);
                    }
                }
                /*
                int lastMoney = playerMoney;
                transactionTimers[player.userID] = timer.Every(0.5f, () => {
                    int m = Mathf.FloorToInt((float)GetBalance(vm.OwnerID));
                    if (lastMoney != m)
                    {
                        lastMoney = m;
                        Item item = vm.inventory.GetSlot(currencyIndex);
                        if (item != null)
                        {
                            if (lastMoney == 0)
                                item.Remove();
                            else
                                item.amount = lastMoney;
                            vm.RefreshSellOrderStockLevel();
                        }
                    }
                });
                */
            }
            playerMoney = 0;
            if (hasCurrencySellOrder)
            {
                player.inventory.containerMain.capacity = currencyIndex + 1;
                playerMoney = Mathf.FloorToInt((float)GetBalance(player.userID));
                if (playerMoney > 0)
                {
                    Item money = ItemManager.CreateByItemID(currencyItem.itemid, playerMoney, 0);
                    money.MoveToContainer(player.inventory.containerMain, currencyIndex, true);
                }

                int lastMoney = playerMoney;
                transactionTimers[player.userID] = timer.Every(0.5f, () => {
                    int m = Mathf.FloorToInt((float)GetBalance(player.userID));
                    if (lastMoney != m)
                    {
                        if (lastMoney == 0 && m > 0)
                        {
                            Item money = ItemManager.CreateByItemID(currencyItem.itemid, m, 0);
                            money.MoveToContainer(player.inventory.containerMain, currencyIndex, true);
                        }
                        lastMoney = m;
                        Item item = player.inventory.containerMain.GetSlot(currencyIndex);
                        if (item != null)
                        {
                            if (lastMoney == 0)
                                item.Remove();
                            else
                                item.amount = lastMoney;
                            player.inventory.SendSnapshot();
                        }
                    }
                });
            }
            if (ConfigValue<float>(Option.transactionTimeout) > 0f)
                timeoutTimers[player.userID] = timer.Once(ConfigValue<float>(Option.transactionTimeout), () => player.EndLooting());
        }

        // when VM shop closed, remove all currency items from player's inventory
        void OnLootEntityEnd(BasePlayer player, BaseEntity entity)
        {
            if ((!useEconomics && !useServerRewards) || entity == null || !(entity is VendingMachine)) return;
            if (transactionTimers.ContainsKey(player.userID))
                transactionTimers[player.userID]?.Destroy();
            if (timeoutTimers.ContainsKey(player.userID))
                timeoutTimers[player.userID]?.Destroy();

            int i = player.inventory.containerMain.capacity;
            while (i >= currencyIndex)
                player.inventory.containerMain.GetSlot(i--)?.Remove();
            Item b = player.inventory.containerMain.FindItemByItemID(currencyItem.itemid);
            if (b != null) b.Remove();
            player.inventory.containerMain.capacity = currencyIndex;

            VendingMachine vm = entity as VendingMachine;
            int j = vm.inventory.capacity;
            while (j >= currencyIndex)
                vm.inventory.GetSlot(j--)?.Remove();
            Item c = vm.inventory.FindItemByItemID(currencyItem.itemid);
            if (c != null) c.Remove();
            vm.inventory.capacity = currencyIndex;
        }

        // on rotate, send network update for lock position
        void OnRotateVendingMachine(VendingMachine vm, BasePlayer player)
        {
            BaseLock l = vm.GetSlot(BaseEntity.Slot.Lock) as BaseLock;
            if (l != null)
                NextTick(() => l.SendNetworkUpdate());
        }

        #endregion

        #region Command Handling

        // command delegator
        void CommandDelegator(BasePlayer player, string command, string[] args)
        {
            if (!hasPermission(player, PermCanUse))
            {
                SendReply(player, GetMessage("NotAllowed", player.UserIDString));
                return;
            }
            string message = "InvalidCommand";
            // assume args[0] is the command (beyond /vm)
            if (args != null && args.Length > 0)
                command = args[0];
            // shift arguments
            if (args != null)
            {
                if (args.Length > 1)
                    args = args.Skip(1).ToArray();
                else
                    args = new string[] { };
            }
            object[] opts = new object[] { command };
            if (Enum.IsDefined(typeof(Command), command))
            {
                switch ((Command)Enum.Parse(typeof(Command), command))
                {
                    case Command.add:
                        HandleLoad(player, args, false, out message, out opts);
                        break;
                    case Command.clear:
                        HandleClear(player, out message);
                        break;
                    case Command.eject:
                        if (hasPermission(player, PermCanEject))
                            EjectAll(out message, out opts);
                        break;
                    case Command.info:
                        HandleInfo(player, out message, out opts);
                        break;
                    case Command.list:
                        HandleList(out message, out opts);
                        break;
                    case Command.load:
                        HandleLoad(player, args, true, out message, out opts);
                        break;
                    case Command.reset:
                        HandleReset(args, out message);
                        break;
                    case Command.save:
                        HandleSave(player, args, out message, out opts);
                        break;
                    case Command.set:
                        HandleSet(player, args, out message, out opts);
                        break;
                    case Command.unset:
                        HandleSet(player, args, out message, out opts, true);
                        break;
                    default:
                        break;
                }
            }
            else
                ShowCommands(out message, out opts);
            if (message != null && message != "")
                SendMessage(player, message, opts);
        }

        // handle reset command
        void HandleReset(string[] args, out string message)
        {
            bool confirm = (args.Length > 0 && args[0] != null && args[0].ToLower() == "confirm");
            if (confirm)
            {
                Config.Clear();
                data = new VendingData();
                SaveData();
                message = "ResetSuccess";
            }
            else
                message = "ConfirmReset";
        }

        // handle clear command
        void HandleClear(BasePlayer player, out string message)
        {
            message = "VMNotFound";
            object entity;
            if (GetRaycastTarget(player, out entity))
                if (entity != null && entity is VendingMachine)
                {
                    (entity as VendingMachine).sellOrders.sellOrders.Clear();
                    message = "ClearSuccess";
                }
        }

        void HandleInfo(BasePlayer player, out string message, out object[] opts)
        {
            message = "VMNotFound";
            opts = new object[] { };
            object entity;
            if (GetRaycastTarget(player, out entity))
                if (entity != null && entity is VendingMachine)
                {
                    ulong id = (entity as VendingMachine).net.ID.Value;
                    bool isConfigured = vms.ContainsKey(id);
                    string flags = "None";
                    if (isConfigured)
                        flags = vms[id].flags.ToString();
                    message = "Information";
                    opts = new object[] { id, isConfigured, flags };
                }
        }

        // handle load command
        void HandleLoad(BasePlayer player, string[] args, bool replaceAll, out string message, out object[] opts)
        {
            message = "";
            opts = new object[] { };
            if (args == null || args.Length == 0 || args[0] == null || args[0] == "")
            {
                message = "InvalidParameter";
                return;
            }
            object entity;
            if (!GetRaycastTarget(player, out entity))
            {
                message = "VMNotFound";
                return;
            }

            opts = new object[] { args[0] };
            if (entity != null && entity is VendingMachine)
                LoadSellOrders(entity as VendingMachine, args[0], replaceAll, out message);
        }

        // handle loading the sell orders into a vending machine
        void LoadSellOrders(VendingMachine vm, string templateName, bool replace, out string message)
        {
            message = "LoadSuccess";
            if (!data.templates.ContainsKey(templateName))
            {
                message = "TemplateNotFound";
                return;
            }
            if (data.templates[templateName].Empty())
            {
                message = "EmptyTemplate";
                return;
            }
            if (replace)
                vm.sellOrders.sellOrders.Clear();
            foreach (SellOrderEntry e in data.templates[templateName].entries)
            {
                ProtoBuf.VendingMachine.SellOrder o = new ProtoBuf.VendingMachine.SellOrder();
                o.itemToSellID = ItemManager.FindItemDefinition(e.itemToSellName).itemid;
                o.itemToSellAmount = e.itemToSellAmount;
                o.itemToSellIsBP = e.itemToSellIsBP;
                o.currencyID = ItemManager.FindItemDefinition(e.currencyName).itemid;
                o.currencyAmountPerItem = e.currencyAmountPerItem;
                o.currencyIsBP = e.currencyIsBP;
                vm.sellOrders.sellOrders.Add(o);
            }
            vm.RefreshSellOrderStockLevel();
            return;
        }

        // handle save command
        void HandleSave(BasePlayer player, string[] args, out string message, out object[] opts)
        {
            message = "";
            opts = new object[] { };
            if (args == null || args.Length == 0 || args[0] == null || args[0] == "")
            {
                message = "InvalidParameter";
                return;
            }
            bool overwrite = (args.Length > 1 && args[1] != null && args[1].ToLower() == "overwrite");
            object entity;
            if (!GetRaycastTarget(player, out entity))
            {
                message = "VMNotFound";
                return;
            }
            opts = new object[] { args[0] };
            if (entity != null && entity is VendingMachine)
                SaveSellOrders(entity as VendingMachine, args[0], out message, overwrite);
        }

        // handle saving the sell orders from a vending machine
        void SaveSellOrders(VendingMachine vm, string templateName, out string message, bool overwrite = false)
        {
            message = "SaveSuccess";
            if (templateName == null || templateName == "")
            {
                message = "InvalidParameter";
                return;
            }
            if (data.templates.ContainsKey(templateName) && !overwrite)
            {
                message = "TemplateExists";
                return;
            }

            ProtoBuf.VendingMachine.SellOrderContainer sellOrderContainer = vm.sellOrders;
            if (sellOrderContainer == null || sellOrderContainer.sellOrders == null || sellOrderContainer.sellOrders.Count == 0)
            {
                message = "EmptyVM";
                return;
            }
            SellOrderTemplate template = new SellOrderTemplate();
            template.PopulateTemplate(sellOrderContainer.sellOrders);
            if (!template.Empty())
            {
                data.templates[templateName] = template;
                SaveData();
            }
            return;
        }

        // handle set/unset flag
        void HandleSet(BasePlayer player, string[] args, out string message, out object[] opts, bool unset = false)
        {
            message = unset ? "UnsetSuccess" : "SetSuccess";
            opts = new object[] { };
            if (args == null || args.Length == 0 || args[0] == null || args[0] == "" || !Enum.IsDefined(typeof(VendingMachineInfo.VMFlags), args[0]))
            {
                message = "InvalidParameter";
                return;
            }
            object entity;
            if (!GetRaycastTarget(player, out entity))
            {
                message = "VMNotFound";
                return;
            }
            opts = new object[] { args[0] };
            if (entity != null && entity is VendingMachine)
            {
                VendingMachineInfo.VMFlags flags = (VendingMachineInfo.VMFlags)Enum.Parse(typeof(VendingMachineInfo.VMFlags), args[0]);
                VendingMachineInfo i;
                if (!vms.TryGetValue((entity as VendingMachine).net.ID.Value, out i))
                {
                    i = new VendingMachineInfo();
                    i.id = (entity as VendingMachine).net.ID.Value;
                    vms[i.id] = i;
                }
                if (unset)
                    i.flags &= ~flags;
                else
                    i.flags |= flags;
                if (i.flags == VendingMachineInfo.VMFlags.None)
                    vms.Remove(i.id);
                SaveVendingMachineData();
            }
        }

        #endregion

        #region Messaging

        // send reply to a player
        void SendMessage(BasePlayer player, string message, object[] options = null)
        {
            string msg = GetMessage(message, player.UserIDString);
            if (options != null && options.Length > 0)
                msg = String.Format(msg, options);
            SendReply(player, GetMessage("Prefix", player.UserIDString) + msg);
        }

        // handle list command
        void HandleList(out string message, out object[] opts)
        {
            message = "TemplateList";
            opts = new object[] { data.GetTemplateList() };
        }

        // show list of valid commands
        void ShowCommands(out string message, out object[] opts)
        {
            message = "CommandList";
            opts = new object[] { string.Join(", ", Enum.GetValues(typeof(Command)).Cast<Command>().Select(x => x.ToString()).ToArray()) };
        }

        void LogTransaction(LogEntry logEntry, bool force = false)
        {
            if ((ConfigValue<bool>(Option.logTransSuccess) && logEntry.success) || (ConfigValue<bool>(Option.logTransFailure) && !logEntry.success) || force)
            {
                string logString = logEntry.ToString();
                LogToFile("Transactions", logString, this, true);
            }
        }

        #endregion

        #region Helper Procedures

        // set all vending machines
        void SetAll(bool lockable, float health = defaultHealth)
        {
            foreach(var ent in BaseNetworkable.serverEntities)
            {
                if (ent is VendingMachine)
                {
                    Set(ent as VendingMachine, lockable, health);
                }
            }
        }

        // setup a specific vending machine
        void Set(VendingMachine vm, bool lockable, float health = defaultHealth, bool restoreProtection = false)
        {
            if (ConfigValue<bool>(Option.noBroadcast))
                vm.SetFlag(BaseEntity.Flags.Reserved4, false, false);
            if (defaultProtection == null)
            {
                defaultProtection = vm.baseProtection;
                if (data.resistances == null)
                {
                    data.SetResistances(defaultProtection.amounts);
                    SaveData();
                }
            }
            else
            {
                if (customProtection == null)
                    customProtection = UnityEngine.Object.Instantiate(vm.baseProtection) as ProtectionProperties;
                if (data.resistances != null && !restoreProtection)
                {
                    customProtection.amounts = data.GetResistances();
                    vm.baseProtection = customProtection;
                    //vm.baseProtection.amounts = data.GetResistances();
                }
                if (restoreProtection)
                    vm.baseProtection = defaultProtection;
            }
            if (!lockable && ConfigValue<bool>(Option.ejectLocks)) Eject(vm);
            vm.isLockable = lockable;
            if (ConfigValue<bool>(Option.setHealth))
            {
                float h = health * vm.healthFraction;
                vm.InitializeHealth(h, health);
                vm.SendNetworkUpdate();
            }
        }

        // eject lock from vending machine
        bool Eject(VendingMachine m)
        {
            BaseEntity lockEntity = m.GetSlot(BaseEntity.Slot.Lock);
            if (lockEntity != null && lockEntity is BaseLock)
            {
                Item lockItem = ItemManager.Create((lockEntity as BaseLock).itemType, 1, lockEntity.skinID);
                lockEntity.Kill();
                lockItem.Drop(m.GetDropPosition(), m.GetDropVelocity(), m.transform.rotation);
                m.isLockable = ConfigValue<bool>(Option.lockable);
                return true;
            }
            return false;
        }

        // eject locks from all vending machines
        void EjectAll(out string message, out object[] opts)
        {
            int counter = 0;
            foreach (VendingMachine m in GameObject.FindObjectsOfType(typeof(VendingMachine)))
                if (Eject(m)) counter++;

            message = "Ejected";
            opts = new object[] { counter };
        }

        // raycast to find entity being looked at
        bool GetRaycastTarget(BasePlayer player, out object closestEntity)
        {
            closestEntity = null;
            RaycastHit hit;
            if (!Physics.Raycast(player.eyes.HeadRay(), out hit, 5f))
                return false;
            closestEntity = hit.GetEntity();
            return true;
        }

        // check if player is an admin
        private static bool isAdmin(BasePlayer player)
        {
            if (player?.net?.connection == null) return true;
            return player.net.connection.authLevel > 0;
        }

        // check if player has permission or is an admin
        private bool hasPermission(BasePlayer player, string permname)
        {
            //return isAdmin(player) || permission.UserHasPermission(player.UserIDString, permname);
            return permission.UserHasPermission(player.UserIDString, permname);
        }

        // get config value and convert type
        T ConfigValue<T>(Option option)
        {
            return (T)Convert.ChangeType(data.config[option], typeof(T));
        }

        // save all locks
        void SaveVMsAndLocks()
        {
            locks.Clear();
            foreach (VendingMachine vm in GameObject.FindObjectsOfType(typeof(VendingMachine)))
            {
                BaseLock l = (BaseLock)vm.GetSlot(BaseEntity.Slot.Lock);
                if (l == null) continue;

                LockInfo li = new LockInfo(vm.net.ID.Value, l);
                locks[vm.net.ID.Value] = li;
            }
            SaveVendingMachineData();
            SaveLocksData();
        }

        // load all locks
        void LoadLocks()
        {
            foreach (LockInfo li in locks.Values)
            {
                VendingMachine vm = (VendingMachine)BaseNetworkable.serverEntities.Find(new NetworkableId(li.vmId));
                if (vm == null) continue;
                if (vm.GetSlot(BaseEntity.Slot.Lock) != null) continue;

                BaseLock l;
                if (li.isCodeLock)
                    l = (CodeLock)GameManager.server.CreateEntity(CodeLockPrefab);
                else
                    l = (KeyLock)GameManager.server.CreateEntity(KeyLockPrefab);

                if (l == null) continue;

                l.gameObject.Identity();
                li.ToLock(ref l);
                l.SetParent(vm, vm.GetSlotAnchorName(BaseEntity.Slot.Lock));
                l.OnDeployed(vm, null, null);
                l.Spawn();
                vm.SetSlot(BaseEntity.Slot.Lock, l);
            }
        }

        // Destroy all attached locks on shutdown
        void DestroyLocks()
        {
            foreach (VendingMachine vm in GameObject.FindObjectsOfType(typeof(VendingMachine)))
            {
                BaseEntity l;
                if ((l = vm.GetSlot(BaseEntity.Slot.Lock)) != null)
                    l.Kill();
            }
        }

        double GetBalance(ulong playerId)
        {
            if (useEconomics)
            {
                return (double)Economics.CallHook("Balance", playerId.ToString());
            }
            else if (useServerRewards)
            {
                return (int)(ServerRewards.CallHook("CheckPoints", playerId) ?? 0.0);
            }
            return 0.0;
        }

        bool Withdraw(ulong playerId, double amount)
        {
            if (useEconomics)
            {
                return (bool)Economics.CallHook("Withdraw", playerId.ToString(), amount);
            }
            else if (useServerRewards)
            {
                return (bool)ServerRewards.CallHook("TakePoints", playerId, (int)amount);
            }
            return false;
        }

        bool Deposit(ulong playerId, double amount)
        {
            if (useEconomics)
            {
                Economics.CallHook("Deposit", playerId.ToString(), amount);
                return true;
            }
            else if (useServerRewards)
            {
                return (bool)ServerRewards.Call("AddPoints", new object[] { playerId, (int)amount });
            }
            return false;
        }

        bool Transfer(ulong fromId, ulong toId, double amount)
        {
            if (useEconomics)
            {
                return (bool)Economics.CallHook("Transfer", fromId.ToString(), toId.ToString(), amount);
            }
            else if (useServerRewards)
            {
                if (Withdraw(fromId, amount))
                {
                    bool result = Deposit(toId, amount);
                    if (!result)
                        Deposit(fromId, amount); // if transfer failed, refund
                    return result;
                }
            }
            return false;
        }

        #endregion

        #region Subclasses

        // config/data container
        class VendingData
        {
            public Dictionary<Option, object> config = new Dictionary<Option, object>();
            public Dictionary<DamageType, float> resistances;
            public Dictionary<string, SellOrderTemplate> templates;

            public string GetTemplateList()
            {
                string list = string.Join(", ", templates.Keys.ToArray());
                if (list == null || list == "")
                    list = "(empty)";
                return list;
            }

            public void SetResistances(float[] amounts)
            {
                resistances = new Dictionary<DamageType, float>();
                for (int i = 0; i < amounts.Length; i++)
                    resistances[(DamageType)i] = amounts[i];
            }

            public float[] GetResistances()
            {
                float[] values = new float[30];
                if (resistances != null)
                    foreach (KeyValuePair<DamageType, float> entry in resistances)
                        values[(int)entry.Key] = entry.Value;
                return values;
            }
        }

        // helper class for building sell order entries
        class SellOrderTemplate
        {
            public List<SellOrderEntry> entries = new List<SellOrderEntry>();

            public void PopulateTemplate(List<ProtoBuf.VendingMachine.SellOrder> sellOrders)
            {
                if (sellOrders == null) return;
                foreach (ProtoBuf.VendingMachine.SellOrder o in sellOrders)
                    AddSellOrder(o);
            }

            public void AddSellOrder(ProtoBuf.VendingMachine.SellOrder o)
            {
                if (o == null) return;
                SellOrderEntry e = new SellOrderEntry();
                e.itemToSellName = ItemManager.FindItemDefinition(o.itemToSellID).shortname;
                e.itemToSellAmount = o.itemToSellAmount;
                e.currencyName = ItemManager.FindItemDefinition(o.currencyID).shortname;
                e.currencyAmountPerItem = o.currencyAmountPerItem;
                e.itemToSellIsBP = o.itemToSellIsBP;
                e.currencyIsBP = o.currencyIsBP;
                entries.Add(e);
            }

            public bool Empty()
            {
                return (entries == null || entries.Count == 0);
            }
        }

        // simple sell order entry container
        struct SellOrderEntry
        {
            public string itemToSellName;
            public int itemToSellAmount;
            public string currencyName;
            public int currencyAmountPerItem;
            public bool itemToSellIsBP;
            public bool currencyIsBP;
        }

        struct LogEntry
        {
            public enum FailureReason { NoMoney, NoItems, Unknown }
            public NetworkableId id;
            public ulong playerID;
            public string playerName;
            public string bought;
            public string cost;
            public bool success;
            public bool isBuyOrder;
            public FailureReason reason;

            public override string ToString()
            {
                if (isBuyOrder)
                    return "VM " + id + ": " + playerName + " [" + playerID + "] " + (success ? "bought " : "failed to buy ") + bought + " for " + cost + (success ? "" : " - Reason: " + GetReason());
                else
                    return "VM " + id + ": " + playerName + " [" + playerID + "] " + (success ? "sold " : "failed to sell ") + bought + " for " + cost + (success ? "" : " - Reason: " + GetReason());
            }
            string GetReason()
            {
                if (reason == FailureReason.NoItems)
                    return "Not enough currency items";
                if (reason == FailureReason.NoMoney)
                    return "Not enough money";
                return "Unknown reason";
            }
        }

        class VendingMachineInfo
        {
            [Flags]
            public enum VMFlags
            {
                None = 0,
                Bottomless = 1,
                Immortal = 1 << 1,
                Restricted = 1 << 2,
                LogTransactions = 1 << 3
            }
            public ulong id;
            [JsonConverter(typeof(StringEnumConverter))]
            public VMFlags flags;
            public bool HasFlag(VMFlags flag) => (flags & flag) == flag;
        }

        // Lock details container
        class LockInfo
        {
            static readonly byte[] entropy = new byte[] { 11, 7, 5, 3 };
            public ulong vmId;
            public bool isCodeLock = false;
            public string codeEncrypted;
            [JsonIgnore]
            public string code
            {
                get
                {
                    return Shift(codeEncrypted, -4);
                }
                set
                {
                    codeEncrypted = Shift(value, 4);
                }
            }
            public string guestCodeEncrypted;
            [JsonIgnore]
            public string guestCode
            {
                get
                {
                    return Shift(guestCodeEncrypted, -4);
                }
                set
                {
                    guestCodeEncrypted = Shift(value, 4);
                }
            }
            public List<ulong> whitelist;
            public List<ulong> guests;
            public int keyCode;
            public bool firstKey;
            public bool isLocked;

            public LockInfo() { }
            public LockInfo(ulong vmId, BaseLock l)
            {
                this.vmId = vmId;
                FromLock(l);
            }

            public void FromLock(BaseLock l)
            {
                if (l.GetType() == typeof(CodeLock))
                {
                    isCodeLock = true;
                    code = (l as CodeLock).code;
                    guestCode = (l as CodeLock).guestCode;
                    whitelist = (l as CodeLock).whitelistPlayers;
                    guests = (l as CodeLock).guestPlayers;
                }
                else if (l.GetType() == typeof(KeyLock))
                {
                    keyCode = (l as KeyLock).keyCode;
                    firstKey = (l as KeyLock).firstKeyCreated;
                }
                isLocked = l.IsLocked();
            }

            public void ToLock(ref BaseLock l)
            {
                if (l.GetType() == typeof(CodeLock))
                {
                    (l as CodeLock).code = code;
                    (l as CodeLock).guestCode = guestCode;
                    (l as CodeLock).whitelistPlayers = whitelist;
                    (l as CodeLock).guestPlayers = guests;
                }
                else if (l.GetType() == typeof(KeyLock))
                {
                    (l as KeyLock).keyCode = keyCode;
                    (l as KeyLock).firstKeyCreated = firstKey;
                }
                l.SetFlag(BaseEntity.Flags.Locked, isLocked);
            }

            // simple obfuscation for codes
            static string Shift(string source, int shift)
            {
                int maxChar = Convert.ToInt32(char.MaxValue);
                int minChar = Convert.ToInt32(char.MinValue);

                char[] buffer = source.ToCharArray();

                for (int i = 0; i < buffer.Length; i++)
                {
                    int shifted = Convert.ToInt32(buffer[i]) + (shift * entropy[i]);

                    if (shifted > maxChar)
                    {
                        shifted -= maxChar;
                    }
                    else if (shifted < minChar)
                    {
                        shifted += maxChar;
                    }

                    buffer[i] = Convert.ToChar(shifted);
                }

                return new string(buffer);
            }
        }

        #endregion
    }
}