package eu.jonahbauer.wizard.client.libgdx.screens;

import com.badlogic.gdx.Input;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.g2d.TextureAtlas;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.scenes.scene2d.*;
import com.badlogic.gdx.scenes.scene2d.actions.MoveToAction;
import com.badlogic.gdx.scenes.scene2d.ui.*;
import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener;
import com.badlogic.gdx.scenes.scene2d.utils.TextureRegionDrawable;
import com.badlogic.gdx.utils.Align;
import com.badlogic.gdx.utils.I18NBundle;
import com.badlogic.gdx.utils.Scaling;
import eu.jonahbauer.wizard.client.libgdx.GameAtlas;
import eu.jonahbauer.wizard.client.libgdx.UiskinAtlas;
import eu.jonahbauer.wizard.client.libgdx.WizardGame;
import eu.jonahbauer.wizard.client.libgdx.actions.overlay.*;
import eu.jonahbauer.wizard.client.libgdx.actors.CardActor;
import eu.jonahbauer.wizard.client.libgdx.actors.CardStack;
import eu.jonahbauer.wizard.client.libgdx.actors.CardsGroup;
import eu.jonahbauer.wizard.client.libgdx.actors.PadOfTruth;
import eu.jonahbauer.wizard.client.libgdx.state.Game;
import eu.jonahbauer.wizard.client.libgdx.state.Session;
import eu.jonahbauer.wizard.client.libgdx.util.*;
import eu.jonahbauer.wizard.common.messages.observer.UserInputMessage;
import eu.jonahbauer.wizard.common.model.Card;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Range;

import java.util.*;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;

import static eu.jonahbauer.wizard.client.libgdx.actions.MyActions.*;

public class GameScreen extends WizardScreen {
    public static final int LAYER_BELOW_OVERLAY = 0;
    public static final int LAYER_BELOW_MENU = 1;

    // positions for externally animated actors
    public static final Vector2 TRUMP_CARD_POSITION = new Vector2(10, 10);
    public static final Vector2 TRUMP_SUIT_POSITION = new Vector2(10, 10 + (CardActor.PREF_HEIGHT + CardActor.PREF_WIDTH) / 2);
    public static final float TRUMP_SUIT_ROTATION = -90;
    public static final Vector2 PAD_OF_TRUTH_POSITION = new Vector2(WizardGame.WIDTH - 10 - PadOfTruth.EXTENDED_WIDTH, 10);

    @Getter
    private TextureAtlas atlas;

    private Image[] borders;

    private Label.LabelStyle labelStyleDefault;
    private Label.LabelStyle labelStyleActive;

    private final UUID self;
    private final LinkedHashMap<UUID, String> players;
    private final List<UUID> orderedPlayers;

    private int dimLayer = 0;
    private Container<?> dimBehind;
    private Group contentRoot;
    private Group overlayRoot;
    private Actor menu;

    private CardsGroup handCards;
    private CardStack cardStack;
    @Getter
    private PadOfTruth padOfTruth;
    @Getter
    private CardActor trumpCardActor;
    @Getter
    private CardActor trumpSuitActor;

    private VerticalGroup messageStack;
    private Label persistentMessage;

    private Action currentAction;
    private final Queue<Action> pendingActions = new LinkedList<>();

    private final Map<UUID, Seat> seats = new HashMap<>();
    private final Map<UUID, Label> nameLabels = new HashMap<>();
    private final Map<UUID, Label> trickLabels = new HashMap<>();

    private final AtomicBoolean pendingSync = new AtomicBoolean();

    public GameScreen(WizardGame game, @NotNull UUID self, @NotNull LinkedHashMap<UUID, String> players) {
        super(game);
        this.self = self;
        this.players = players;
        this.orderedPlayers = players.keySet().stream().toList();
    }

    //<editor-fold desc="Layout">
    @Override
    protected final void load() {
        super.load();

        assets.loadGame();
        assets.finishLoading();

        atlas = assets.get(WizardAssetManager.ATLAS_GAME);
    }

