package eu.jonahbauer.wizard.core.machine.states;

import eu.jonahbauer.wizard.common.model.Card;
import eu.jonahbauer.wizard.core.util.Pair;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Value;
import lombok.experimental.Accessors;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;

import java.util.*;
import java.util.function.Supplier;

@SuppressWarnings("unchecked")
@Unmodifiable
@EqualsAndHashCode(of = {"values", "present"})
public final class GameData {
    private static final int SIZE = 12;

    public static final GameData EMPTY = new GameData();

    public static final Key<List<UUID>> PLAYERS = new Key<>("players", 0, null);
    public static final Key<Map<UUID, Integer>> SCORE = new Key<>("score", 1, Map::of);
    public static final Key<Integer> ROUND = new Key<>("round", 2, () -> 0);
    public static final Key<Map<UUID, List<Card>>> HANDS = new Key<>("hands", 3, null);
    public static final Key<Card> TRUMP_CARD = new Key<>("trumpCard", 4, null);
    public static final Key<Card.Suit> TRUMP_SUIT = new Key<>("trumpSuit", 5, null);
    public static final Key<Map<UUID, Integer>> PREDICTIONS = new Key<>("predictions", 6, Map::of);
    public static final Key<Map<UUID, Integer>> TRICKS = new Key<>("tricks", 7, Map::of);
    public static final Key<Integer> TRICK = new Key<>("trick", 8, () -> 0);
    public static final Key<List<Pair<UUID, Card>>> STACK = new Key<>("stack", 9, List::of);
    public static final Key<UUID> CURRENT_PLAYER = new Key<>("currentPlayer", 10, null);
    public static final Key<UUID> CLOUDED_PLAYER = new Key<>("cloudedPlayer", 11, null);

    private final Object[] values;
    private final boolean[] present;
    private transient final boolean[] required = new boolean[SIZE];

    private GameData() {
        this.values = new Object[SIZE];
        this.present = new boolean[SIZE];
    }

    private GameData(Object[] values, boolean[] present) {
        this.values = values;
        this.present = present;
    }

    /**
     * Returns the value to which the specified key is mapped, the default value for the specified key, or throws
     * a {@link NoSuchElementException} if this map neither contains a mapping for the key nor does the key have a
     * default value.
     * @param key the key whose associated value is to be returned
     * @param <T> the value type
     * @return the value to which the specified key is mapped, or null if this map contains no mapping for the key
     */
    public <T> T get(@NotNull Key<T> key) {
        int index = key.index();
        if (present[index]) {
            return (T) values[index];
        } else if (key.defaultValue() != null) {
            present[index] = true;
            values[index] = key.defaultValue().get();
            return (T) values[index];
        } else {
            throw new NoSuchElementException();
        }
    }

    /**
     * Returns {@code true} if this map contains a mapping for the specified key.
     * @param key key whose presence in this map is to be tested
     * @return {@code true} if this map contains a mapping for the specified key
     */
    public boolean has(@NotNull Key<?> key) {
        return present[key.index()];
    }

    /**
     * Returns a copy of this map with the value for the specified key changed or {@code this} if the current value
     * is equal to the new value.
     * @param key the key whose associated value is to be changed
     * @param value value to be associated with the specified key
     * @param <T> the value type
     * @return the newly generated map or {@code this} is no changes had to be made
     */
    public <T> GameData with(@NotNull Key<T> key, T value) {
        int index = key.index();

        if (present[index] && Objects.equals(values[index], value)) {
            return this;
        }

        Object[] newValues = Arrays.copyOf(this.values, SIZE);
        boolean[] newPresent = Arrays.copyOf(this.present, SIZE);
        newValues[index] = value;
        newPresent[index] = true;
        return new GameData(newValues, newPresent);
    }

    /**
     * @see #with(Key, Object)
     */
    public GameData with(@NotNull TypedValue<?>@NotNull... entries) {
        boolean equal = true;
        for (TypedValue<?> entry : entries) {
            int index = entry.key().index();
            if (!present[index] || !Objects.equals(values[index], entry.value())) {
                equal = false;
                break;
            }
        }

        if (equal) {
            return this;
        }

        Object[] newValues = Arrays.copyOf(this.values, SIZE);
        boolean[] newPresent = Arrays.copyOf(this.present, SIZE);
        for (TypedValue<?> entry : entries) {
            int index = entry.key().index();
            newValues[index] = entry.value();
            newPresent[index] = true;
        }

        return new GameData(newValues, newPresent);
    }

