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.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.gmail.nossr50.MMOTestEnvironment;
import com.gmail.nossr50.config.experience.ExperienceConfig;
import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
import com.gmail.nossr50.mcMMO;
import com.gmail.nossr50.skills.fishing.FishingManager;
import com.gmail.nossr50.util.Permissions;
import com.gmail.nossr50.util.player.UserManager;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.entity.FishHook;
import org.bukkit.entity.Item;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerFishEvent;
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 fishing anti-exploit flow of {@link PlayerListener}. Since the exploit checks were
 * split across two handlers (flagging at LOWEST so other plugins can react, reward gating at
 * HIGHEST), the contract under test is that a catch denied by either exploit check — same-spot
 * overfishing or catching again within a second — must not pay mcMMO rewards from the later
 * handler. Events are dispatched through every {@link PlayerFishEvent} handler honoring their
 * real {@link EventHandler} priorities, because the flag written by the LOWEST handler is what
 * the HIGHEST handler's gate reads.
 */
class PlayerListenerFishingExploitTest extends MMOTestEnvironment {
    private static final Logger logger =
            getLogger(PlayerListenerFishingExploitTest.class.getName());

    private static final int VANILLA_CATCH_XP = 5;
    private static final int OVER_FISH_LIMIT = 10;

    private PlayerListener playerListener;
    private FishingManager fishingManager;
    private FishHook hook;

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

        when(UserManager.hasPlayerDataKey(player)).thenReturn(true);
        when(Permissions.skillEnabled(player, PrimarySkillType.FISHING)).thenReturn(true);

        when(ExperienceConfig.getInstance().isFishingExploitingPrevented()).thenReturn(true);
        when(ExperienceConfig.getInstance().getFishingExploitingOptionMoveRange()).thenReturn(3);
        when(ExperienceConfig.getInstance().getFishingExploitingOptionOverFishLimit())
                .thenReturn(OVER_FISH_LIMIT);

        // Every cast lands on the same spot so same-target tracking behaves like a real
        // stationary fisher; individual tests choose whether that trips the overfish limit
        hook = mock(FishHook.class);
        when(hook.getLocation()).thenReturn(new Location(world, 10, 64, 10));

        fishingManager = Mockito.spy(new FishingManager(mmoPlayer));
        doReturn(fishingManager).when(mmoPlayer).getFishingManager();
        doNothing().when(fishingManager).processFishing(any(), any());
        doNothing().when(fishingManager).setFishingTarget();
    }

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

    private PlayerFishEvent castAndCatchFish() {
        final Item caughtItem = mock(Item.class);
        when(caughtItem.getItemStack()).thenReturn(mock(ItemStack.class));

        final PlayerFishEvent event = new PlayerFishEvent(player, caughtItem, hook,
                EquipmentSlot.HAND, PlayerFishEvent.State.CAUGHT_FISH);
        event.setExpToDrop(VANILLA_CATCH_XP);
        dispatchLikeEventBus(event);
        return event;
    }

    /**
     * Mirrors the Bukkit event bus contract for the listener's fish handlers: handlers run in
     * priority order and handlers registered with ignoreCancelled are skipped once the event is
     * cancelled.
     */
    private void dispatchLikeEventBus(PlayerFishEvent event) {
        final List<Method> handlers = Arrays.stream(PlayerListener.class.getMethods())
                .filter(method -> method.isAnnotationPresent(EventHandler.class))
                .filter(method -> method.getParameterCount() == 1
                        && method.getParameterTypes()[0] == PlayerFishEvent.class)
                .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 quickRepeatCatchShouldNotPayMcMMORewards() {
        // Given - exploit prevention is on and the fishing spot is under the overfish limit
        // When - the player lands a legitimate catch
        final PlayerFishEvent firstCatch = castAndCatchFish();

        // Then - the catch pays mcMMO rewards and keeps its vanilla XP
        verify(fishingManager, times(1)).processFishing(any(), any());
        assertThat(firstCatch.getExpToDrop()).isEqualTo(VANILLA_CATCH_XP);
        verify((Item) firstCatch.getCaught(), never()).remove();

        // When - the player lands another catch within a second of the first
        final PlayerFishEvent secondCatch = castAndCatchFish();

        // Then - the spam catch is fully denied: the caught item is deleted, the vanilla XP is
        // zeroed, and no mcMMO rewards (skill XP, treasure roll, extra fish) are processed
        verify((Item) secondCatch.getCaught()).remove();
        assertThat(secondCatch.getExpToDrop()).isZero();
        verify(fishingManager, times(1)).processFishing(any(), any());
    }

    @Test
    void overfishedSpotCatchShouldNotPayMcMMORewards() {
        // Given - exploit prevention is on and the overfish limit is two catches per spot
        when(ExperienceConfig.getInstance().getFishingExploitingOptionOverFishLimit())
                .thenReturn(2);

        // When - the player lands a legitimate catch
        final PlayerFishEvent firstCatch = castAndCatchFish();

        // Then - the catch pays mcMMO rewards
        verify(fishingManager, times(1)).processFishing(any(), any());
        verify((Item) firstCatch.getCaught(), never()).remove();

        // When - the player catches again without moving off the overfished spot
        final PlayerFishEvent secondCatch = castAndCatchFish();

        // Then - the overfished catch is fully denied: the caught item is deleted, the vanilla
        // XP is zeroed, and no mcMMO rewards are processed
        verify((Item) secondCatch.getCaught()).remove();
        assertThat(secondCatch.getExpToDrop()).isZero();
        verify(fishingManager, times(1)).processFishing(any(), any());
    }
}