    @Override
    public void show() {
        super.show();

        dimBehind = new Container<>();
        dimBehind.setBackground(skin.getDrawable(UiskinAtlas.WHITE));
        dimBehind.setColor(0, 0, 0, 0.5f);
        dimBehind.setVisible(false);
        dimBehind.setTouchable(Touchable.enabled);
        dimBehind.setName("dim_behind");

        contentRoot = new Group();
        contentRoot.setName("content_root");
        var contentRootWrapper = new Container<>(contentRoot);
        contentRootWrapper.setSize(WizardGame.WIDTH, WizardGame.HEIGHT);
        contentRootWrapper.fill().clip();
        contentRootWrapper.setName("content_root_wrapper");

        overlayRoot = new Group();
        overlayRoot.setName("overlay_root");

        stage.addActor(dimBehind);
        stage.addActor(createBackground());
        stage.addActor(contentRootWrapper);
        stage.addActor(overlayRoot);
        stage.addActor(menu = createMenu());

        labelStyleDefault = skin.get("textfield", Label.LabelStyle.class);
        labelStyleActive = new Label.LabelStyle(labelStyleDefault);
        labelStyleActive.fontColor = Color.RED;

        seat();

        handCards = new CardsGroup(Collections.emptyList(), atlas);
        handCards.setOnClickListener(actor -> onCardClicked(actor.getCard()));
        var handCardsContainer = new Container<>(handCards);
        handCardsContainer.setPosition(360, 75);
        handCardsContainer.setSize(1200, CardActor.PREF_HEIGHT);
        handCardsContainer.fillX();

        messageStack = new VerticalGroup().columnCenter().bottom();
        messageStack.setPosition(360, 85 + CardActor.PREF_HEIGHT);
        messageStack.setSize(1200, 0);
        messageStack.setTouchable(Touchable.disabled);
        messageStack.setName("message_stack");

        padOfTruth = new PadOfTruth(skin, new TextureRegionDrawable(atlas.findRegion(GameAtlas.PAD_OF_TRUTH)));
        padOfTruth.setPosition(PAD_OF_TRUTH_POSITION.x, PAD_OF_TRUTH_POSITION.y);
        padOfTruth.setOrigin(PadOfTruth.EXTENDED_WIDTH, 0);

        cardStack = new CardStack();
        cardStack.setHoverBounds(0.5f * WizardGame.WIDTH - 50, 0.5f * WizardGame.HEIGHT - 50, 100, 100);

        addLabels();

        contentRoot.addActor(handCardsContainer);
        contentRoot.addActor(cardStack);
        contentRoot.addActor(padOfTruth);
        contentRoot.addActor(messageStack);

        stage.addAction(new Action() {
            @Override
            public boolean act(float delta) {
                executePendingActions();
                return false;
            }
        });

        stage.addListener(new InputListener() {
            @Override
            public boolean keyDown(InputEvent event, int keycode) {
                if (keycode == Input.Keys.ESCAPE) {
                    showMenu(!menu.isVisible());
                    return true;
                }
                return super.keyDown(event, keycode);
            }
        });
    }

    private Group createBackground() {
        var group = new Group();

        var background = new Image(skin.getRegion(UiskinAtlas.BACKGROUND));
        background.setBounds(0, 0, WizardGame.WIDTH, WizardGame.HEIGHT);
        group.addActor(background);

        var border = skin.getRegion(UiskinAtlas.BORDER_SHADOW);
        borders = new Image[] {
                new Image(new TextureRegionDrawable(border), Scaling.stretch),
                new Image(new TextureRegionDrawable(border), Scaling.stretch)
        };
        group.addActor(borders[0]);
        group.addActor(borders[1]);

        var title = new Image(skin.getRegion(UiskinAtlas.TITLE));
        title.setColor(0.5f, 0.5f, 0.5f, 0.5f);
        title.setSize(810, 192);
        title.setPosition(
                (WizardGame.WIDTH - title.getWidth()) / 2,
                (WizardGame.HEIGHT - title.getHeight()) / 2
        );
        group.addActor(title);
        group.setName("background");

        return group;
    }

    private void seat() {
        var count = players.size();
        Seat[] seats = switch (count) {
            case 3 -> new Seat[] {Seat.BOTTOM, Seat.TOP_LEFT, Seat.TOP_RIGHT};
            case 4 -> new Seat[] {Seat.BOTTOM, Seat.LEFT, Seat.TOP, Seat.RIGHT};
            case 5 -> new Seat[] {Seat.BOTTOM, Seat.LEFT, Seat.TOP_LEFT, Seat.TOP_RIGHT, Seat.RIGHT};
            case 6 -> new Seat[] {Seat.BOTTOM, Seat.LEFT, Seat.TOP_LEFT, Seat.TOP, Seat.TOP_RIGHT, Seat.RIGHT};
            default -> throw new AssertionError();
        };

        int index = orderedPlayers.indexOf(self);
        for (int i = 0; i < count; i++) {
            var player = orderedPlayers.get((index + i) % count);
            var seat = seats[i];
            this.seats.put(player, seat);
        }
    }