    /**
     * @see #with(Key, Object)
     */
    public <T1, T2> GameData with(@NotNull Key<T1> key1, T1 value1, Key<T2> key2, T2 value2) {
        int index1 = key1.index();
        int index2 = key2.index();

        if (present[index1]
                && present[index2]
                && Objects.equals(values[index1], value1)
                && Objects.equals(values[index2], value2)) {
            return this;
        }

        Object[] newValues = Arrays.copyOf(this.values, SIZE);
        boolean[] newPresent = Arrays.copyOf(this.present, SIZE);
        newValues[index1] = value1;
        newPresent[index1] = true;
        newValues[index2] = value2;
        newPresent[index2] = true;
        return new GameData(newValues, newPresent);
    }

    /**
     * @see #with(Key, Object)
     */
    public <T1, T2, T3> GameData with(@NotNull Key<T1> key1, T1 value1, Key<T2> key2, T2 value2, Key<T3> key3, T3 value3) {
        int index1 = key1.index();
        int index2 = key2.index();
        int index3 = key3.index();

        if (present[index1]
                && present[index2]
                && present[index3]
                && Objects.equals(values[index1], value1)
                && Objects.equals(values[index2], value2)
                && Objects.equals(values[index3], value3)) {
            return this;
        }

        Object[] newValues = Arrays.copyOf(this.values, SIZE);
        boolean[] newPresent = Arrays.copyOf(this.present, SIZE);
        newValues[index1] = value1;
        newPresent[index1] = true;
        newValues[index2] = value2;
        newPresent[index2] = true;
        newValues[index3] = value3;
        newPresent[index3] = true;
        return new GameData(newValues, newPresent);
    }

    /**
     * Returns {@code this} if this map contains a mapping for the specified key or throws a
     * {@link NoSuchElementException} otherwise.
     * @param key key whose presence in this map is to be tested
     * @return {@code this}
     * @throws NoSuchElementException if this map contains no mapping for the specified key
     * @see Map#containsKey(Object)
     */
    @Contract("_ -> this")
    public GameData require(@NotNull Key<?> key) {
        if (!present[key.index()] && key.defaultValue() == null) {
            throw new InvalidDataException("Could not find required value '" + key + "'.");
        }
        required[key.index()] = true;
        return this;
    }

    /**
     * @see #clean()
     */
    public GameData keep(@NotNull Key<?> key) {
        required[key.index()] = true;
        return this;
    }

    /**
     * Returns {@code this} if this map contains a mapping for each of the specified keys or throws a
     * {@link NoSuchElementException} otherwise.
     * @param keys keys whose presence in this map is to be tested
     * @return {@code this}
     * @throws NoSuchElementException if this map contains no mapping for at least one of the specified keys
     * @see #require(Key)
     */
    @Contract("_ -> this")
    public GameData require(Key<?>...keys) {
        for (Key<?> key : keys) {
            require(key);
        }
        return this;
    }

    /**
     * Returns {@code this} if this map contains a mapping for both the specified keys and the specified map contains
     * a mapping for each entry in the specified list.
     * @param list key to a list whose entries' presence is to be tested
     * @param map key to a map which must contain the specified keys
     * @param <K> key type
     * @param <V> value type
     * @return {@code this}
     * @throws NoSuchElementException if this map contains no mapping for at least one of the specified keys or if
     *                                the associated map contains no mapping for at least one of the entries of
     *                                the associated list
     */
    @Contract("_,_ -> this")
    public <K, V> GameData requireEach(Key<? extends Collection<K>> list, Key<Map<K,V>> map) {
        require(list, map);
        var mapValue = get(map);
        var listValue = get(list);
        for (K k : listValue) {
            if (!mapValue.containsKey(k)) throw new InvalidDataException("Could not find required value: " + map.name() + "[" + k + "].");
        }
        return this;
    }

    /**
     * Retains only the mappings that have been required or kept since object creation.
     * @return {@code this}
     */
    public GameData clean() {
        Object[] newValues = Arrays.copyOf(this.values, SIZE);
        boolean[] newPresent = Arrays.copyOf(this.present, SIZE);

        boolean modified = false;
        for (int i = 0; i < SIZE; i++) {
            if (!required[i]) {
                newValues[i] = null;
                newPresent[i] = false;
                modified = true;
            }
        }

        if (modified) return new GameData(newValues, newPresent);
        else return this;
    }

    @SuppressWarnings("unused")
    @Value
    @Accessors(fluent = true)
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public static class Key<T> {
        String name;
        int index;
        Supplier<T> defaultValue;

        @Override
        public String toString() {
            return name();
        }
    }

    public record TypedValue<T>(Key<T> key, T value) {
        public static <T> TypedValue<T> entry(Key<T> key, T value) {
            return new TypedValue<>(key, value);
        }
    }
}
