package com.gmail.nossr50.listeners;

import static java.util.logging.Logger.getLogger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.gmail.nossr50.MMOTestEnvironment;
import com.gmail.nossr50.api.exceptions.InvalidSkillException;
import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
import com.gmail.nossr50.datatypes.skills.SubSkillType;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.skills.salvage.SalvageManager;
import com.gmail.nossr50.skills.salvage.salvageables.Salvageable;
import com.gmail.nossr50.skills.salvage.salvageables.SalvageableManager;
import com.gmail.nossr50.util.Permissions;
import com.gmail.nossr50.util.player.NotificationManager;
import com.gmail.nossr50.util.player.UserManager;
import com.gmail.nossr50.util.skills.RankUtils;
import java.lang.reflect.Method;
import java.util.Comparator;
import java.util.List;
import java.util.Arrays;
import java.util.logging.Logger;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;

/**
 * Covers the salvage anvil interaction flow of {@link PlayerListener}. The confirmation flow
 * must never salvage an item the player was not prompted for, and while a confirmation is
 * pending the held item must not be usable (vanilla armor quick-equip would otherwise swap the
 * held piece with worn armor mid-confirmation).
 * <p>
 * Events are dispatched through all of the listener's interact handlers honoring their real
 * {@link EventHandler} settings, because those settings are load-bearing: interactions with air
 * are cancelled by default, so a handler that ignores cancelled events never sees the follow-up
 * use-item interaction the client sends after an anvil click.
 */
class PlayerListenerSalvageInteractTest extends MMOTestEnvironment {
    private static final Logger logger =
            getLogger(PlayerListenerSalvageInteractTest.class.getName());

    private PlayerListener playerListener;
    private SalvageManager salvageManager;
    private SalvageableManager salvageableManager;
    private Block anvilBlock;
    private Block dirtBlock;
    private ItemStack helmet;
    private ItemStack chestplate;

    @BeforeEach
    void setUp() throws InvalidSkillException {
        mockBaseEnvironment(logger);
        playerListener = new PlayerListener(mcMMO.p);

        when(generalConfig.getSalvageAnvilMaterial()).thenReturn(Material.GOLD_BLOCK);
        when(generalConfig.getRepairAnvilMaterial()).thenReturn(Material.IRON_BLOCK);
        when(generalConfig.getAbilitiesOnlyActivateWhenSneaking()).thenReturn(false);
        when(generalConfig.getSalvageConfirmRequired()).thenReturn(true);

        when(UserManager.hasPlayerDataKey(player)).thenReturn(true);
        when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
        when(player.isSneaking()).thenReturn(false);

        when(Permissions.skillEnabled(player, PrimarySkillType.SALVAGE)).thenReturn(true);
        when(RankUtils.hasUnlockedSubskill(player, SubSkillType.SALVAGE_SCRAP_COLLECTOR))
                .thenReturn(true);

        salvageableManager = mock(SalvageableManager.class);
        when(mcMMO.getSalvageableManager()).thenReturn(salvageableManager);

        helmet = mockSalvageableItem(salvageableManager);
        chestplate = mockSalvageableItem(salvageableManager);
        when(helmet.isSimilar(helmet)).thenReturn(true);
        when(chestplate.isSimilar(chestplate)).thenReturn(true);

        anvilBlock = mock(Block.class);
        when(anvilBlock.getType()).thenReturn(Material.GOLD_BLOCK);
        when(anvilBlock.getLocation()).thenReturn(new Location(world, 0, 0, 0));

        dirtBlock = mock(Block.class);
        when(dirtBlock.getType()).thenReturn(Material.DIRT);

        // removeAbilityBoostsFromInventory iterates the inventory contents before a salvage
        when(playerInventory.getContents()).thenReturn(new ItemStack[0]);

        salvageManager = Mockito.spy(new SalvageManager(mmoPlayer));
        doReturn(salvageManager).when(mmoPlayer).getSalvageManager();
        doNothing().when(salvageManager).handleSalvage(any(), any());
    }

    @AfterEach
    void tearDown() {
        cleanUpStaticMocks();
    }

    private ItemStack mockSalvageableItem(SalvageableManager salvageableManager) {
        final ItemStack item = mock(ItemStack.class);
        when(item.getAmount()).thenReturn(1);
        when(item.clone()).thenReturn(item);
        when(salvageableManager.isSalvageable(item)).thenReturn(true);
        return item;
    }

    private void mockSalvageableConfig(Material material, int minimumLevel) {
        final Salvageable salvageable = mock(Salvageable.class);
        when(salvageable.getMinimumLevel()).thenReturn(minimumLevel);
        when(salvageableManager.getSalvageable(material)).thenReturn(salvageable);
    }