    private void addLabels() {
        int i = 0;
        for (var entry : players.entrySet()) {
            UUID uuid = entry.getKey();
            String name = entry.getValue();

            padOfTruth.setName(i++, name);

            var seat = seats.get(uuid);
            var group = new HorizontalGroup();
            group.setPosition(seat.getLabelX(), seat.getLabelY());
            group.align(seat.getLabelAlign());
            group.space(-3);

            var nameLabel = new Label(name, skin);
            group.addActor(nameLabel);

            var trickLabel = new Label("0", skin, "textfield");
            trickLabel.setAlignment(Align.center);
            group.addActor(new Container<>(trickLabel).fill().width(50));

            nameLabels.put(uuid, nameLabel);
            trickLabels.put(uuid, trickLabel);
            contentRoot.addActor(group);
        }
    }

    private Actor createMenu() {
        // TODO design

        var returnToGame = new TextButton("Zurück zum Spiel", skin);
        var returnToMenu = new TextButton("Zurück zum Hauptmenü", skin);

        var listener = new ChangeListener() {

            @Override
            public void changed(ChangeEvent event, Actor actor) {
                if (actor == returnToGame) {
                    showMenu(false);
                } else if (actor == returnToMenu) {
                    game.getClient().execute(Game.class, (s,c) -> s.returnToMenu());
                }
            }
        };

        returnToGame.addListener(listener);
        returnToMenu.addListener(listener);

        var menu = new Table(skin).center().left();
        menu.setSize(WizardGame.WIDTH, WizardGame.HEIGHT);
        menu.setPosition(0, 0);

        menu.add(returnToGame).row();
        menu.add(returnToMenu).row();

        menu.setVisible(false);
        menu.setName("menu");
        return menu;
    }

    @Override
    @SuppressWarnings("SuspiciousNameCombination")
    public void resize(int width, int height) {
        super.resize(width, height);

        if (offsetX > 0) {
            borders[0].setBounds(-13, 0, 23, WizardGame.HEIGHT);
            borders[0].setRotation(0);
            borders[1].setBounds(WizardGame.WIDTH + 13, WizardGame.HEIGHT, 23, WizardGame.HEIGHT);
            borders[1].setRotation(180);
        } else if (offsetY > 0) {
            borders[0].setBounds(0, WizardGame.HEIGHT + 13, 23, WizardGame.WIDTH);
            borders[0].setRotation(-90);
            borders[1].setBounds(WizardGame.WIDTH, -13, 23, WizardGame.WIDTH);
            borders[1].setRotation(90);
        }

        dimBehind.setBounds(-offsetX, -offsetY, worldWidth, worldHeight);
    }
    //</editor-fold>

    private void executePendingActions() {
        var next = currentAction == null || !isRunning(currentAction);
        var remaining = pendingActions.size();

        if (next) {
            if (remaining > 0) {
                currentAction = pendingActions.poll();
                stage.addAction(currentAction);
            } else {
                currentAction = null;
            }
        }

        if (currentAction == null && pendingSync.getAndSet(false)) {
            doSetPersistentMessage(null);
            game.getClient().execute(Game.class, Game::sync);
        }

        dim(LAYER_BELOW_OVERLAY, currentAction instanceof Overlay);
    }

    private void execute(Action action) {
        pendingActions.add(action);
    }

    private void execute(Runnable runnable) {
        pendingActions.add(run(runnable));
    }

    private boolean isSelf(UUID uuid) {
        return self.equals(uuid);
    }

    public void onCardClicked(@NotNull Card card) {
        game.getClient().execute(Game.class, (s, c) -> s.onCardClicked(c, card));
    }

    public void onSuitClicked(@NotNull Card.Suit suit) {
        game.getClient().execute(Game.class, (s, c) -> s.onSuitClicked(c, suit));
    }

    public void onPredictionMade(int prediction) {
        game.getClient().execute(Game.class, (s, c) -> s.onPredictionMade(c, prediction));
    }

    public void showSessionScreen() {
        game.getClient().execute(Session.class, Session::showInfoScreen);
    }

    //<editor-fold desc="UI">
    public void dim(int layer, boolean dim) {
        var old = dimLayer;
        if (dim) {
            dimLayer |= 1 << layer;
        } else {
            dimLayer &= ~ (1 << layer);
        }

        // 0 parentBackground
        // 1 background
        // 2 contentRoot
        // 3 overlayRoot
        // 4 menu

        if (dimLayer != old) {
            if (dimLayer > 1) {
                dimBehind.setVisible(true);
                dimBehind.setZIndex(4);
            } else if (dimLayer > 0) {
                dimBehind.setVisible(true);
                dimBehind.setZIndex(3);
            } else {
                dimBehind.setVisible(false);
                dimBehind.setZIndex(1);
            }
        }
    }

