using SptCommon.Annotations;
using Core.Generators;
using Core.Helpers;
using Core.Models.Common;
using Core.Models.Eft.Common;
using Core.Models.Eft.Common.Tables;
using Core.Models.Eft.Hideout;
using Core.Models.Eft.Inventory;
using Core.Models.Eft.ItemEvent;
using Core.Models.Enums;
using Core.Models.Enums.Hideout;
using Core.Models.Spt.Config;
using Core.Models.Utils;
using Core.Routers;
using Core.Servers;
using Core.Services;
using Core.Utils;
using Core.Utils.Cloners;


namespace Core.Controllers;

[Injectable]
public class HideoutController(
    ISptLogger<HideoutController> _logger,
    HashUtil _hashUtil,
    TimeUtil _timeUtil,
    DatabaseService _databaseService,
    RandomUtil _randomUtil,
    InventoryHelper _inventoryHelper,
    ItemHelper _itemHelper,
    SaveServer _saveServer,
    PlayerService _playerService,
    PresetHelper _presetHelper,
    PaymentHelper _paymentHelper,
    EventOutputHolder _eventOutputHolder,
    HttpResponseUtil _httpResponseUtil,
    ProfileHelper _profileHelper,
    HideoutHelper _hideoutHelper,
    ScavCaseRewardGenerator _scavCaseRewardGenerator,
    LocalisationService _localisationService,
    ProfileActivityService _profileActivityService,
    FenceService _fenceService,
    CircleOfCultistService _circleOfCultistService,
    ICloner _cloner,
    ConfigServer _configServer
)
{
    protected HideoutConfig _hideoutConfig = _configServer.GetConfig<HideoutConfig>();
    public const string NameTaskConditionCountersCraftingId = "673f5d6fdd6ed700c703afdc";

    protected List<HideoutAreas> _hideoutAreas =
    [
        HideoutAreas.AIR_FILTERING,
        HideoutAreas.WATER_COLLECTOR,
        HideoutAreas.GENERATOR,
        HideoutAreas.BITCOIN_FARM,
    ];

    public void StartUpgrade(PmcData pmcData, HideoutUpgradeRequestData request, string sessionID, ItemEventRouterResponse output)
    {
        var items = request.Items.Select(
                reqItem =>
                {
                    var item = pmcData.Inventory.Items.FirstOrDefault(invItem => invItem.Id == reqItem.Id);
                    return new { inventoryItem = item, requestedItem = reqItem };
                }
            )
            .ToList();

        // If it's not money, its construction / barter items
        foreach (var item in items)
        {
            if (item.inventoryItem is null)
            {
                _logger.Error(
                    _localisationService.GetText("hideout-unable_to_find_item_in_inventory", item.requestedItem.Id)
                );
                _httpResponseUtil.AppendErrorToOutput(output);

                return;
            }

            if (
                _paymentHelper.IsMoneyTpl(item.inventoryItem.Template) &&
                item.inventoryItem.Upd is not null &&
                item.inventoryItem.Upd.StackObjectsCount is not null &&
                item.inventoryItem.Upd.StackObjectsCount > item.requestedItem.Count
            )
            {
                item.inventoryItem.Upd.StackObjectsCount -= item.requestedItem.Count;
            }
            else
            {
                _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionID, output);
            }
        }

        // Construction time management
        var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType);
        if (profileHideoutArea is null)
        {
            _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType));
            _httpResponseUtil.AppendErrorToOutput(output);

            return;
        }

        var hideoutDataDb = _databaseService
            .GetTables()
            .Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType);
        if (hideoutDataDb is null)
        {
            _logger.Error(
                _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType)
            );
            _httpResponseUtil.AppendErrorToOutput(output);

            return;
        }

        var ctime = hideoutDataDb.Stages[(profileHideoutArea.Level + 1).ToString()].ConstructionTime;
        if (ctime > 0)
        {
            if (_profileHelper.IsDeveloperAccount(sessionID))
            {
                ctime = 40;
            }

            var timestamp = _timeUtil.GetTimeStamp();

            profileHideoutArea.CompleteTime = Math.Round((double)(timestamp - ctime));
            profileHideoutArea.Constructing = true;
        }
    }

    public void UpgradeComplete(PmcData pmcData, HideoutUpgradeCompleteRequestData request, string sessionID, ItemEventRouterResponse output)
    {
        var hideout = _databaseService.GetHideout();
        var globals = _databaseService.GetGlobals();

        var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == request.AreaType);
        if (profileHideoutArea is null)
        {
            _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType));
            _httpResponseUtil.AppendErrorToOutput(output);

            return;
        }

        // Upgrade profile values
        profileHideoutArea.Level++;
        profileHideoutArea.CompleteTime = 0;
        profileHideoutArea.Constructing = false;

        var hideoutData = hideout.Areas.FirstOrDefault(area => area.Type == profileHideoutArea.Type);
        if (hideoutData is null)
        {
            _logger.Error(
                _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType)
            );
            _httpResponseUtil.AppendErrorToOutput(output);

            return;
        }

        // Apply bonuses
        var hideoutStage = hideoutData.Stages[profileHideoutArea.Level.ToString()];
        var bonuses = hideoutStage.Bonuses;
        if (bonuses?.Count > 0)
        {
            foreach (var bonus in bonuses)
            {
                _hideoutHelper.ApplyPlayerUpgradesBonuses(pmcData, bonus);
            }
        }

        // Upgrade includes a container improvement/addition
        if (!string.IsNullOrEmpty(hideoutStage?.Container))
        {
            AddContainerImprovementToProfile(
                output,
                sessionID,
                pmcData,
                profileHideoutArea,
                hideoutData,
                hideoutStage
            );
        }

        // Upgrading water collector / med station
        if (
            profileHideoutArea.Type == HideoutAreas.WATER_COLLECTOR ||
            profileHideoutArea.Type == HideoutAreas.MEDSTATION
        )
        {
            SetWallVisibleIfPrereqsMet(pmcData);
        }

        // Cleanup temporary buffs/debuffs from wall if complete
        if (profileHideoutArea.Type == HideoutAreas.EMERGENCY_WALL && profileHideoutArea.Level == 6)
        {
            _hideoutHelper.RemoveHideoutWallBuffsAndDebuffs(hideoutData, pmcData);
        }

        // Add Skill Points Per Area Upgrade
        _profileHelper.AddSkillPointsToPlayer(
            pmcData,
            SkillTypes.HideoutManagement,
            globals.Configuration.SkillsSettings.HideoutManagement.SkillPointsPerAreaUpgrade
        );
    }

    private void SetWallVisibleIfPrereqsMet(PmcData pmcData)
    {
        var medStation = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.MEDSTATION);
        var waterCollector = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.WATER_COLLECTOR);
        if (medStation?.Level >= 1 && waterCollector?.Level >= 1)
        {
            var wall = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == HideoutAreas.EMERGENCY_WALL);
            if (wall?.Level == 0)
            {
                wall.Level = 3;
            }
        }
    }

    private void AddContainerImprovementToProfile(ItemEventRouterResponse output, string sessionID, PmcData pmcData, BotHideoutArea profileParentHideoutArea,
        HideoutArea dbHideoutArea, Stage hideoutStage)
    {
        // Add key/value to `hideoutAreaStashes` dictionary - used to link hideout area to inventory stash by its id
        if (pmcData.Inventory.HideoutAreaStashes.GetValueOrDefault(dbHideoutArea.Type.ToString()) is null)
        {
            pmcData.Inventory.HideoutAreaStashes[dbHideoutArea.Type.ToString()] = dbHideoutArea.Id;
        }

        // Add/upgrade stash item in player inventory
        AddUpdateInventoryItemToProfile(sessionID, pmcData, dbHideoutArea, hideoutStage);

        // Edge case, add/update `stand1/stand2/stand3` children
        if (dbHideoutArea.Type == HideoutAreas.EQUIPMENT_PRESETS_STAND)
        {
            // Can have multiple 'standx' children depending on upgrade level
            AddMissingPresetStandItemsToProfile(sessionID, hideoutStage, pmcData, dbHideoutArea, output);
        }

        // Dont inform client when upgraded area is hall of fame or equipment stand, BSG doesn't inform client this specifc upgrade has occurred
        // will break client if sent
        List<HideoutAreas> check = [HideoutAreas.PLACE_OF_FAME];
        if (!check.Contains(dbHideoutArea.Type ?? HideoutAreas.NOTSET))
        {
            AddContainerUpgradeToClientOutput(sessionID, dbHideoutArea.Type, dbHideoutArea, hideoutStage, output);
        }

        // Some hideout areas (Gun stand) have child areas linked to it
        var childDbArea = _databaseService
            .GetHideout()
            .Areas.FirstOrDefault(area => area.ParentArea == dbHideoutArea.Id);
        if (childDbArea is not null)
        {
            // Add key/value to `hideoutAreaStashes` dictionary - used to link hideout area to inventory stash by its id
            if (pmcData.Inventory.HideoutAreaStashes.GetValueOrDefault(childDbArea.Type.ToString()) is null)
            {
                pmcData.Inventory.HideoutAreaStashes[childDbArea.Type.ToString()] = childDbArea.Id;
            }

            // Set child area level to same as parent area
            pmcData.Hideout.Areas.FirstOrDefault((hideoutArea) => hideoutArea.Type == childDbArea.Type).Level =
                pmcData.Hideout.Areas.FirstOrDefault((x) => x.Type == profileParentHideoutArea.Type).Level;

            // Add/upgrade stash item in player inventory
            var childDbAreaStage = childDbArea.Stages[profileParentHideoutArea.Level.ToString()];
            AddUpdateInventoryItemToProfile(sessionID, pmcData, childDbArea, childDbAreaStage);

            // Inform client of the changes
            AddContainerUpgradeToClientOutput(sessionID, childDbArea.Type, childDbArea, childDbAreaStage, output);
        }
    }

    private void AddUpdateInventoryItemToProfile(string sessionId, PmcData pmcData, HideoutArea dbHideoutArea, Stage hideoutStage)
    {
        var existingInventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == dbHideoutArea.Id);
        if (existingInventoryItem is not null)
        {
            // Update existing items container tpl to point to new id (tpl)
            existingInventoryItem.Template = hideoutStage.Container;

            return;
        }

        // Add new item as none exists (don't inform client of newContainerItem, will be done in `profileChanges.changedHideoutStashes`)
        var newContainerItem = new Item { Id = dbHideoutArea.Id, Template = hideoutStage.Container };
        pmcData.Inventory.Items.Add(newContainerItem);
    }

    private void AddContainerUpgradeToClientOutput(string sessionID, HideoutAreas? areaType, HideoutArea hideoutDbData, Stage hideoutStage,
        ItemEventRouterResponse output)
    {
        if (output.ProfileChanges[sessionID].ChangedHideoutStashes is null)
        {
            output.ProfileChanges[sessionID].ChangedHideoutStashes = new Dictionary<string, HideoutStashItem>();
        }

        // Inform client of changes
        output.ProfileChanges[sessionID].ChangedHideoutStashes[areaType.ToString()] = new HideoutStashItem
        {
            Id = hideoutDbData.Id,
            Template = hideoutStage.Container,
        };
    }

    public ItemEventRouterResponse PutItemsInAreaSlots(PmcData pmcData, HideoutPutItemInRequestData addItemToHideoutRequest, string sessionID)
    {
        var output = _eventOutputHolder.GetOutput(sessionID);

        var itemsToAdd = addItemToHideoutRequest.Items.Select(
            kvp =>
            {
                var item = pmcData.Inventory.Items.FirstOrDefault((invItem) => invItem.Id == kvp.Value.Id);
                return new { inventoryItem = item, requestedItem = kvp.Value, slot = kvp.Key };
            }
        );

        var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == addItemToHideoutRequest.AreaType);
        if (hideoutArea is null)
        {
            _logger.Error(
                _localisationService.GetText(
                    "hideout-unable_to_find_area_in_database",
                    addItemToHideoutRequest.AreaType
                )
            );
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        foreach (var item in itemsToAdd)
        {
            if (item.inventoryItem is null)
            {
                _logger.Error(
                    _localisationService.GetText(
                        "hideout-unable_to_find_item_in_inventory",
                        new
                        {
                            itemId = item.requestedItem.Id,
                            area = hideoutArea.Type
                        }
                    )
                );
                return _httpResponseUtil.AppendErrorToOutput(output);
            }

            // Add item to area.slots
            var destinationLocationIndex = int.Parse(item.slot);
            var hideoutSlotIndex = hideoutArea.Slots.FindIndex(
                (slot) => slot.LocationIndex == destinationLocationIndex
            );
            if (hideoutSlotIndex == -1)
            {
                _logger.Error(
                    $"Unable to put item: {item.requestedItem.Id} into slot as slot cannot be found for area: {addItemToHideoutRequest.AreaType}, skipping"
                );
                continue;
            }

            hideoutArea.Slots[hideoutSlotIndex].Items =
            [
                new HideoutItem
                {
                    Id = item.inventoryItem.Id,
                    Template = item.inventoryItem.Template,
                    Upd = item.inventoryItem.Upd
                },
            ];

            _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionID, output);
        }

        // Trigger a forced update
        _hideoutHelper.UpdatePlayerHideout(sessionID);

        return output;
    }

    public ItemEventRouterResponse TakeItemsFromAreaSlots(PmcData pmcData, HideoutTakeItemOutRequestData request, string sessionID)
    {
        var output = _eventOutputHolder.GetOutput(sessionID);

        var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType);
        if (hideoutArea is null)
        {
            _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType));
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        if (hideoutArea.Slots is null || hideoutArea.Slots.Count == 0)
        {
            _logger.Error(
                _localisationService.GetText("hideout-unable_to_find_item_to_remove_from_area", hideoutArea.Type)
            );
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        // Handle areas that have resources that can be placed in/taken out of slots from the area
        if (
            _hideoutAreas.Contains(hideoutArea.Type ?? HideoutAreas.NOTSET)
        )
        {
            var response = RemoveResourceFromArea(sessionID, pmcData, request, output, hideoutArea);

            // Force a refresh of productions/hideout areas with resources
            _hideoutHelper.UpdatePlayerHideout(sessionID);
            return response;
        }

        throw new Exception(
            _localisationService.GetText("hideout-unhandled_remove_item_from_area_request", hideoutArea.Type)
        );
    }

    private ItemEventRouterResponse RemoveResourceFromArea(string sessionID, PmcData pmcData, HideoutTakeItemOutRequestData removeResourceRequest,
        ItemEventRouterResponse output, BotHideoutArea hideoutArea)
    {
        var slotIndexToRemove = removeResourceRequest?.Slots.FirstOrDefault();
        if (slotIndexToRemove is null)
        {
            _logger.Warning(
                $"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no slots found in request, RESTART CLIENT IMMEDIATELY"
            );

            return output;
        }

        // Assume only one item in slot
        var itemToReturn = hideoutArea.Slots.FirstOrDefault(slot => slot.LocationIndex == slotIndexToRemove)?.Items.FirstOrDefault();
        if (itemToReturn is null)
        {
            _logger.Warning($"Unable to remove resource from area: {removeResourceRequest.AreaType} slot as no item found, RESTART CLIENT IMMEDIATELY");

            return output;
        }

        AddItemDirectRequest request = new AddItemDirectRequest
        {
            ItemWithModsToAdd = [itemToReturn.ConvertToItem()],
            FoundInRaid = itemToReturn.Upd?.SpawnedInSession,
            Callback = null,
            UseSortingTable = false,
        };

        _inventoryHelper.AddItemToStash(sessionID, request, pmcData, output);
        if (output.Warnings?.Count > 0)
        {
            // Adding to stash failed, drop out - dont remove item from hideout area slot
            return output;
        }

        // Remove items from slot, locationIndex remains
        var hideoutSlotIndex = hideoutArea.Slots.FindIndex((slot) => slot.LocationIndex == slotIndexToRemove);
        hideoutArea.Slots[hideoutSlotIndex].Items = null;

        return output;
    }

    public ItemEventRouterResponse ToggleArea(PmcData pmcData, HideoutToggleAreaRequestData request, string sessionID)
    {
        var output = _eventOutputHolder.GetOutput(sessionID);

        // Force a production update (occur before area is toggled as it could be generator and doing it after generator enabled would cause incorrect calculaton of production progress)
        _hideoutHelper.UpdatePlayerHideout(sessionID);

        var hideoutArea = pmcData.Hideout.Areas.FirstOrDefault((area) => area.Type == request.AreaType);
        if (hideoutArea is null)
        {
            _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType));
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        hideoutArea.Active = request.Enabled;

        return output;
    }

    public ItemEventRouterResponse SingleProductionStart(PmcData pmcData, HideoutSingleProductionStartRequestData body, string sessionID)
    {
        // Start production
        _hideoutHelper.RegisterProduction(pmcData, body, sessionID);

        // Find the recipe of the production
        var recipe = _databaseService
            .GetHideout()
            .Production.Recipes.FirstOrDefault(production => production.Id == body.RecipeId);

        // Find the actual amount of items we need to remove because body can send weird data
        var recipeRequirementsClone = _cloner.Clone(
            recipe.Requirements.Where((r) => r.Type == "Item" || r.Type == "Tool")
        );

        List<IdWithCount> itemsToDelete = new List<IdWithCount>();
        var output = _eventOutputHolder.GetOutput(sessionID);
        itemsToDelete.AddRange(body.Tools);
        itemsToDelete.AddRange(body.Items);

        foreach (var itemToDelete in itemsToDelete)
        {
            var itemToCheck = pmcData.Inventory.Items.FirstOrDefault(i => i.Id == itemToDelete.Id);
            var requirement = recipeRequirementsClone.FirstOrDefault(
                requirement => requirement.TemplateId == itemToCheck.Template
            );

            // Handle tools not having a `count`, but always only requiring 1
            var requiredCount = requirement.Count ?? 1;
            if (requiredCount <= 0)
            {
                continue;
            }

            _inventoryHelper.RemoveItemByCount(pmcData, itemToDelete.Id, requiredCount, sessionID, output);

            // Tools don't have a count
            if (requirement.Type != "Tool")
            {
                requirement.Count -= (int)itemToDelete.Count;
            }
        }

        return output;
    }

    public ItemEventRouterResponse ScavCaseProductionStart(PmcData pmcData, HideoutScavCaseStartRequestData body, string sessionID)
    {
        var output = _eventOutputHolder.GetOutput(sessionID);

        foreach (var requestedItem in body.Items)
        {
            var inventoryItem = pmcData.Inventory.Items.FirstOrDefault(item => item.Id == requestedItem.Id);
            if (inventoryItem is null)
            {
                _logger.Error(
                    _localisationService.GetText(
                        "hideout-unable_to_find_scavcase_requested_item_in_profile_inventory",
                        requestedItem.Id
                    )
                );
                return _httpResponseUtil.AppendErrorToOutput(output);
            }

            if (inventoryItem.Upd?.StackObjectsCount is not null && inventoryItem.Upd.StackObjectsCount > requestedItem.Count)
            {
                inventoryItem.Upd.StackObjectsCount -= requestedItem.Count;
            }
            else
            {
                _inventoryHelper.RemoveItem(pmcData, requestedItem.Id, sessionID, output);
            }
        }

        var recipe = _databaseService.GetHideout().Production.ScavRecipes.FirstOrDefault(r => r.Id == body.RecipeId);
        if (recipe is null)
        {
            _logger.Error(
                _localisationService.GetText("hideout-unable_to_find_scav_case_recipie_in_database", body.RecipeId)
            );

            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        // @Important: Here we need to be very exact:
        // - normal recipe: Production time value is stored in attribute "productionTime" with small "p"
        // - scav case recipe: Production time value is stored in attribute "ProductionTime" with capital "P"
        var adjustedCraftTime =
            recipe.ProductionTime -
            _hideoutHelper.GetSkillProductionTimeReduction(
                pmcData,
                recipe.ProductionTime ?? 0,
                SkillTypes.Crafting,
                _databaseService.GetGlobals().Configuration.SkillsSettings.Crafting.CraftTimeReductionPerLevel ?? 0
            );

        var modifiedScavCaseTime = GetScavCaseTime(pmcData, adjustedCraftTime);

        pmcData.Hideout.Production[body.RecipeId] = _hideoutHelper.InitProduction(
            body.RecipeId,
            (int)(_profileHelper.IsDeveloperAccount(sessionID) ? 40 : modifiedScavCaseTime),
            false
        );
        pmcData.Hideout.Production[body.RecipeId].SptIsScavCase = true;

        return output;
    }

    private double? GetScavCaseTime(PmcData pmcData, double? productionTime)
    {
        var fenceLevel = _fenceService.GetFenceInfo(pmcData);
        if (fenceLevel is null)
        {
            return productionTime;
        }

        return productionTime * fenceLevel.ScavCaseTimeModifier;
    }

    public void AddScavCaseRewardsToProfile(PmcData pmcData, List<Item> rewards, string recipeId)
    {
        pmcData.Hideout.Production[$"ScavCase{recipeId}"] = new Production { Products = rewards, RecipeId = recipeId };
    }

    public ItemEventRouterResponse ContinuousProductionStart(PmcData pmcData, HideoutContinuousProductionStartRequestData request, string sessionID)
    {
        _hideoutHelper.RegisterProduction(pmcData, request, sessionID);

        return _eventOutputHolder.GetOutput(sessionID);
    }

    public ItemEventRouterResponse TakeProduction(PmcData pmcData, HideoutTakeProductionRequestData request, string sessionID)
    {
        var output = _eventOutputHolder.GetOutput(sessionID);
        var hideoutDb = _databaseService.GetHideout();

        if (request.RecipeId == HideoutHelper.BitcoinFarm)
        {
            // Ensure server and client are in-sync when player presses 'get items' on farm
            _hideoutHelper.UpdatePlayerHideout(sessionID);
            _hideoutHelper.GetBTC(pmcData, request, sessionID, output);

            return output;
        }

        var recipe = hideoutDb.Production.Recipes.FirstOrDefault(r => r.Id == request.RecipeId);
        if (recipe is not null)
        {
            HandleRecipe(sessionID, recipe, pmcData, request, output);

            return output;
        }

        var scavCase = hideoutDb.Production.ScavRecipes.FirstOrDefault(r => r.Id == request.RecipeId);
        if (scavCase is not null)
        {
            HandleScavCase(sessionID, pmcData, request, output);

            return output;
        }

        _logger.Error(
            _localisationService.GetText(
                "hideout-unable_to_find_production_in_profile_by_recipie_id",
                request.RecipeId
            )
        );

        return _httpResponseUtil.AppendErrorToOutput(output);
    }

    private void HandleRecipe(string sessionID, HideoutProduction recipe, PmcData pmcData, HideoutTakeProductionRequestData request,
        ItemEventRouterResponse output)
    {
        // Validate that we have a matching production
        var productionDict = pmcData.Hideout.Production;
        string? prodId = null;
        foreach (var production in productionDict)
        {
            // Skip undefined production objects
            if (production.Value is null)
            {
                continue;
            }

            // Production or ScavCase
            if (production.Value.RecipeId == request.RecipeId)
            {
                prodId = production.Key; // Set to objects key
                break;
            }
        }

        // If we're unable to find the production, send an error to the client
        if (prodId is null)
        {
            _logger.Error(
                _localisationService.GetText(
                    "hideout-unable_to_find_production_in_profile_by_recipie_id",
                    request.RecipeId
                )
            );

            _httpResponseUtil.AppendErrorToOutput(
                output,
                _localisationService.GetText(
                    "hideout-unable_to_find_production_in_profile_by_recipie_id",
                    request.RecipeId
                )
            );

            return;
        }

        // Variables for managemnet of skill
        var craftingExpAmount = 0;

        var counterHoursCrafting = GetHoursCraftingTaskConditionCounter(pmcData, recipe);
        var hoursCrafting = counterHoursCrafting.Value;

        // Array of arrays of item + children
        List<List<Item>> itemAndChildrenToSendToPlayer = [];

        // Reward is weapon/armor preset, handle differently compared to 'normal' items
        var rewardIsPreset = _presetHelper.HasPreset(recipe.EndProduct);
        if (rewardIsPreset)
        {
            var defaultPreset = _presetHelper.GetDefaultPreset(recipe.EndProduct);

            // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory
            List<Item> presetAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(defaultPreset.Items));

            _itemHelper.RemapRootItemId(presetAndMods);

            // Store preset items in array
            itemAndChildrenToSendToPlayer = [presetAndMods];
        }

        var rewardIsStackable = _itemHelper.IsItemTplStackable(recipe.EndProduct);
        if (rewardIsStackable ?? false)
        {
            // Create root item
            Item rewardToAdd = new Item
            {
                Id = _hashUtil.Generate(),
                Template = recipe.EndProduct,
                Upd = new Upd { StackObjectsCount = recipe.Count },
            };

            // Split item into separate items with acceptable stack sizes
            var splitReward = _itemHelper.SplitStackIntoSeparateItems(rewardToAdd);
            itemAndChildrenToSendToPlayer.AddRange(splitReward);
        }
        else
        {
            // Not stackable, may have to send send multiple of reward

            // Add the first reward item to array when not a preset (first preset added above earlier)
            if (!rewardIsPreset)
            {
                itemAndChildrenToSendToPlayer.Add([new Item { Id = _hashUtil.Generate(), Template = recipe.EndProduct }]);
            }

            // Add multiple of item if recipe requests it
            // Start index at one so we ignore first item in array
            var countOfItemsToReward = recipe.Count;
            for (var index = 1; index < countOfItemsToReward; index++)
            {
                List<Item> itemAndMods = _itemHelper.ReplaceIDs(_cloner.Clone(itemAndChildrenToSendToPlayer.FirstOrDefault()));
                itemAndChildrenToSendToPlayer.AddRange([itemAndMods]);
            }
        }

        // Recipe has an `isEncoded` requirement for reward(s), Add `RecodableComponent` property
        if (recipe.IsEncoded ?? false)
        {
            foreach (var reward in itemAndChildrenToSendToPlayer)
            {
                _itemHelper.AddUpdObjectToItem(reward.FirstOrDefault());

                reward.FirstOrDefault().Upd.RecodableComponent = new UpdRecodableComponent { IsEncoded = true };
            }
        }

        // Build an array of the tools that need to be returned to the player
        List<List<Item>> toolsToSendToPlayer = [];
        var hideoutProduction = pmcData.Hideout.Production[prodId];
        if (hideoutProduction.SptRequiredTools?.Count > 0)
        {
            foreach (var tool in hideoutProduction.SptRequiredTools)
            {
                toolsToSendToPlayer.AddRange([tool]);
            }
        }

        // Check if the recipe is the same as the last one - get bonus when crafting same thing multiple times
        var area = pmcData.Hideout.Areas.FirstOrDefault(area => area.Type == recipe.AreaType);
        if (area is not null && request.RecipeId != area.LastRecipe)
        {
            // 1 point per craft upon the end of production for alternating between 2 different crafting recipes in the same module
            craftingExpAmount += _hideoutConfig.ExpCraftAmount; // Default is 10
        }

        // Update variable with time spent crafting item(s)
        // 1 point per 8 hours of crafting
        hoursCrafting += recipe.ProductionTime;
        if (hoursCrafting / _hideoutConfig.HoursForSkillCrafting >= 1)
        {
            // Spent enough time crafting to get a bonus xp multipler
            var multiplierCrafting = Math.Floor((double)hoursCrafting / _hideoutConfig.HoursForSkillCrafting);
            craftingExpAmount += (int)(1 * multiplierCrafting);
            hoursCrafting -= _hideoutConfig.HoursForSkillCrafting * multiplierCrafting;
        }

        // Make sure we can fit both the craft result and tools in the stash
        var totalResultItems = new List<List<Item>>();
        totalResultItems.AddRange(itemAndChildrenToSendToPlayer);
        totalResultItems.AddRange(toolsToSendToPlayer);

        if (!_inventoryHelper.CanPlaceItemsInInventory(sessionID, totalResultItems))
        {
            _httpResponseUtil.AppendErrorToOutput(
                output,
                _localisationService.GetText("inventory-no_stash_space"),
                BackendErrorCodes.NotEnoughSpace
            );
            return;
        }

        // Add the tools to the stash, we have to do this individually due to FiR state potentially being different
        foreach (var toolItem in toolsToSendToPlayer)
        {
            // Note: FIR state will be based on the first item's SpawnedInSession property per item group
            AddItemsDirectRequest addToolsRequest = new AddItemsDirectRequest
            {
                ItemsWithModsToAdd = [toolItem],
                FoundInRaid = toolItem[0].Upd?.SpawnedInSession ?? false,
                UseSortingTable = false,
                Callback = null,
            };

            _inventoryHelper.AddItemsToStash(sessionID, addToolsRequest, pmcData, output);
            if (output.Warnings?.Count > 0)
            {
                return;
            }
        }

        // Add the crafting result to the stash, marked as FiR
        AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest
        {
            ItemsWithModsToAdd = itemAndChildrenToSendToPlayer,
            FoundInRaid = true,
            UseSortingTable = false,
            Callback = null,
        };
        _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output);
        if (output.Warnings?.Count > 0)
        {
            return;
        }

        //  - increment skill point for crafting
        //  - delete the production in profile Hideout.Production
        // Hideout Management skill
        // ? use a configuration variable for the value?
        var globals = _databaseService.GetGlobals();
        _profileHelper.AddSkillPointsToPlayer(
            pmcData,
            SkillTypes.HideoutManagement,
            globals.Configuration.SkillsSettings.HideoutManagement.SkillPointsPerCraft,
            true
        );

        // Add Crafting skill to player profile
        if (craftingExpAmount > 0)
        {
            _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Crafting, craftingExpAmount);

            var intellectAmountToGive = 0.5 * Math.Round((double)(craftingExpAmount / 15));
            if (intellectAmountToGive > 0)
            {
                _profileHelper.AddSkillPointsToPlayer(pmcData, SkillTypes.Intellect, intellectAmountToGive);
            }
        }

        area.LastRecipe = request.RecipeId;

        // Update profiles hours crafting value
        counterHoursCrafting.Value = hoursCrafting;

        // Continuous crafts have special handling in EventOutputHolder.updateOutputProperties()
        pmcData.Hideout.Production[prodId].SptIsComplete = true;
        pmcData.Hideout.Production[prodId].SptIsContinuous = recipe.Continuous;

        // Continious recipies need the craft time refreshed as it gets created once on initial craft and stays the same regardless of what
        // production.json is set to
        if (recipe.Continuous ?? false)
        {
            pmcData.Hideout.Production[prodId].ProductionTime = _hideoutHelper.GetAdjustedCraftTimeWithSkills(
                pmcData,
                recipe.Id,
                true
            );
        }

        // Flag normal (non continious) crafts as complete
        if (!recipe.Continuous ?? false)
        {
            pmcData.Hideout.Production[prodId].InProgress = false;
        }
    }

    private TaskConditionCounter GetHoursCraftingTaskConditionCounter(PmcData pmcData, HideoutProduction recipe)
    {
        if (!pmcData.TaskConditionCounters.TryGetValue(HideoutController.NameTaskConditionCountersCraftingId, out var _))
        {
            // Doesn't exist, create
            pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId] = new TaskConditionCounter
            {
                Id = recipe.Id,
                Type = HideoutController.NameTaskConditionCountersCraftingId,
                SourceId = "CounterCrafting",
                Value = 0,
            };
        }

        return pmcData.TaskConditionCounters[HideoutController.NameTaskConditionCountersCraftingId];
    }

    private void HandleScavCase(string sessionID, PmcData pmcData, HideoutTakeProductionRequestData request, ItemEventRouterResponse output)
    {
        var ongoingProductions = pmcData.Hideout.Production;
        string? prodId = null;
        foreach (var production in ongoingProductions)
        {
            if (production.Value.GetType() == typeof(HideoutProduction))
            {
                // Production or ScavCase
                if ((production.Value).RecipeId == request.RecipeId)
                {
                    prodId = production.Key; // Set to objects key
                    break;
                }
            }
        }

        if (prodId == null)
        {
            _logger.Error(
                _localisationService.GetText(
                    "hideout-unable_to_find_production_in_profile_by_recipie_id",
                    request.RecipeId
                )
            );

            _httpResponseUtil.AppendErrorToOutput(output);

            return;
        }

        // Create rewards for scav case
        var scavCaseRewards = _scavCaseRewardGenerator.Generate(request.RecipeId);

        AddItemsDirectRequest addItemsRequest = new AddItemsDirectRequest
        {
            ItemsWithModsToAdd = scavCaseRewards,
            FoundInRaid = true,
            Callback = null,
            UseSortingTable = false,
        };

        _inventoryHelper.AddItemsToStash(sessionID, addItemsRequest, pmcData, output);
        if (output.Warnings?.Count > 0)
        {
            return;
        }

        // Remove the old production from output object before its sent to client
        output.ProfileChanges[sessionID].Production.Remove(request.RecipeId);

        // Flag as complete - will be cleaned up later by hideoutController.update()
        pmcData.Hideout.Production[prodId].SptIsComplete = true;

        // Crafting complete, flag
        pmcData.Hideout.Production[prodId].InProgress = false;
    }

    public void HandleQTEEventOutcome(string sessionId, PmcData pmcData, HandleQTEEventRequestData request, ItemEventRouterResponse output)
    {
        // {
        //     "Action": "HideoutQuickTimeEvent",
        //     "results": [true, false, true, true, true, true, true, true, true, false, false, false, false, false, false],
        //     "id": "63b16feb5d012c402c01f6ef",
        //     "timestamp": 1672585349
        // }

        // Skill changes are done in
        // /client/hideout/workout (applyWorkoutChanges).

        var qteDb = _databaseService.GetHideout().Qte;
        var relevantQte = qteDb.FirstOrDefault(qte => qte.Id == request.Id);
        foreach (var outcome in request.Results)
        {
            if (outcome)
            {
                // Success
                pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Energy;
                pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleSuccessEffect].Hydration;
            }
            else
            {
                // Failed
                pmcData.Health.Energy.Current += relevantQte.Results[QteEffectType.singleFailEffect].Energy;
                pmcData.Health.Hydration.Current += relevantQte.Results[QteEffectType.singleFailEffect].Hydration;
            }
        }

        if (pmcData.Health.Energy.Current < 1)
        {
            pmcData.Health.Energy.Current = 1;
        }

        if (pmcData.Health.Hydration.Current < 1)
        {
            pmcData.Health.Hydration.Current = 1;
        }

        HandleMusclePain(pmcData, relevantQte.Results[QteEffectType.finishEffect]);
    }

    private void HandleMusclePain(PmcData pmcData, QteResult finishEffect)
    {
        var hasMildPain = pmcData.Health.BodyParts["Chest"].Effects?.ContainsKey("MildMusclePain");
        var hasSeverePain = pmcData.Health.BodyParts["Chest"].Effects?.ContainsKey("SevereMusclePain");

        // Has no muscle pain at all, add mild
        if (hasMildPain is null && hasSeverePain is null)
        {
            // nullguard
            pmcData.Health.BodyParts["Chest"].Effects ??= new Dictionary<string, BodyPartEffectProperties>();
            pmcData.Health.BodyParts["Chest"].Effects["MildMusclePain"] = new BodyPartEffectProperties
            {
                Time = finishEffect.RewardEffects.FirstOrDefault().Time, // TODO - remove hard coded access, get value properly
            };

            return;
        }

        if (hasMildPain is not null)
        {
            // Already has mild pain, remove mild and add severe
            pmcData.Health.BodyParts["Chest"].Effects.Remove("MildMusclePain");

            pmcData.Health.BodyParts["Chest"].Effects["SevereMusclePain"] = new BodyPartEffectProperties
            {
                Time = finishEffect.RewardEffects.FirstOrDefault().Time,
            };
        }
    }

    public void RecordShootingRangePoints(string sessionId, PmcData pmcData, RecordShootingRangePoints request)
    {
        var shootingRangeKey = "ShootingRangePoints";
        var overallCounterItems = pmcData.Stats.Eft.OverallCounters.Items;

        // Find counter by key
        var shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey));
        if (shootingRangeHighScore is null)
        {
            // Counter not found, add blank one
            overallCounterItems.Add(new CounterKeyValue { Key = [shootingRangeKey], Value = 0 });
            shootingRangeHighScore = overallCounterItems.FirstOrDefault((counter) => counter.Key.Contains(shootingRangeKey));
        }

        shootingRangeHighScore.Value = request.Points;
    }

    public ItemEventRouterResponse ImproveArea(string sessionId, PmcData pmcData, HideoutImproveAreaRequestData request)
    {
        var output = _eventOutputHolder.GetOutput(sessionId);

        // Create mapping of required item with corrisponding item from player inventory
        var items = request.Items.Select(
            (reqItem) =>
            {
                var item = pmcData.Inventory.Items.FirstOrDefault(invItem => invItem.Id == reqItem.Id);
                return new { inventoryItem = item, requestedItem = reqItem };
            }
        );

        // If it's not money, its construction / barter items
        foreach (var item in items)
        {
            if (item.inventoryItem is null)
            {
                _logger.Error(
                    _localisationService.GetText("hideout-unable_to_find_item_in_inventory", item.requestedItem.Id)
                );
                return _httpResponseUtil.AppendErrorToOutput(output);
            }

            if (
                _paymentHelper.IsMoneyTpl(item.inventoryItem.Template) &&
                item.inventoryItem.Upd is not null &&
                item.inventoryItem.Upd.StackObjectsCount is not null &&
                item.inventoryItem.Upd.StackObjectsCount > item.requestedItem.Count
            )
            {
                item.inventoryItem.Upd.StackObjectsCount -= item.requestedItem.Count;
            }
            else
            {
                _inventoryHelper.RemoveItem(pmcData, item.inventoryItem.Id, sessionId, output);
            }
        }

        var profileHideoutArea = pmcData.Hideout.Areas.FirstOrDefault(x => x.Type == request.AreaType);
        if (profileHideoutArea is null)
        {
            _logger.Error(_localisationService.GetText("hideout-unable_to_find_area", request.AreaType));
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        var hideoutDbData = _databaseService.GetHideout().Areas.FirstOrDefault((area) => area.Type == request.AreaType);
        if (hideoutDbData is null)
        {
            _logger.Error(
                _localisationService.GetText("hideout-unable_to_find_area_in_database", request.AreaType)
            );
            return _httpResponseUtil.AppendErrorToOutput(output);
        }

        // Add all improvemets to output object
        var improvements = hideoutDbData.Stages[profileHideoutArea.Level.ToString()].Improvements;
        var timestamp = _timeUtil.GetTimeStamp();

        if (output.ProfileChanges[sessionId].Improvements is null)
        {
            output.ProfileChanges[sessionId].Improvements = new Dictionary<string, HideoutImprovement>();
        }

        foreach (var improvement in improvements)
        {
            var improvementDetails = new HideoutImprovement
            {
                Completed = false,
                ImproveCompleteTimestamp = (long)(timestamp + improvement.ImprovementTime),
            };
            output.ProfileChanges[sessionId].Improvements[improvement.Id] = improvementDetails;

            pmcData.Hideout.Improvements ??= new Dictionary<string, HideoutImprovement>();
            pmcData.Hideout.Improvements[improvement.Id] = improvementDetails;
        }

        return output;
    }

    public ItemEventRouterResponse CancelProduction(string sessionId, PmcData pmcData, HideoutCancelProductionRequestData request)
    {
        var output = _eventOutputHolder.GetOutput(sessionId);

        var craftToCancel = pmcData.Hideout.Production[request.RecipeId];
        if (craftToCancel is null)
        {
            var errorMessage = $"Unable to find craft {request.RecipeId} to cancel";
            _logger.Error(errorMessage);

            return _httpResponseUtil.AppendErrorToOutput(output, errorMessage);
        }

        // Null out production data so client gets informed when response send back
        pmcData.Hideout.Production[request.RecipeId] = null;

        // TODO - handle timestamp somehow?

        return output;
    }

    public ItemEventRouterResponse CicleOfCultistProductionStart(string sessionId, PmcData pmcData, HideoutCircleOfCultistProductionStartRequestData request)
    {
        return _circleOfCultistService.StartSacrifice(sessionId, pmcData, request);
    }

    public ItemEventRouterResponse HideoutDeleteProductionCommand(string sessionId, PmcData pmcData, HideoutDeleteProductionRequestData request)
    {
        var output = _eventOutputHolder.GetOutput(sessionId);

        pmcData.Hideout.Production[request.RecipeId] = null;
        output.ProfileChanges[sessionId].Production = null;

        return output;
    }

    public ItemEventRouterResponse HideoutCustomizationApply(string sessionId, PmcData pmcData, HideoutCustomizationApplyRequestData request)
    {
        var output = _eventOutputHolder.GetOutput(sessionId);

        var itemDetails = _databaseService
            .GetHideout()
            .Customisation.Globals.FirstOrDefault((cust) => cust.Id == request.OfferId);
        if (itemDetails is null)
        {
            _logger.Error($"Unable to find customisation: {request.OfferId} in db, cannot apply to hideout");

            return output;
        }

        // pmcData.Hideout.Customization[GetHideoutCustomisationType(itemDetails.Type)];
        // this is in the Node server, doesnt do anything

        return output;
    }

    private string? GetHideoutCustomisationType(string? type)
    {
        switch (type)
        {
            case "wall":
                return "Wall";
            case "floor":
                return "Floor";
            case "light":
                return "Light";
            case "ceiling":
                return "Ceiling";
            case "shootingRangeMark":
                return "ShootingRangeMark";
            default:
                _logger.Warning($"Unknown {type}, unable to map");
                return type;
        }
    }

    private void AddMissingPresetStandItemsToProfile(string sessionId, Stage equipmentPresetStage, PmcData pmcData, HideoutArea equipmentPresetHideoutArea,
        ItemEventRouterResponse output)
    {
        // Each slot is a single Mannequin
        var slots = _itemHelper.GetItem(equipmentPresetStage.Container).Value.Properties.Slots;
        foreach (var mannequinSlot in slots)
        {
            // Chek if we've already added this manniquin
            var existingMannequin = pmcData.Inventory.Items.FirstOrDefault(
                (item) => item.ParentId == equipmentPresetHideoutArea.Id && item.SlotId == mannequinSlot.Name
            );

            // No child, add it
            if (existingMannequin is null)
            {
                var standId = _hashUtil.Generate();
                var mannequinToAdd = new Item
                {
                    Id = standId,
                    Template = ItemTpl.INVENTORY_DEFAULT,
                    ParentId = equipmentPresetHideoutArea.Id,
                    SlotId = mannequinSlot.Name,
                };
                pmcData.Inventory.Items.Add(mannequinToAdd);

                // Add pocket child item
                var mannequinPocketItemToAdd = new Item
                {
                    Id = _hashUtil.Generate(),
                    Template = pmcData.Inventory.Items.FirstOrDefault(
                            item => item.SlotId == "Pockets" && item.ParentId == pmcData.Inventory.Equipment
                        )
                        .Template, // Same pocket tpl as players profile (unheard get bigger, matching pockets etc)
                    ParentId = standId,
                    SlotId = "Pockets",
                };
                pmcData.Inventory.Items.Add(mannequinPocketItemToAdd);
                output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinToAdd);
                output.ProfileChanges[sessionId].Items.NewItems.Add(mannequinPocketItemToAdd);
            }
        }
    }

    /// <summary>
    /// Handle HideoutCustomizationSetMannequinPose event
    /// </summary>
    /// <param name="sessionId">Session id</param>
    /// <param name="pmcData">Player profile</param>
    /// <param name="request">Client request</param>
    /// <returns></returns>
    public ItemEventRouterResponse HideoutCustomizationSetMannequinPose(string sessionId, PmcData pmcData, HideoutCustomizationSetMannequinPoseRequest request)
    {
        if (request.Poses is null)
        {
            _logger.Warning("this really shouldnt be possible, but a request has come in with a pose change without poses");
            return _eventOutputHolder.GetOutput(sessionId);
        }
        
        foreach (var poseKvP in request.Poses)
        {
            // Nullguard
            pmcData.Hideout.MannequinPoses ??= new Dictionary<string, string>();
            pmcData.Hideout.MannequinPoses[poseKvP.Key] = poseKvP.Value;
        }

        return _eventOutputHolder.GetOutput(sessionId);
    }

    public List<QteData> GetQteList(string sessionId)
    {
        return _databaseService.GetHideout().Qte;
    }
    
    /**
     * Function called every `hideoutConfig.runIntervalSeconds` seconds as part of onUpdate event
     */
    public void Update() {
        foreach (var sessionID in _saveServer.GetProfiles()) {
            if (sessionID.Value.CharacterData.PmcData.Hideout is not null &&
                _profileActivityService.ActiveWithinLastMinutes(
                    sessionID.Key,
                    _hideoutConfig.UpdateProfileHideoutWhenActiveWithinMinutes
                )
                ) {
                _hideoutHelper.UpdatePlayerHideout(sessionID.Key);
            }
        }
    }
}
