package eu.jonahbauer.wizard.server;

import eu.jonahbauer.wizard.common.messages.data.PlayerData;
import eu.jonahbauer.wizard.common.messages.data.SessionData;
import eu.jonahbauer.wizard.common.messages.observer.ObserverMessage;
import eu.jonahbauer.wizard.common.messages.player.PlayerMessage;
import eu.jonahbauer.wizard.common.messages.server.*;
import eu.jonahbauer.wizard.common.model.Configuration;
import eu.jonahbauer.wizard.core.machine.Game;
import eu.jonahbauer.wizard.core.messages.Observer;
import eu.jonahbauer.wizard.core.model.Configurations;
import eu.jonahbauer.wizard.core.util.Pair;
import eu.jonahbauer.wizard.server.machine.Player;
import lombok.AccessLevel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.extern.log4j.Log4j2;

import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadLocalRandom;

@Getter
@Log4j2
@EqualsAndHashCode(of = "uuid")
public class Session implements Observer {
    private static final int MIN_PLAYERS = 3;
    private static final int MAX_PLAYERS = 6;

    private final UUID uuid;
    private final String name;
    private final long timeout;
    private final Configuration configuration;

    @Getter(AccessLevel.NONE)
    private final Map<UUID, SessionPlayer> players = new HashMap<>();

    private Game game;
    private final List<Pair<UUID, ServerMessage>> messages = new ArrayList<>();


    public Session(UUID uuid, String name, long timeout, Configuration configuration) {
        this.uuid = uuid;
        this.name = name;
        this.timeout = timeout;
        this.configuration = configuration;
    }

    /**
     * Associates the given player with this session and notifies all other players in the
     * session with a {@link PlayerJoinedMessage}, the joining player with a {@link SessionJoinedMessage} and all
     * players in the lobby with a {@link SessionModifiedMessage}.
     *
     * @param player the player
     * @param name the players chosen name
     * @return the players uuid
     */
    public synchronized UUID join(Player player, String name) {
        if (game != null) {
            throw new NackException(NackMessage.GAME_ALREADY_STARTED, "Game has already started.");
        } else if (players.size() == MAX_PLAYERS) {
            throw new NackException(NackMessage.SESSION_FULL, "Session is full.");
        } else if (players.values().stream().anyMatch(p -> p.getName().equalsIgnoreCase(name))) {
            throw new NackException(NackMessage.NAME_TAKEN, "Name is already taken.");
        }

        SessionPlayer sessionPlayer;
        do {
            sessionPlayer = new SessionPlayer(UUID.randomUUID(), name);
        } while (players.putIfAbsent(sessionPlayer.getUuid(), sessionPlayer) != null);
        sessionPlayer.setPlayer(player);

        notifyJoined(sessionPlayer.toData());
        Lobby.getInstance().notifyPlayers(new SessionModifiedMessage(toData()));

        return sessionPlayer.getUuid();
    }

    public synchronized PlayerData rejoin(Player player, UUID uuid, String secret) {
        if (!players.containsKey(uuid)) {
            throw new NackException(NackMessage.ILLEGAL_ARGUMENT, "An error occurred.");
        }

        SessionPlayer sessionPlayer = players.get(uuid);
        if (sessionPlayer.isConnected()) {
            throw new NackException(NackMessage.ILLEGAL_ARGUMENT, "An error occurred.");
        } else if (!sessionPlayer.getSecret().equals(secret)) {
            throw new NackException(NackMessage.ILLEGAL_ARGUMENT, "An error occurred.");
        }

        sessionPlayer.setPlayer(player);
        player.send(new SessionJoinedMessage(
                this.uuid,
                uuid,
                players.values().stream().map(SessionPlayer::toData).toList(),
                secret
        ));
        for (Pair<UUID, ServerMessage> message : messages) {
            if (message.getKey() == null || message.getKey().equals(uuid)) {
                player.send(message.getValue());
            }
        }

        return sessionPlayer.toData();
    }

    public synchronized void leave(UUID player) {
        if (game == null) {
            if (players.remove(player) != null) {
                if (players.size() == 0) {
                    Lobby.getInstance().removeSession(uuid);
                } else {
                    notifyPlayers(new PlayerLeftMessage(player));
                    Lobby.getInstance().notifyPlayers(new SessionModifiedMessage(toData()));
                }
            }
        } else {
            var sessionPlayer = players.get(player);
            if (sessionPlayer != null) {
                sessionPlayer.setPlayer(null);
                if (players.values().stream().noneMatch(SessionPlayer::isConnected)) {
                    Lobby.getInstance().removeSession(uuid);
                }
            }
        }
    }