    public void showMenu(boolean visible) {
        if (!menu.isVisible() && visible) {
            menu.setVisible(true);
            dim(LAYER_BELOW_MENU, true);
        } else if (menu.isVisible() && !visible) {
            menu.setVisible(false);
            dim(LAYER_BELOW_MENU, false);
        }
    }

    /**
     * Resets all round-scoped and shows an {@linkplain StartRoundOverlay overlay}.
     */
    public void startRound(int round) {
        execute(() -> {
                    if (trumpCardActor != null) {
                        trumpCardActor.remove();
                    }

                    if (trumpSuitActor != null) {
                        trumpSuitActor.remove();
                    }

                    handCards.clearChildren();
                    cardStack.clearChildren();
                    trickLabels.values().forEach(label -> label.setText("0"));
        });
        execute(new StartRoundOverlay(this, round));
    }

    public void deal() {
        execute(parallel(
                run(() -> game.sounds.sfxShuffle()),
                delay(SoundManager.CARD_SHUFFLE_DURATION)
        ));
    }

    public void startTrick() {
        clearActivePlayer();
        execute(() -> cardStack.clearChildren());
    }

    public void finishTrick(@NotNull UUID player, int totalPlayerTricks) {
        var seat = seats.getOrDefault(player, Seat.FALLBACK);

        var animation = parallel();
        var group = new Group();
        execute(sequence(
                run(() -> {
                    contentRoot.addActorAfter(cardStack, group);

                    cardStack.removeAll().forEach(card -> {
                        var angle = (card.getRotation() % 360 + 360) % 360;
                        var rotation = rotateTo(angle < 90 || angle > 270 ? 0 : 180, AnimationTimings.STACK_FINISH_ROTATE);
                        rotation.setUseShortestDirection(true);


                        animation.addAction(sequence(
                                targeting(card, changeParent(group)),
                                parallel(
                                        targeting(card, rotation),
                                        targeting(card, moveTo(
                                                seat.getFrontX() - card.getWidth() / 2,
                                                seat.getFrontY() - card.getHeight() / 2,
                                                AnimationTimings.STACK_FINISH_MOVE
                                        ))
                                ),
                                targeting(card, alpha(0, AnimationTimings.STACK_FINISH_FADE)),
                                run(() -> {
                                    var trickLabel = trickLabels.get(player);
                                    if (trickLabel != null) trickLabel.setText(Integer.toString(totalPlayerTricks));
                                })
                        ));
                    });
                }),
                animation,
                removeActor(group)
        ));
    }

    /**
     * Updates the given players hand cards.
     */
    public void setHand(@NotNull UUID player, @NotNull List<Card> cards, boolean juggle) {
        if (isSelf(player)) {
            var sequence = sequence();
            sequence.addAction(run(() -> {
                var changes = handCards.update(cards);

                // animate card changes
                if (juggle) {
                    sequence.addAction(animateJuggle(changes.first(), changes.second()));
                }
            }));
            execute(sequence);
        }
    }

    /**
     * Adds the prediction for the given round and player to the {@linkplain #padOfTruth pad of truth} and
     * shows a corresponding message.
     */
    public void addPrediction(@Range(from = 0, to = 19) int round, @NotNull UUID player, int prediction, boolean changed) {
        if (isSelf(player)) {
            addMessage("game.action." + (changed ? "change" : "make") + "_prediction.self", prediction);
        } else {
            var name = players.get(player);
            addMessage("game.action."  + (changed ? "change" : "make") + "_prediction.other", name, prediction);
        }

        var index = orderedPlayers.indexOf(player);
        if (index == -1) throw new NoSuchElementException();

        padOfTruth.checkPosition(index, round);

        execute(() -> padOfTruth.setPrediction(index, round, prediction));
    }

    /**
     * Removes the card from the players hand and puts it into the {@linkplain #cardStack stack}.
     */
    public void playCard(@NotNull UUID player, @NotNull Card handCard, @NotNull Card card) {
        if (isSelf(player)) {
            addMessage("game.action.play_card.self");
        } else {
            var name = players.get(player);
            addMessage("game.action.play_card.other", name);
        }

        Seat seat = seats.getOrDefault(player, Seat.FALLBACK);

        var sequence = sequence();
        sequence.addAction(run(() -> {
            CardActor actor = null;
            if (isSelf(player)) {
                actor = handCards.remove(handCard);
            }

            if (actor == null) {
                actor = seat.createHandCard(card, atlas);
            } else if (handCard != card) {
                actor.setCard(card);
            }

            cardStack.add(seat, actor);
            sequence.addAction(delay(actor));
            sequence.addAction(delay(AnimationTimings.STACK_HOLD));

            game.sounds.sfxPlayCard();
        }));
        execute(sequence);
    }