    private PlayerInteractEvent fireRightClick(Action action, Block block, ItemStack heldItem) {
        when(playerInventory.getItemInMainHand()).thenReturn(heldItem);
        final PlayerInteractEvent event = new PlayerInteractEvent(player, action, heldItem, block,
                BlockFace.SELF, EquipmentSlot.HAND);
        dispatchLikeEventBus(event);
        return event;
    }

    /**
     * Mirrors the Bukkit event bus contract for the listener's interact handlers: handlers run
     * in priority order and handlers registered with ignoreCancelled are skipped once the event
     * is cancelled. A {@link PlayerInteractEvent} with no clicked block reports itself as
     * cancelled from the moment it is constructed, so right-click-air interactions only ever
     * reach handlers that do not ignore cancelled events. The ability/item activation handler
     * (HIGHEST) and monitor handlers are excluded: this suite covers the anvil confirmation
     * flow, and the activation handler needs world and skill state this harness does not mock.
     */
    private void dispatchLikeEventBus(PlayerInteractEvent event) {
        final List<Method> handlers = Arrays.stream(PlayerListener.class.getMethods())
                .filter(method -> method.isAnnotationPresent(EventHandler.class))
                .filter(method -> method.getParameterCount() == 1
                        && method.getParameterTypes()[0] == PlayerInteractEvent.class)
                .filter(method -> method.getAnnotation(EventHandler.class).priority()
                        != EventPriority.MONITOR
                        && method.getAnnotation(EventHandler.class).priority()
                        != EventPriority.HIGHEST)
                .sorted(Comparator.comparing(
                        method -> method.getAnnotation(EventHandler.class).priority()))
                .toList();

        for (final Method handler : handlers) {
            if (handler.getAnnotation(EventHandler.class).ignoreCancelled()
                    && event.isCancelled()) {
                continue;
            }

            try {
                handler.invoke(playerListener, event);
            } catch (ReflectiveOperationException e) {
                throw new AssertionError("Failed to dispatch to " + handler.getName(), e);
            }
        }
    }

    @Test
    void firstAnvilClickShouldPromptAndCancelInteraction() {
        // Given - a player holding a salvageable helmet with no salvage confirmation pending
        // When - the player right-clicks the salvage anvil
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // Then - the interaction is fully cancelled so the helmet is not equipped
        assertThat(event.useInteractedBlock()).isEqualTo(Event.Result.DENY);
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
        // And - nothing is salvaged before the player confirms
        verify(salvageManager, never()).handleSalvage(any(), any());
    }

    @Test
    void secondAnvilClickShouldSalvageWhenSameItemIsStillHeld() {
        // Given - the player was prompted to confirm salvaging the helmet
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - the player right-clicks the anvil again while still holding the same helmet
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // Then - the helmet is salvaged and the interaction stays cancelled
        verify(salvageManager).handleSalvage(any(), eq(helmet));
        assertThat(event.useInteractedBlock()).isEqualTo(Event.Result.DENY);
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
    }

    @Test
    void confirmationClickShouldNotSalvageWhenHeldItemChangedSincePrompt() {
        // Given - the player was prompted to confirm salvaging the helmet
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - vanilla armor quick-equip swapped the held item and the player clicks again
        // while holding the previously worn chestplate
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, chestplate);

