package cz.jzitnik.game;

import cz.jzitnik.game.entities.Block;
import cz.jzitnik.game.entities.GameStates;
import cz.jzitnik.game.entities.Player;
import cz.jzitnik.game.entities.SteveData;
import cz.jzitnik.game.generation.Generation;
import cz.jzitnik.game.entities.items.Item;
import cz.jzitnik.game.entities.items.ItemType;
import cz.jzitnik.game.handlers.place.CustomPlaceHandler;
import cz.jzitnik.game.mobs.EntitySpawnProvider;
import cz.jzitnik.game.sprites.Breaking;
import cz.jzitnik.game.sprites.Steve.SteveState;
import cz.jzitnik.game.annotations.WalkSound;
import cz.jzitnik.game.annotations.BreaksByPlace;
import cz.jzitnik.game.annotations.MineSound;
import cz.jzitnik.game.annotations.MiningSound;
import cz.jzitnik.game.annotations.PlaceSound;
import cz.jzitnik.game.blocks.Chest;
import cz.jzitnik.game.blocks.Furnace;
import cz.jzitnik.game.config.Configuration;
import cz.jzitnik.game.core.sound.SoundKey;
import cz.jzitnik.game.ui.Window;
import cz.jzitnik.game.ui.Inventory;
import cz.jzitnik.game.handlers.rightclick.RightClickHandlerProvider;
import cz.jzitnik.tui.ScreenMovingCalculationProvider;
import cz.jzitnik.tui.ScreenRenderer;
import cz.jzitnik.game.stats.Stats;
import lombok.Getter;
import lombok.Setter;

import org.jline.terminal.Terminal;

import java.awt.*;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Main class representing the game world and core logic.
 * <p>
 * Manages the game state, player actions, world updates and interactions.
 */
@Getter
public class Game {
    /**
     * The 2D world grid consisting of block lists for each coordinate.
     * Dimensions: [height][width] = [256][512].
     */
    @SuppressWarnings("unchecked")
    private final List<Block>[][] world = (List<Block>[][]) new CopyOnWriteArrayList[256][512];
    private final Player player = new Player();
    private transient boolean mining = false;
    @Setter
    private transient Window window = Window.WORLD;
    private final Inventory inventory = new Inventory();
    private transient EntitySpawnProvider entitySpawnProvider = new EntitySpawnProvider();
    private transient GameStates gameStates = new GameStates(this);
    private Stats stats = new Stats();

    /**
     * Current time of day in the game (0–600 range).
     */
    @Setter
    private int daytime = 0;
    //
    private Configuration configuration = new Configuration();

    /**
     * Constructs the game instance and generates the initial world.
     */
    public Game() {
        Generation.generateWorld(this);
    }

    public boolean isNight(){
        return daytime > 200 && daytime < 400;
    }

    /**
     * Returns the current coordinates of the player (bottom half).
     *
     * @return int array with x and y coordinates, or null if not found.
     */
    public int[] getPlayerCords() {
        for (int i = 0; i < world.length; i++) {
            for (int j = 0; j < world[i].length; j++) {
                for (Block block : world[i][j]) {
                    if (block.getBlockId().equals("steve")) {
                        var steveData = (SteveData) block.getData();

                        if (steveData.isTop()) {
                            return new int[]{j, i + 1};
                        } else {
                            return new int[]{j, i};
                        }
                    }
                }
            }
        }
        return null;
    }

    public Point getPlayerCordsPoint() {
        int[] cords = getPlayerCords();

        return new Point(cords[0], cords[1]);
    }

    /**
     * Moves the player one block to the right if possible.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     * @param terminal       the terminal instance used for layout.
     */
    public void movePlayerRight(ScreenRenderer screenRenderer, Terminal terminal) {
        if (window != Window.WORLD) {
            return;
        }
        int[] cords = getPlayerCords();

        if (isSolid(world[cords[1]][cords[0] + 1]) || isSolid(world[cords[1] - 1][cords[0] + 1])) {
            return;
        }

        world[cords[1]][cords[0] + 1].add(player.getPlayerBlock2());
        world[cords[1]][cords[0]].remove(player.getPlayerBlock2());
        world[cords[1] - 1][cords[0] + 1].add(player.getPlayerBlock1());
        world[cords[1] - 1][cords[0]].remove(player.getPlayerBlock1());
        new Thread(() -> screenRenderer.render(this)).start();

        stats.setBlocksTraveled(stats.getBlocksTraveled() + 1);

        new Thread(() -> entitySpawnProvider.update(this, terminal)).start();

        playMovePlayerSound(cords[0] + 1, cords[1]);

        update(screenRenderer);
    }