    /**
     * Adds the scores for a round to the corresponding row of the {@linkplain #padOfTruth pad of truth}.
     */
    public void addScores(@Range(from = 0, to = 19) int round, @NotNull Map<UUID, Integer> scores) {
        padOfTruth.checkPosition(orderedPlayers.size() - 1, round);
        execute(() -> {
            for (int i = 0; i < orderedPlayers.size(); i++) {
                UUID player = orderedPlayers.get(i);
                padOfTruth.setScore(i, round, scores.getOrDefault(player, 0));
            }
        });
    }

    /**
     * Removes all visual changes done by {@link #setActivePlayer(UUID, UserInputMessage.Action, long)}.
     */
    public void clearActivePlayer() {
        setActivePlayer(null, null, 0);
    }

    /**
     * Highlights the given players label and sets the {@linkplain #setPersistentMessage(String) persistent message}
     * accordingly.
     */
    public void setActivePlayer(@Nullable UUID player, @Nullable UserInputMessage.Action action, long timeout) {
        execute(() -> nameLabels.forEach((p, l) -> l.setStyle(p.equals(player) ? labelStyleActive : labelStyleDefault)));

        if (isSelf(player) || action == null) {
            setPersistentMessage(null);
        } else {
            var key = switch (action) {
                case CHANGE_PREDICTION -> "game.message.change_prediction.";
                case JUGGLE_CARD -> "game.message.juggle_card.";
                case MAKE_PREDICTION -> "game.message.make_prediction.";
                case PICK_TRUMP -> "game.message.pick_trump.";
                case PLAY_CARD -> "game.message.play_card.";
                default -> throw new AssertionError();
            };

            if (player != null) {
                setPersistentMessage(key + "other", players.get(player));
            } else {
                setPersistentMessage(key + "all");
            }
        }
    }

    /**
     * Visually highlights the given card on the players hand.
     */
    public void setSelectedCard(@Nullable Card card) {
        handCards.setSelected(card);
    }

    /**
     * Swaps the current {@linkplain #trumpCardActor trump card} (if present) with a {@linkplain Card#WEREWOLF werewolf}
     * card on the given players hand.
     */
    public void swapTrumpCard(@NotNull UUID player) {
        var seat = seats.getOrDefault(player, Seat.FALLBACK);

        var sequence = sequence();
        sequence.addAction(run(() -> {
            if (trumpCardActor == null || !trumpCardActor.hasParent()) return;

            if (isSelf(player)) {
                var handCard = handCards.find(Card.WEREWOLF);
                if (handCard == null) return;

                handCard.setCard(trumpCardActor.getCard());
                trumpCardActor.setCard(Card.WEREWOLF);

                float localHandX = handCard.getX(), localHandY = handCard.getY();
                float stageTrumpX = 10, stageTrumpY = 10;
                float localTrumpX, localTrumpY;
                float stageHandX, stageHandY;

                var pos = new Vector2(handCard.getX(), handCard.getY());
                handCards.localToStageCoordinates(pos);
                stageHandX = pos.x;
                stageHandY = pos.y;

                pos.set(stageTrumpX, stageTrumpY);
                handCards.stageToLocalCoordinates(pos);
                localTrumpX = pos.x;
                localTrumpY = pos.y;

                trumpCardActor.setPosition(stageHandX, stageHandY);
                trumpCardActor.setRotation(0);

                handCard.setPosition(localTrumpX, localTrumpY);
                handCard.setRotation(0);

                sequence.addAction(parallel(
                        targeting(trumpCardActor, moveTo(stageTrumpX, stageTrumpY, AnimationTimings.WEREWOLF_SWAP)),
                        targeting(handCard, moveTo(localHandX, localHandY, AnimationTimings.WEREWOLF_SWAP))
                ));
            } else {
                var handCard = new CardActor(trumpCardActor.getCard(), atlas);
                handCard.setPosition(10, 10);
                trumpCardActor.setCard(Card.WEREWOLF);
                contentRoot.addActor(handCard);

                sequence.addAction(seat.moveToHand(trumpCardActor, 0));
                sequence.addAction(parallel(
                        targeting(trumpCardActor, moveTo(10,10, AnimationTimings.WEREWOLF_SWAP)),
                        targeting(trumpCardActor, rotateTo(0, AnimationTimings.WEREWOLF_SWAP)),
                        seat.moveToHand(handCard, AnimationTimings.WEREWOLF_SWAP)
                ));
                sequence.addAction(removeActor(handCard));
            }
        }));
        execute(sequence);
    }