    public synchronized void ready(UUID uuid, boolean ready) {
        var player = players.get(uuid);
        if (player == null) {
            throw new NackException(NackMessage.PLAYER_NOT_FOUND, "Who are you?");
        } else if (game != null) {
            throw new NackException(NackMessage.GAME_ALREADY_STARTED, "Game has already started.");
        }

        player.send(new AckMessage());
        if (player.isReady() != ready) {
            player.setReady(ready);
            notifyPlayers(new PlayerModifiedMessage(player.toData()));
        }

        if (players.size() >= MIN_PLAYERS && players.values().stream().allMatch(SessionPlayer::isReady)) {
            startGame();
        }
    }

    /**
     * Forwards the message sent by the player to the game.
     *
     * @param uuid the player
     * @param message the players message
     */
    public synchronized void handlePlayerMessage(UUID uuid, PlayerMessage message) {
        var player = players.get(uuid);
        if (player == null) {
            throw new NackException(NackMessage.PLAYER_NOT_FOUND, "Who are you?");
        } else if (game == null) {
            throw new NackException(NackMessage.GAME_NOT_YET_STARTED, "Game hat not yet started.");
        } else {
            try {
                game.execute(s -> {
                    // start buffering while game is locked
                    player.buffer();
                    return s.onMessage(game, uuid, message);
                });
                player.send(new AckMessage());
            } catch (IllegalStateException e) {
                throw new NackException(NackMessage.ILLEGAL_STATE, e.getMessage());
            } catch (IllegalArgumentException e) {
                throw new NackException(NackMessage.ILLEGAL_ARGUMENT, e.getMessage());
            }
        }
    }

    private void startGame() {
        notifyPlayers(new StartingGameMessage());
        messages.add(Pair.of(null, new StartingGameMessage()));
        game = new Game(Configurations.get(configuration).withTimeout(timeout), this);
        game.start(List.copyOf(players.keySet()));
        CompletableFuture.runAsync(() -> {
            while (true) {
                try {
                    game.await();
                    break;
                } catch (InterruptedException e) {
                    // ignored
                } catch (ExecutionException e) {
                    log.warn("Game completed with exception.", e);
                    break;
                }
            }
            players.forEach((id, player) -> player.setReady(false));
            synchronized (this) {
                game = null;
                messages.clear();
                for (SessionPlayer player : List.copyOf(players.values())) {
                    if (!player.isConnected()) {
                        leave(player.getUuid());
                    }
                }
            }
        });
    }

    private void notifyJoined(PlayerData joined) {
        var message = new PlayerJoinedMessage(joined);
        for (SessionPlayer player : players.values()) {
            if (player.getUuid().equals(joined.getUuid())) {
                player.send(new SessionJoinedMessage(
                        getUuid(),
                        player.getUuid(),
                        players.values().stream().map(SessionPlayer::toData).toList(),
                        player.getSecret()
                ));
            } else {
                player.send(message);
            }
        }
    }

    private void notifyPlayers(ServerMessage message) {
        for (SessionPlayer player : players.values()) {
            player.send(message);
        }
    }

    public SessionData toData() {
        return new SessionData(uuid, name, players.size(), configuration);
    }

    @Override
    public synchronized void notify(ObserverMessage message) {
        var gameMessage = new GameMessage(message);
        notifyPlayers(gameMessage);
        messages.add(Pair.of(null, gameMessage));
    }

    @Override
    public synchronized void notify(UUID player, ObserverMessage message) {
        var gameMessage = new GameMessage(message);
        players.get(player).send(gameMessage);
        messages.add(Pair.of(player, gameMessage));
    }

    @Data
    @EqualsAndHashCode(of = "uuid")
    private static class SessionPlayer {
        private final UUID uuid;
        private final String name;
        private final String secret = generateSecret();
        private Player player;
        private boolean ready;

        private static String generateSecret() {
            return ThreadLocalRandom.current()
                    .ints(32, 'a', 'z' + 1)
                    .collect(StringBuilder::new, (sb, i) -> sb.append((char) i), StringBuilder::append)
                    .toString();
        }

        public PlayerData toData() {
            return new PlayerData(uuid, name, ready);
        }

        public void send(ServerMessage message) {
            if (player != null) {
                player.send(message);
            }
        }

        public void buffer() {
            if (player != null) {
                player.buffer();
            }
        }

        public boolean isConnected() {
            return player != null;
        }
    }
}