        // Then - the chestplate is not salvaged; the player must confirm the new item instead
        verify(salvageManager, never()).handleSalvage(any(), any());
    }

    @Test
    void rightClickAirShouldNotEquipHeldItemWhileConfirmationIsPending() {
        // Given - the player was prompted to confirm salvaging the helmet
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - the follow-up use-item interaction resolves as a right-click on air, which is
        // how the server reports the armor-equip attempt when its raytrace misses the anvil
        final PlayerInteractEvent event = fireRightClick(Action.RIGHT_CLICK_AIR, null, helmet);

        // Then - using the held item is denied so the helmet cannot be equipped
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
    }

    @Test
    void rightClickOtherBlockShouldNotEquipHeldItemWhileConfirmationIsPending() {
        // Given - the player was prompted to confirm salvaging the helmet
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - the player mis-clicks a nearby non-anvil block while holding the helmet
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, dirtBlock, helmet);

        // Then - using the held item is denied so the helmet cannot be equipped
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
    }

    @Test
    void rightClickAirShouldNotDenyItemUseWhenNoConfirmationIsPending() {
        // Given - a player holding a salvageable helmet with no salvage confirmation pending
        // When - the player right-clicks air
        final PlayerInteractEvent event = fireRightClick(Action.RIGHT_CLICK_AIR, null, helmet);

        // Then - the interaction is left alone so vanilla equipping still works
        assertThat(event.useItemInHand()).isNotEqualTo(Event.Result.DENY);
    }

    @Test
    void anvilClickShouldNotEquipHeldItemWhenSneakIsReleasedMidConfirmation() {
        // Given - salvage interactions require sneaking and the player was prompted to confirm
        // salvaging the helmet while sneaking
        when(generalConfig.getAbilitiesOnlyActivateWhenSneaking()).thenReturn(true);
        when(player.isSneaking()).thenReturn(true);
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - the player releases sneak and right-clicks the anvil again with the helmet,
        // which skips the salvage branch and leaves the anvil's use-item allowance in place
        when(player.isSneaking()).thenReturn(false);
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // Then - using the held item stays denied so the helmet cannot be equipped
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
        // And - nothing is salvaged without a confirmation click
        verify(salvageManager, never()).handleSalvage(any(), any());
    }

    @Test
    void anvilClickShouldExplainLockedScrapCollectorInsteadOfEquipping() {
        // Given - a salvageable helmet whose item level requirement the player already meets,
        // held by a player who has not unlocked Scrap Collector
        when(RankUtils.hasUnlockedSubskill(player, SubSkillType.SALVAGE_SCRAP_COLLECTOR))
                .thenReturn(false);
        when(RankUtils.getRankUnlockLevel(SubSkillType.SALVAGE_SCRAP_COLLECTOR, 1))
                .thenReturn(20);
        when(helmet.getType()).thenReturn(Material.DIAMOND_HELMET);
        mockSalvageableConfig(Material.DIAMOND_HELMET, 0);
        when(mmoPlayer.getSkillLevel(PrimarySkillType.SALVAGE)).thenReturn(5);

        // When - the player right-clicks the salvage anvil
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // Then - the interaction is fully cancelled so the helmet is not equipped
        assertThat(event.useInteractedBlock()).isEqualTo(Event.Result.DENY);
        assertThat(event.useItemInHand()).isEqualTo(Event.Result.DENY);
        // And - the chat message only covers Scrap Collector, the one requirement still missing
        notificationManager.verify(() -> NotificationManager.sendPlayerInformationChatOnly(
                player, "Salvage.Skills.ScrapCollector.Locked", "20"));
        verify(salvageManager, never()).handleSalvage(any(), any());
    }

    @Test
    void lockedScrapCollectorMessageShouldIncludeItemLevelWhenThatIsAlsoMissing() {
        // Given - a salvageable helmet with an item level requirement the player has not
        // reached, held by a player who has not unlocked Scrap Collector either
        when(RankUtils.hasUnlockedSubskill(player, SubSkillType.SALVAGE_SCRAP_COLLECTOR))
                .thenReturn(false);
        when(RankUtils.getRankUnlockLevel(SubSkillType.SALVAGE_SCRAP_COLLECTOR, 1))
                .thenReturn(20);
        when(helmet.getType()).thenReturn(Material.DIAMOND_HELMET);
        mockSalvageableConfig(Material.DIAMOND_HELMET, 50);
        when(mmoPlayer.getSkillLevel(PrimarySkillType.SALVAGE)).thenReturn(5);

        // When - the player right-clicks the salvage anvil
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // Then - the chat message names both missing requirements and the item
        notificationManager.verify(() -> NotificationManager.sendPlayerInformationChatOnly(
                player, "Salvage.Skills.ScrapCollector.LockedAndItem", "20", "50",
                "Diamond Helmet"));
        verify(salvageManager, never()).handleSalvage(any(), any());
    }

    @Test
    void anvilClickWithUnsalvageableItemShouldStayVanillaWhenScrapCollectorLocked() {
        // Given - Scrap Collector locked and a held item no salvage config covers
        when(RankUtils.hasUnlockedSubskill(player, SubSkillType.SALVAGE_SCRAP_COLLECTOR))
                .thenReturn(false);
        final ItemStack stone = mock(ItemStack.class);
        when(stone.getAmount()).thenReturn(1);

        // When - the player right-clicks the salvage anvil
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, stone);

        // Then - the click stays vanilla and no locked message is sent
        assertThat(event.useItemInHand()).isNotEqualTo(Event.Result.DENY);
        notificationManager.verifyNoInteractions();
    }

    @Test
    void rightClickAirShouldNotDenyItemUseWhenHeldItemDiffersFromPromptedItem() {
        // Given - the player was prompted to confirm salvaging the helmet
        fireRightClick(Action.RIGHT_CLICK_BLOCK, anvilBlock, helmet);

        // When - the player right-clicks air while holding a different piece of armor
        final PlayerInteractEvent event =
                fireRightClick(Action.RIGHT_CLICK_AIR, null, chestplate);

        // Then - the interaction is left alone so the other piece can still be equipped
        assertThat(event.useItemInHand()).isNotEqualTo(Event.Result.DENY);
    }
}