    public void setTrump(@Nullable Card card, @Nullable Card.Suit suit) {
        initTrumpCards();

        if (suit != null && suit != CardUtil.getDefaultTrumpSuit(card)) {
            trumpSuitActor.setPosition(TRUMP_SUIT_POSITION.x, TRUMP_SUIT_POSITION.y);
            trumpSuitActor.setRotation(TRUMP_SUIT_ROTATION);
            trumpSuitActor.setCard(suit);
            contentRoot.addActor(trumpSuitActor);
        } else {
            trumpSuitActor.remove();
        }

        if (card != null) {
            trumpCardActor.setPosition(TRUMP_CARD_POSITION.x, TRUMP_CARD_POSITION.y);
            trumpCardActor.setCard(card);
            contentRoot.addActor(trumpCardActor);
        } else {
            trumpCardActor.remove();
        }
    }

    public void setStack(@NotNull List<Pair<UUID, Card>> stack) {
        cardStack.clearChildren();

        for (var pair : stack) {
            cardStack.add(seats.getOrDefault(pair.first(), Seat.FALLBACK), pair.second(), atlas);
        }
    }

    public void setPredictions(@NotNull Map<Integer, Map<UUID, Integer>> predictions) {
        predictions.forEach((round, roundPredictions) -> {
            roundPredictions.forEach((player, prediction) -> {
                var index = orderedPlayers.indexOf(player);
                if (index == -1) throw new NoSuchElementException();

                padOfTruth.setPrediction(index, round, prediction);
            });
        });
    }

    public void setScores(@NotNull Map<Integer, Map<UUID, Integer>> scores) {
        scores.forEach((round, roundScores) -> {
            roundScores.forEach((player, score) -> {
                var index = orderedPlayers.indexOf(player);
                if (index == -1) throw new NoSuchElementException();

                padOfTruth.setScore(index, round, score);
            });
        });
    }

    public void clearMessages() {
        messageStack.clear();
        persistentMessage = null;
    }

    public void clear() {
        // stop all actions
        pendingActions.clear();
        stage.getRoot().removeAction(currentAction);
        currentAction = null;

        pendingSync.set(false);

        // remove ui elements
        clearMessages();
        dim(LAYER_BELOW_OVERLAY, false);
        overlayRoot.clear();
        cardStack.clearChildren();
        handCards.clearChildren();
        if (trumpCardActor != null) {
            trumpCardActor.remove();
        }
        if (trumpSuitActor != null) {
            trumpSuitActor.remove();
        }
        padOfTruth.clearValues();
        nameLabels.values().forEach(label -> label.setStyle(labelStyleDefault));
        trickLabels.values().forEach(label -> label.setText("0"));
    }
    //</editor-fold>

    //<editor-fold desc="Overlays" defaultstate="collapsed">
    public Group getContentRoot() {
        return contentRoot;
    }

    public Group getOverlayRoot() {
        return overlayRoot;
    }

    public Skin getSkin() {
        return skin;
    }

    public I18NBundle getMessages() {
        return messages;
    }

    public void showTrumpOverlay(@Nullable UUID player, @Nullable Card trumpCard, @Nullable Card.Suit trumpSuit) {
        initTrumpCards();
        execute(new TrumpOverlay(this, players.get(player), trumpCard, trumpSuit));
    }

    public InteractionOverlay showSpecialCardOverlay(@NotNull Card card, long timeout) {
        if (card == Card.JUGGLER) {
            var overlay = new PlayColoredCardOverlay(this, timeout, card, Card.JUGGLER_RED, Card.JUGGLER_GREEN, Card.JUGGLER_BLUE, Card.JUGGLER_YELLOW);
            execute(overlay);
            return overlay;
        } else if (card == Card.CLOUD) {
            var overlay = new PlayColoredCardOverlay(this, timeout, card, Card.CLOUD_RED, Card.CLOUD_GREEN, Card.CLOUD_BLUE, Card.CLOUD_YELLOW);
            execute(overlay);
            return overlay;
        } else if (card == Card.CHANGELING) {
            var overlay = new PlayChangelingOverlay(this, timeout);
            execute(overlay);
            return overlay;
        } else {
            throw new IllegalArgumentException();
        }
    }