    /**
     * Moves the player one block to the left if possible.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     * @param terminal       the terminal instance used for layout.
     */
    public void movePlayerLeft(ScreenRenderer screenRenderer, Terminal terminal) {
        if (window != Window.WORLD) {
            return;
        }
        int[] cords = getPlayerCords();

        if (isSolid(world[cords[1]][cords[0] - 1]) || isSolid(world[cords[1] - 1][cords[0] - 1])) {
            return;
        }

        world[cords[1]][cords[0] - 1].add(player.getPlayerBlock2());
        world[cords[1]][cords[0]].remove(player.getPlayerBlock2());
        world[cords[1] - 1][cords[0] - 1].add(player.getPlayerBlock1());
        world[cords[1] - 1][cords[0]].remove(player.getPlayerBlock1());
        new Thread(() -> screenRenderer.render(this)).start();

        stats.setBlocksTraveled(stats.getBlocksTraveled() + 1);

        new Thread(() -> entitySpawnProvider.update(this, terminal)).start();

        playMovePlayerSound(cords[0] - 1, cords[1]);

        update(screenRenderer);
    }

    /**
     * Moves the player upward by one block, if jumping is valid.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     */
    public void movePlayerUp(ScreenRenderer screenRenderer) {
        if (window != Window.WORLD) {
            return;
        }
        int[] cords = getPlayerCords();

        if (isSolid(world[cords[1] - 2][cords[0]]) || !isSolid(world[cords[1] + 1][cords[0]])) {
            return;
        }

        world[cords[1] - 1][cords[0]].remove(player.getPlayerBlock1());
        world[cords[1] - 1][cords[0]].add(player.getPlayerBlock2());
        world[cords[1] - 2][cords[0]].add(player.getPlayerBlock1());
        world[cords[1]][cords[0]].remove(player.getPlayerBlock2());

        stats.setBlocksTraveled(stats.getBlocksTraveled() + 1);

        new Thread(() -> {
            try {
                Thread.sleep(400);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            update(screenRenderer);
        }).start();
    }

    /**
     * Attacks mobs at a specified location.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     * @param x              x-coordinate.
     * @param y              y-coordinate.
     */
    public void hit(ScreenRenderer screenRenderer, int x, int y) {
        if (mining || window != Window.WORLD) {
            return;
        }

        List<Block> mobs = world[y][x].stream().filter(Block::isMob).toList();
        gameStates.dependencies.sound.playSound(configuration, SoundKey.HIT, null);

        for (Block mob : mobs) {
            int dealDamage = inventory.getItemInHand().map(Item::getDealDamage).orElse(1);
            if (mob.getHp() - dealDamage <= 0) {
                // Mob is killed
                gameStates.dependencies.entityKill.get(mob.getBlockId()).killed(this, mob, x, y);
                world[y][x].remove(mob);
            } else {
                mob.decreaseHp(dealDamage);
                mob.setSpriteState(gameStates.dependencies.entityHurtAnimation.get(mob.getBlockId())
                        .setHurtAnimation(true, mob.getSpriteState().get()));
                if (mob.getLinkedMobTexture() != null) {
                    mob.getLinkedMobTexture().setSpriteState(gameStates.dependencies.entityHurtAnimation.get(mob.getBlockId())
                            .setHurtAnimation(true, mob.getLinkedMobTexture().getSpriteState().get()));
                }
            }
        }
        screenRenderer.render(this);

        new Thread(() -> {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            for (Block mob : mobs) {
                mob.setSpriteState(gameStates.dependencies.entityHurtAnimation.get(mob.getBlockId())
                        .setHurtAnimation(false, mob.getSpriteState().get()));

                if (mob.getLinkedMobTexture() != null) {
                    mob.getLinkedMobTexture().setSpriteState(gameStates.dependencies.entityHurtAnimation.get(mob.getBlockId())
                            .setHurtAnimation(false, mob.getLinkedMobTexture().getSpriteState().get()));
                }
            }
            screenRenderer.render(this);
        }).start();
    }

    /**
     * Initiates the mining process at a given location.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     * @param x              x-coordinate.
     * @param y              y-coordinate.
     */
    public void mine(ScreenRenderer screenRenderer, int x, int y) {
        if (mining || window != Window.WORLD) {
            return;
        }

        Block breakingBlock = new Block("breaking", SpriteLoader.SPRITES.BREAKING);
        breakingBlock.setGhost(true);
        world[y][x].add(breakingBlock);
        screenRenderer.render(this);

        double hardness = world[y][x].stream().filter(block -> !block.getBlockId().equals("air")).toList().getFirst()
                .calculateHardness(inventory);

        var blocks = world[y][x];

        this.mining = true;

        new Thread(() -> {
            for (Block block : blocks) {
                if (block.getClass().isAnnotationPresent(MiningSound.class)) {
                    new Thread(() -> {
                        var anot = block.getClass().getAnnotation(MiningSound.class);
                        gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
                    }).start();
                }
            }
            try {
                Thread.sleep((long) (hardness * 166));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            breakingBlock.setSpriteState(Breaking.BreakingState.SECOND);
            screenRenderer.render(this);
            for (Block block : blocks) {
                if (block.getClass().isAnnotationPresent(MiningSound.class)) {
                    new Thread(() -> {
                        var anot = block.getClass().getAnnotation(MiningSound.class);
                        gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
                    }).start();
                }
            }
            try {
                Thread.sleep((long) (hardness * 166));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            breakingBlock.setSpriteState(Breaking.BreakingState.THIRD);
            screenRenderer.render(this);
            for (Block block : blocks) {
                if (block.getClass().isAnnotationPresent(MiningSound.class)) {
                    new Thread(() -> {
                        var anot = block.getClass().getAnnotation(MiningSound.class);
                        gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
                    }).start();
                }
            }
            try {
                Thread.sleep((long) (hardness * 166));
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

            mining = false;

            mineInstant(screenRenderer, x, y, true);
        }).start();
    }

    /**
     * Instantly mines a block without animation delay.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     * @param x              x-coordinate.
     * @param y              y-coordinate.
     * @param minedDirectly  whether the block was mined directly by player.
     */
    public void mineInstant(ScreenRenderer screenRenderer, int x, int y, boolean minedDirectly) {
        var blocks = world[y][x];
        var blocksCopy = new ArrayList<>(blocks);
        CustomPlaceHandler customPlaceHandler = gameStates.dependencies.placeHandler
                .get(blocks.stream().filter(Block::isMineable).toList().getFirst().getBlockId());
        boolean giveItem = customPlaceHandler.mine(this, x, y);

        blocksCopy.forEach((e) -> {
            e.setOnFire(false);
            e.setBurningTime(0);
        });

        if (giveItem) {
            for (Block block : blocksCopy) {
                if (!block.isMineable()) {
                    continue;
                }

                if (block.getToolVariants().isEmpty()) {
                    // Add to inv
                    block.getDrops().forEach(inventory::addItem);
                    continue;
                }

                var toolVariants = block.getToolVariants();
                if (inventory.getItemInHand().isPresent()
                        && inventory.getItemInHand().get().getToolVariant().isPresent() && block.getTool().isPresent()
                        && block.getTool().get().equals(inventory.getItemInHand().get().getType())
                        && toolVariants.contains(inventory.getItemInHand().get().getToolVariant().get())
                        && minedDirectly) {
                    block.getDrops().forEach(inventory::addItem);
                }
            }
        }

        for (Block block : blocksCopy) {
            if (block.getBlockId().equals("chest")) {
                ((Chest) block.getData()).breakBlock(this);
            } else if (block.getBlockId().equals("furnace")) {
                ((Furnace) block.getData()).breakBlock(this);
            }
        }

        if (inventory.getItemInHand().isPresent() && inventory.getItemInHand().get().getMaxDurability() != 0) {
            boolean broken = inventory.getItemInHand().get().use();
            if (broken) {
                inventory.decreaseItemInHand();
            }
        }

        gameStates.dependencies.eventHandlerProvider.handleMine(screenRenderer, this, x, y);

        for (Block block : blocksCopy) {
            if (block.getClass().isAnnotationPresent(MineSound.class)) {
                new Thread(() -> {
                    var anot = block.getClass().getAnnotation(MineSound.class);
                    gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
                }).start();
            }
        }

        stats.setBlocksMined(stats.getBlocksMined() + 1);

        screenRenderer.render(this);

        update(screenRenderer);
    }

    /**
     * Checks whether a block is valid for mining based on proximity and visibility.
     *
     * @param x        x-coordinate.
     * @param y        y-coordinate.
     * @param terminal terminal context used to determine screen bounds.
     * @return true if mineable, false otherwise.
     */
    public boolean isMineable(int x, int y, Terminal terminal) {
        List<Block> blocks = world[y][x];

        int[] cords = getPlayerCords();

        int playerX = cords[0];
        int playerY = cords[1];

        int distanceX = Math.abs(playerX - x);
        int distanceY = Math.abs(playerY - y);

        int[] data = ScreenMovingCalculationProvider.calculate(playerX, playerY, terminal.getHeight(),
                terminal.getWidth(), world[0].length, world.length);

        int startX = data[0];
        int endX = data[1];
        int startY = data[2];
        int endY = data[3];

        return y >= startY && y < endY - 1 && x >= startX && x < endX - 1 && distanceX <= 5 && distanceY <= 5
                && !(playerX == x && playerY == y) && !(playerX == x && playerY - 1 == y)
                && blocks.stream().anyMatch(Block::isMineable);
    }


    /**
     * Checks whether a block is valid for hitting (attacking).
     *
     * @param x        x-coordinate.
     * @param y        y-coordinate.
     * @param terminal terminal context used to determine screen bounds.
     * @return true if a mob can be hit at the given location.
     */
    public boolean isHitable(int x, int y, Terminal terminal) {
        List<Block> blocks = world[y][x];

        int[] cords = getPlayerCords();

        int playerX = cords[0];
        int playerY = cords[1];

        int distanceX = Math.abs(playerX - x);
        int distanceY = Math.abs(playerY - y);

        int[] data = ScreenMovingCalculationProvider.calculate(playerX, playerY, terminal.getHeight(),
                terminal.getWidth(), world[0].length, world.length);

        int startX = data[0];
        int endX = data[1];
        int startY = data[2];
        int endY = data[3];

        return y >= startY && y < endY - 1 && x >= startX && x < endX - 1 && distanceX <= 5 && distanceY <= 5
                && !(playerX == x && playerY == y) && !(playerX == x && playerY - 1 == y)
                && blocks.stream().anyMatch(Block::isMob);
    }

    /**
     * Periodically checks and updates the player’s falling state due to gravity.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     */
    public void update(ScreenRenderer screenRenderer) {
        while (true) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            int[] cords2 = getPlayerCords();

            if (!isSolid(world[cords2[1] + 1][cords2[0]])) {
                world[cords2[1] - 1][cords2[0]].remove(player.getPlayerBlock1());
                world[cords2[1]][cords2[0]].add(player.getPlayerBlock1());
                world[cords2[1] + 1][cords2[0]].add(player.getPlayerBlock2());
                world[cords2[1]][cords2[0]].remove(player.getPlayerBlock2());
                player.addFalling();

                stats.setBlocksTraveled(stats.getBlocksTraveled() + 1);

                screenRenderer.render(this);
            } else {
                ArrayList<Block> combinedList = new ArrayList<>();
                combinedList.addAll(world[cords2[1]][cords2[0]]);
                combinedList.addAll(world[cords2[1] + 1][cords2[0]]);
                if (player.getFallDistance() != 0) {
                    playMovePlayerSound(cords2[0], cords2[1]);
                }
                player.fell(combinedList, this, screenRenderer);
                screenRenderer.render(this);
                break;
            }
        }
    }

    /**
     * Attempts to place a block or interact with the environment at a given location.
     * Also handles eating and tool usage.
     *
     * @param x              x-coordinate.
     * @param y              y-coordinate.
     * @param screenRenderer the renderer used to refresh the screen.
     */
    public void build(int x, int y, ScreenRenderer screenRenderer) {
        if (window != Window.WORLD) {
            return;
        }
        if (inventory.getItemInHand().isPresent() && inventory.getItemInHand().get().getType() == ItemType.FOOD) {
            if (player.getHunger() >= 10) {
                return;
            }

            player.setHunger(Math.min(10, player.getHunger() + inventory.getItemInHand().get().getAddHunger()));
            inventory.decreaseItemInHand();
            screenRenderer.render(this);
            return;
        }
        var blocks = world[y][x];

        int[] cords = getPlayerCords();

        int playerX = cords[0];
        int playerY = cords[1];

        int distanceX = Math.abs(playerX - x);
        int distanceY = Math.abs(playerY - y);

        if (distanceX > 5 || distanceY > 5) {
            return;
        }

        if (inventory.getItemInHand().isPresent() && inventory.getItemInHand().get().getType() == ItemType.PICKUPER) {
            if (gameStates.dependencies.pickupHandlerProvider.get(inventory.getItemInHand().get().getId()).handle(this,
                    x, y)) {
                screenRenderer.render(this);
                return;
            }
        }

        if (!blocks.stream().allMatch(block -> block.getBlockId().equals("air") || block.isFlowing()
                || block.getClass().isAnnotationPresent(BreaksByPlace.class))) {
            boolean toolUsed = false;
            if (inventory.getItemInHand().isPresent()) {
                var item = inventory.getItemInHand().get();
                toolUsed = gameStates.dependencies.toolUseProvider.handle(item.getType(), this, x, y);
                if (toolUsed) {
                    boolean broken = item.use();
                    if (broken) {
                        inventory.decreaseItemInHand();
                    }
                }
            }

            if (!toolUsed) {
                RightClickHandlerProvider.handle(x, y, this, screenRenderer);
            }
            screenRenderer.render(this);
            return;
        }

        if (!(inventory.getItemInHand().isPresent() && inventory.getItemInHand().get().getType() == ItemType.BLOCK)) {
            return;
        }

        Item item = inventory.getItemInHand().get();

        CustomPlaceHandler placeHandler = gameStates.dependencies.placeHandler.get(item.getBlock().get().getBlockId());

        var blocksRemove = blocks.stream().filter(block -> block.getClass().isAnnotationPresent(BreaksByPlace.class))
                .toList();

        if (placeHandler.place(this, x, y)) {
            blocks.removeAll(blocksRemove);
            for (Block block : blocks) {
                if (block.getClass().isAnnotationPresent(PlaceSound.class)) {
                    var anot = block.getClass().getAnnotation(PlaceSound.class);
                    gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
                }
            }
            gameStates.dependencies.eventHandlerProvider.handlePlace(screenRenderer, this, x, y);
            stats.setBlocksPlaced(stats.getBlocksPlaced() + 1);
            screenRenderer.render(this);
        }
    }

    /**
     * Switches the selected inventory slot (hotbar).
     *
     * @param slot           the slot index to switch to.
     * @param screenRenderer the renderer used to refresh the screen.
     */
    public void changeSlot(int slot, ScreenRenderer screenRenderer) {
        if (window != Window.WORLD) {
            return;
        }

        inventory.setItemInhHandIndex(slot);
        screenRenderer.render(this);
    }

    /**
     * Checks if a block list represents a solid space (i.e., not all ghost blocks).
     *
     * @param blocks list of blocks at a coordinate.
     * @return true if any block is not a ghost block.
     */
    public boolean isSolid(List<Block> blocks) {
        return !blocks.stream().allMatch(block -> block.isGhost() || block.isMob());
    }

    /**
     * Visually and logically triggers the player "hit" (damage) animation.
     *
     * @param screenRenderer the renderer used to refresh the screen.
     */
    public void playerHit(ScreenRenderer screenRenderer) {
        player.getPlayerBlock1().setSpriteState(SteveState.FIRST_HURT);
        player.getPlayerBlock2().setSpriteState(SteveState.SECOND_HURT);

        new Thread(() -> {
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            player.getPlayerBlock1().setSpriteState(SteveState.FIRST);
            player.getPlayerBlock2().setSpriteState(SteveState.SECOND);
            screenRenderer.render(this);
        }).start();
    }

    private void playMovePlayerSound(int x, int y) {
        var blocks = world[y + 1][x];

        for (Block block : blocks) {
            if (block.getClass().isAnnotationPresent(WalkSound.class)) {
                var anot = block.getClass().getAnnotation(WalkSound.class);
                gameStates.dependencies.sound.playSound(configuration, anot.value(), anot.annotationType());
            }
        }
    }

    public Object getContext(Class clazz) {
        return gameStates.globalContextProvider.getMap(clazz);
    }
}
