package com.jcloisterzone.board;

import java.io.Serializable;
import java.util.function.Function;
import java.util.function.Predicate;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.jcloisterzone.Immutable;
import com.jcloisterzone.game.RandomGenerator;

import io.vavr.Tuple2;
import io.vavr.collection.HashMap;
import io.vavr.collection.LinkedHashMap;
import io.vavr.collection.Map;
import io.vavr.collection.Stream;
import io.vavr.collection.Vector;
import io.vavr.control.Option;

/**
 * Represents a stack of tiles that can be drawn. It handles active/unactivated tiles and only draws from active ones.
 */
@Immutable
public class TilePack implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * The logger.
     */
    protected final transient Logger logger = LoggerFactory.getLogger(getClass());

    private final LinkedHashMap<String, TileGroup> groups;

    private final int hiddenUnderHills;

    /**
     * Instantiates a new {@code TilePack}.
     *
     * @param groups the groups making up this pack
     */
    public TilePack(LinkedHashMap<String, TileGroup> groups, int hiddenUnderHills) {
        this.groups = groups;
        this.hiddenUnderHills = hiddenUnderHills;
    }

    /**
     * Gets the groups making up this pack.
     *
     * @return the groups making up this pack
     */
    public LinkedHashMap<String, TileGroup> getGroups() {
        return groups;
    }

    /**
     * Gets number of tiles secretly put face-down under the hill
     *
     * @return number of tiles
     */
    public int getHiddenUnderHills() {
        return hiddenUnderHills;
    }

    /**
     * Sets the groups making up this pack.
     *
     * @param groups the groups
     * @return a new instance with the groups set
     */
    public TilePack setGroups(LinkedHashMap<String, TileGroup> groups) {
        if (this.groups == groups) return this;
        return new TilePack(groups, hiddenUnderHills);
    }

    /**
     * Sets the number of tiles hidden under hills
     *
     * @param hiddenUnderHills tile count
     * @return a new instance with the count set
     */
    public TilePack setHiddenUnderHills(int hiddenUnderHills) {
        if (this.hiddenUnderHills == hiddenUnderHills) return this;
        return new TilePack(groups, hiddenUnderHills);
    }

    public TilePack increaseHiddenUnderHills() {
        return setHiddenUnderHills(hiddenUnderHills + 1);
    }

    private Stream<TileGroup> getActiveGroups() {
        return Stream.ofAll(groups.values()).filter(TileGroup::isActive);
    }

    private Stream<Tile> getActiveTiles() {
        return getActiveGroups().flatMap(TileGroup::getTiles);
    }

    public Map<EdgePattern, Integer> getPatterns() {
        return Stream.ofAll(groups.values()).flatMap(TileGroup::getTiles)
            .map(Tile::getEdgePattern)
            .map(EdgePattern::canonize)
            .foldLeft(HashMap.empty(), (m, e) -> m.put(e, m.getOrElse(e, 0) + 1));
    }

    /**
     * Returns the total size of this pack, including active and non-active groups.
     *
     * @return the total size of this pack
     */
    public int totalSize() {
        return Stream.ofAll(groups.values()).map(TileGroup::size).sum().intValue() - hiddenUnderHills;
    }

    /**
     * Returns the size of this pack, including only active groups.
     *
     * @return the size of this pack
     */
    public int size() {
        return getActiveGroups().map(TileGroup::size).sum().intValue() - hiddenUnderHills;
    }

    /**
     * Checks if this pack is empty.
     *
     * @return {@code true} if this pack is empty, {@code false} otherwise
     */
    public boolean isEmpty() {
        return size() <= 0;
    }

    /**
     * Size used for random when tile is drawn.
     */
    protected int getInternalSize() {
        return size() + hiddenUnderHills;
    }

    /**
     * Check if this pack contains a group named {@code name}.
     *
     * @param name the name of interest
     * @return {@code true} if this pack contains a group named {@code name}, {@code false} otherwise
     */
    public boolean hasGroup(String name) {
        return groups.containsKey(name);
    }

    /**
     * Gets the group named {@code name}.
     *
     * @param name the name of interest
     * @return the group named {@code name} if it exists, {@code null} otherwise
     */
    public TileGroup getGroup(String name) {
        return groups.get(name).getOrNull();
    }

    /**
     * Gets the size of the group named {@code name}.
     *
     * @param name the name of interest
     * @return the size of the group named {@code name} if it exists, {@code 0} otherwise
     */
    public int getGroupSize(String name) {
        return groups.get(name).map(TileGroup::size).getOrElse(0);
    }

    /**
     * Applies a function to the group named {@code name}.
     *
     * @param name   the name of interest
     * @param mapper the function to apply to the group
     * @return a new instance with the group named {@code name} replaced by the output of {@code mapper}
     */
    public TilePack mapGroup(String name, Function<TileGroup, TileGroup> mapper) {
        return updateGroup(mapper.apply(getGroup(name)));
    }

    private TilePack updateGroup(TileGroup group) {
        if (group.isEmpty()) {
            TilePack pack = setGroups(groups.remove(group.getName()));
            String succ = group.getSuccessiveGroup();
            if (succ != null && pack.hasGroup(succ)) {
                pack = pack.activateGroup(succ);
            }
            return pack;
        } else {
            return setGroups(groups.put(group.getName(), group));
        }
    }

    public TilePack removeGroup(String name) {
        return setGroups(groups.remove(name));
    }

    /**
     * Draws random tile  {@code index}.
     *
     * @param random random number generator
     * @return a tuple containing both the tile drawn and the tile pack it belongs to
     * @throws IllegalArgumentException if {@code index} is not strictly less than the size of the pack
     */
    public Tuple2<Tile, TilePack> drawTile(RandomGenerator random) {
        int index = random.nextInt(getInternalSize());
        for (TileGroup group : getActiveGroups()) {
            if (index < group.size()) {
                Vector<Tile> tiles = group.getTiles();
                Tile tile = tiles.get(index);
                group = group.setTiles(tiles.removeAt(index));
                return new Tuple2<>(tile, updateGroup(group));
            } else {
                index -= group.size();
            }
        }
        throw new IllegalArgumentException();
    }

    /**
     * Draws the tile with id {@code tileId} in group {@code groupName}.
     *
     * @param groupName the group name
     * @param tileId    the tile id
     * @return a tuple containing both the tile drawn and the tile pack it belongs to
     * @throws IllegalArgumentException if there is no group named {@code groupName} or if the group has no tile with id
     * {@code tileId}
     */
    public Tuple2<Tile, TilePack> drawTile(String groupName, String tileId) {
        Predicate<Tile> matchesId = t -> t.getId().equals(tileId);
        TileGroup group = groups.get(groupName)
            .getOrElseThrow(IllegalArgumentException::new);
        Tile tile = group.getTiles().find(matchesId)
            .getOrElseThrow(IllegalArgumentException::new);
        TilePack pack = updateGroup(group.mapTiles(tiles -> tiles.removeFirst(matchesId)));
        return new Tuple2<>(tile, pack);
    }

    /**
     * Draws the tile with id {@code tileId} in group {@code groupName}.
     *
     * @param tileId    the tile id
     * @return a tuple containing both the tile drawn and the tile pack it belongs to
     * @throws IllegalArgumentException if there is no no tile with id {@code tileId}
     */
    public Tuple2<Tile, TilePack> drawTile(String tileId) {
        for (TileGroup group: getActiveGroups()) {
            try {
                return drawTile(group.getName(), tileId);
            } catch (IllegalArgumentException e) {
                //pass
            }
        }
        throw new IllegalArgumentException("Tile pack does not contain active " + tileId);
    }

    /**
     * Removes the tile with id {@code tileId}.
     *
     * @param tileId the tile id
     * @return a new instance with the tile removed
     */
    public TilePack removeTilesById(String tileId) {
        return setGroups(groups.mapValues(g ->
            g.mapTiles(tiles -> tiles.filter(tile -> !tile.getId().equals(tileId)))
        ));
    }

    /**
     * Activates the group named {@code groupName}.
     *
     * @param groupName the group name
     * @return a new instance with the group activated
     */
    public TilePack activateGroup(String groupName) {
        TileGroup group = groups.get(groupName).getOrNull();
        if (group == null || group.isActive()) return this;
        return setGroups(groups.put(groupName, group.setActive(true)));
    }

    /**
     * Deactivates the group named {@code groupName}.
     *
     * @param groupName the group name
     * @return a new instance with the group deactivated
     */
    public TilePack deactivateGroup(String groupName) {
        TileGroup group = groups.get(groupName).getOrNull();
        if (group == null || !group.isActive()) return this;
        return setGroups(groups.put(groupName, group.setActive(false)));
    }

    /**
     * Gets the number of tiles matching the given edge pattern.
     *
     * @param edgePattern the edge pattern to match
     * @return the number of matching tiles
     */
    public int getSizeForEdgePattern(EdgePattern edgePattern) {
        return getActiveTiles()
            .filter(tile -> edgePattern.isMatchingAnyRotation(tile.getEdgePattern()))
            .size();
    }

    /**
     * Finds the tile with id {@code tileId}.
     *
     * @param tileId the tile id
     * @return {@code Some<TileDefinition>} if a tile with id {@code tileId} is found, {@code None} otherwise
     */
    public Option<Tile> findTile(String tileId) {
        Predicate<Tile> pred = t -> t.getId().equals(tileId);
        for (TileGroup group : groups.values()) {
            Option<Tile> res = group.getTiles().find(pred);
            if (!res.isEmpty()) return res;
        }
        return Option.none();
    }

    @Override
    public String toString() {
        return String.format("%s/%s", size(), totalSize());
    }
}