    public InteractionOverlay showPickTrumpOverlay(long timeout, boolean allowNone) {
        var overlay = new PickTrumpOverlay(this, timeout, allowNone);
        execute(overlay);
        return overlay;
    }

    public void showScoreOverlay(boolean finalScores) {
        execute(new ScoreOverlay(this, finalScores));
    }

    public InteractionOverlay showMakePredictionOverlay(int round, long timeout) {
        var overlay = new MakePredictionOverlay(this, timeout, round);
        execute(overlay);
        return overlay;
    }

    public InteractionOverlay showChangePredictionOverlay(int round, int oldPrediction, long timeout) {
        var overlay = new ChangePredictionOverlay(this, timeout, round, oldPrediction);
        execute(overlay);
        return overlay;
    }
    //</editor-fold>

    //<editor-fold desc="Messages" defaultstate="collapsed">
    public void addMessage(String key) {
        addMessage(false, key);
    }

    public void addMessage(String key, Object...args) {
        addMessage(false, key, args);
    }

    public void addMessage(boolean immediate, String key) {
        addMessage(immediate, key, (Object[]) null);
    }

    public void addMessage(boolean immediate, String key, Object...args) {
        var text = args != null ? messages.format(key, args) : messages.get(key);
        var label = new Label(text, skin);
        label.addAction(sequence(
                delay(AnimationTimings.MESSAGE_HOLD),
                alpha(0, AnimationTimings.MESSAGE_FADE),
                removeActor()
        ));

        Runnable runnable = () -> {
            if (persistentMessage != null && persistentMessage.getParent() == messageStack) {
                messageStack.addActorBefore(persistentMessage, label);
            } else {
                messageStack.addActor(label);
            }
        };

        if (immediate) {
            runnable.run();
        } else {
            execute(runnable);
        }
    }

    public void setPersistentMessage(String key) {
        setPersistentMessage(key, (Object[]) null);
    }

    public void setPersistentMessage(String key, Object...args) {
        var text = key == null ? null : args != null ? messages.format(key, args) : messages.get(key);
        execute(() -> doSetPersistentMessage(text));
    }

    private void doSetPersistentMessage(String message) {
        if (persistentMessage != null) {
            persistentMessage.addAction(sequence(
                    delay(AnimationTimings.MESSAGE_HOLD),
                    alpha(0, AnimationTimings.MESSAGE_FADE),
                    removeActor()
            ));
            persistentMessage = null;
        }

        if (message != null) {
            persistentMessage = new Label(message, skin);
            messageStack.addActor(persistentMessage);
        }
    }
    //</editor-fold>

    public void ready(boolean success) {
        if (success) closeInteractionOverlay();
    }

    public void timeout() {
        addMessage("game.message.timeout");
        closeInteractionOverlay();
        clearActivePlayer();
    }

    public void sync() {
        pendingSync.set(true);
        execute(() -> {
            if (pendingSync.get()) {
                doSetPersistentMessage(messages.get("game.message.sync"));
            }
        });
    }

    public void closeInteractionOverlay() {
        execute(() -> {
            if (currentAction instanceof Overlay overlay && overlay instanceof InteractionOverlay) {
                overlay.close();
            }
        });
    }

    @Override
    public void dispose() {
        super.dispose();
        assets.unloadGame();
    }

    private Action animateJuggle(@NotNull List<CardActor> removed, @NotNull List<CardActor> added) {
        // find left- and rightmost seat
        Seat tmpLeft = null, tmpRight = null;
        for (Seat seat : seats.values()) {
            if (seat == Seat.BOTTOM) continue;
            if (tmpLeft == null || tmpLeft.compareTo(seat) > 0) tmpLeft = seat;
            if (tmpRight == null || tmpRight.compareTo(seat) < 0) tmpRight = seat;
        }
        if (tmpLeft == null) return null;
        var left = tmpLeft;
        var right = tmpRight;

        var animation = parallel();
        removed.forEach(actor -> {
            contentRoot.addActor(actor);
            animation.addAction(left.moveToHand(actor, AnimationTimings.JUGGLE));
        });

        added.forEach(actor -> {
            // capture old values taking consideration of the animated layout changes in CardsGroup
            float x = actor.getX(), y = actor.getY();
            var actions = actor.getActions();
            for (int i = 0; i < actions.size; i ++) {
                var action = actions.get(i);
                if (action instanceof MoveToAction move) {
                    x = move.getX();
                    y = move.getY();
                    actions.removeIndex(i--);
                }
            }
            var rotation = actor.getRotation();

            // apply start values
            var startPos = new Vector2(right.getHandX(), right.getHandY());
            handCards.stageToLocalCoordinates(startPos);
            animation.addAction(targeting(actor, moveTo(startPos.x, startPos.y)));
            animation.addAction(targeting(actor, rotateTo(right.getHandAngle())));

            // animate
            animation.addAction(targeting(actor, moveTo(x, y, AnimationTimings.JUGGLE)));
            animation.addAction(targeting(actor, rotateTo(rotation, AnimationTimings.JUGGLE)));
        });

        var cleanup = parallel();
        removed.forEach(actor -> cleanup.addAction(removeActor(actor)));

        return sequence(animation, cleanup);
    }

    private boolean isRunning(Action action) {
        var actions = stage.getRoot().getActions();
        for (int i = 0; i < actions.size; i++) {
            if (actions.get(i) == action) {
                return true;
            }
        }
        return false;
    }

    private void initTrumpCards() {
        if (trumpCardActor == null) {
            trumpCardActor = new CardActor(Card.HIDDEN, atlas);
            trumpCardActor.setName("trump_card");
        }

        if (trumpSuitActor == null) {
            trumpSuitActor = new CardActor(Card.HIDDEN, atlas);
            trumpSuitActor.setOrigin(0, 0);
            trumpSuitActor.setName("trump_suit");
        }
    }

    @Getter
    public enum Seat {
        FALLBACK(WizardGame.WIDTH * 0.5f, WizardGame.HEIGHT * 0.5f, 0, 0, 0, WizardGame.WIDTH * 0.5f, WizardGame.HEIGHT * 0.5f),
        BOTTOM(WizardGame.WIDTH * 0.5f, 0, WizardGame.WIDTH * 0.5f, 37.5f, Align.center, WizardGame.WIDTH * 0.5f, 300),
        LEFT(-324, WizardGame.HEIGHT * 0.5f, 50, WizardGame.HEIGHT * 0.5f + 110f, Align.bottomLeft, 117.5f, WizardGame.HEIGHT * 0.5f),
        TOP_LEFT(WizardGame.WIDTH * 0.25f - 144, WizardGame.HEIGHT + 289, WizardGame.WIDTH * 0.25f, WizardGame.HEIGHT - 50, Align.top, WizardGame.WIDTH * 0.25f, WizardGame.HEIGHT - 200),
        TOP(WizardGame.WIDTH * 0.5f, WizardGame.HEIGHT + 324, WizardGame.WIDTH * 0.5f, WizardGame.HEIGHT - 50, Align.top, WizardGame.WIDTH * 0.5f, WizardGame.HEIGHT - 200),
        TOP_RIGHT(WizardGame.WIDTH * 0.75f + 144, WizardGame.HEIGHT + 289, WizardGame.WIDTH * 0.75f, WizardGame.HEIGHT - 50, Align.top, WizardGame.WIDTH * 0.75f, WizardGame.HEIGHT - 200),
        RIGHT(WizardGame.WIDTH + 324, WizardGame.HEIGHT * 0.5f, WizardGame.WIDTH - 50, WizardGame.HEIGHT * 0.5f + 110f, Align.bottomRight, WizardGame.WIDTH - 117.5f, WizardGame.HEIGHT * 0.5f);

        // position of the hand, should be offscreen
        private final float handX;
        private final float handY;
        private final float handAngle;

        // position of the label
        private final float labelX;
        private final float labelY;
        private final int labelAlign;

        private final float frontX;
        private final float frontY;

        Seat(float handX, float handY, float labelX, float labelY, int labelAlign, float frontX, float frontY) {
            this.handX = handX;
            this.handY = handY;
            this.labelX = labelX;
            this.labelY = labelY;
            this.labelAlign = labelAlign;
            this.frontX = frontX;
            this.frontY = frontY;

            var deltaX = WizardGame.WIDTH * 0.5f - handX;
            var deltaY = WizardGame.HEIGHT * 0.5f - handY;
            this.handAngle = (float) Math.toDegrees(Math.atan2(deltaY, deltaX) + Math.PI / 2);
        }

        public CardActor createHandCard(Card card, TextureAtlas atlas) {
            var actor = new CardActor(card, atlas);
            actor.setPosition(getHandX() - actor.getWidth() / 2, getHandY() - actor.getHeight() / 2);
            actor.setRotation(getHandAngle());
            return actor;
        }

        public Action moveToHand(CardActor actor, float duration) {
            return parallel(
                    targeting(actor, moveTo(handX, handY, duration)),
                    targeting(actor, rotateTo(handAngle, duration))
            );
        }
    }
}
