package com.github.games647.scoreboardstats.pvp;

import com.github.games647.scoreboardstats.config.Settings;
import com.github.games647.scoreboardstats.variables.ReplaceManager;
import com.github.games647.scoreboardstats.variables.Replacer;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.zaxxer.hikari.HikariDataSource;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CancellationException;
import java.util.concurrent.Future;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.metadata.MetadataValue;
import org.bukkit.plugin.Plugin;
import org.slf4j.Logger;

/**
 * This represents a handler for saving player stats.
 */
public class Database {

    private static final String METAKEY = "player_stats";

    private final Plugin plugin;
    private final Logger logger;

    private final Map<String, Integer> toplist;
    private final DatabaseConfiguration dbConfig;
    private HikariDataSource dataSource;

    public Database(Plugin plugin, Logger logger) {
        this.plugin = plugin;
        this.logger = logger;

        this.dbConfig = new DatabaseConfiguration(plugin);
        this.toplist = Maps.newHashMapWithExpectedSize(Settings.getTopitems());
    }

    /**
     * Get the cache player stats if they exists and the arguments are valid.
     *
     * @param request the associated player
     * @return the stats if they are in the cache
     */
    @Deprecated
    public PlayerStats getCachedStats(Player request) {
        return getStats(request).orElse(null);
    }

    public Optional<PlayerStats> getStats(Player request) {
        if (request != null) {
            for (MetadataValue metadata : request.getMetadata(METAKEY)) {
                if (metadata.value() instanceof PlayerStats) {
                    return Optional.of((PlayerStats) metadata.value());
                }
            }
        }

        return Optional.empty();
    }

    /**
     * Starts loading the stats for a specific player in an external thread.
     *
     * @param player the associated player
     */
    public void loadAccountAsync(Player player) {
        if (dataSource != null && !getStats(player).isPresent()) {
            Bukkit.getScheduler().runTaskAsynchronously(plugin, new StatsLoader(plugin, player, this));
        }
    }

    /**
     * Starts loading the stats for a specific player sync
     *
     * @param uniqueId the associated playername or uuid
     * @return the loaded stats
     */
    public Optional<PlayerStats> loadAccount(UUID uniqueId) {
        if (dataSource == null) {
            return Optional.empty();
        } else {
            try (Connection conn = dataSource.getConnection();
                 PreparedStatement stmt = conn.prepareStatement("SELECT * FROM player_stats WHERE uuid=?")) {

                stmt.setString(1, uniqueId.toString());
                try (ResultSet resultSet = stmt.executeQuery()) {
                    return Optional.of(extractPlayerStats(resultSet));
                }
            } catch (SQLException ex) {
                logger.error("Error loading player profile", ex);
            }

            return Optional.empty();
        }
    }

    private PlayerStats extractPlayerStats(ResultSet resultSet) throws SQLException {
        int id = resultSet.getInt(1);

        String rawUUID = resultSet.getString(2);
        UUID uuid = null;
        if (rawUUID != null) {
            uuid = UUID.fromString(rawUUID);
        }

        String playerName = resultSet.getString(3);

        int kills = resultSet.getInt(4);
        int deaths = resultSet.getInt(5);
        int mobkills = resultSet.getInt(6);
        int killstreak = resultSet.getInt(7);

        Instant lastOnline = resultSet.getTimestamp(8).toInstant();
        return new PlayerStats(id, uuid, playerName, kills, deaths, mobkills, killstreak, lastOnline);
    }

    /**
     * Starts loading the stats for a specific player sync
     *
     * @param player the associated player
     * @return the loaded stats
     */
    public Optional<PlayerStats> loadAccount(Player player) {
        return loadAccount(player.getUniqueId());
    }

    /**
     * Save PlayerStats async.
     *
     * @param stats PlayerStats data
     */
    public void saveAsync(PlayerStats stats) {
        Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> save(Lists.newArrayList(stats)));
    }

    /**
     * Save the PlayerStats on the current Thread.
     *
     * @param stats PlayerStats data
     */
    public void save(Collection<PlayerStats> stats) {
        if (stats != null && dataSource != null) {
            update(stats.stream()
                    .filter(Objects::nonNull)
                    .filter(stat -> !stat.isNew())
                    .collect(Collectors.toList()));

            insert(stats.stream()
                    .filter(Objects::nonNull)
                    .filter(PlayerStats::isNew)
                    .collect(Collectors.toList()));
        }
    }

    private void update(Collection<PlayerStats> stats) {
        if (stats.isEmpty()) {
            return;
        }

        //Save the stats to the database
        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement("UPDATE player_stats "
                     + "SET kills=?, deaths=?, killstreak=?, mobkills=?, last_online=CURRENT_TIMESTAMP, playername=? "
                     + "WHERE id=?")) {
            conn.setAutoCommit(false);
            for (PlayerStats stat : stats) {
                stmt.setInt(1, stat.getKills());
                stmt.setInt(2, stat.getDeaths());
                stmt.setInt(3, stat.getKillstreak());
                stmt.setInt(4, stat.getMobkills());

                stmt.setString(5, stat.getPlayername());

                stmt.setInt(6, stat.getId());
                stmt.addBatch();
            }

            stmt.executeBatch();
            conn.commit();
        } catch (Exception ex) {
            logger.error("Error updating profiles", ex);
        }
    }

    private void insert(Collection<PlayerStats> stats) {
        if (stats.isEmpty()) {
            return;
        }

        try (Connection conn = dataSource.getConnection();
             PreparedStatement stmt = conn.prepareStatement("INSERT INTO player_stats "
                     + "(uuid, playername, kills, deaths, killstreak, mobkills, last_online) VALUES "
                     + "(?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)", Statement.RETURN_GENERATED_KEYS)) {
            conn.setAutoCommit(false);
            for (PlayerStats stat : stats) {
                stmt.setString(1, stat.getUuid().toString());
                stmt.setString(2, stat.getPlayername());

                stmt.setInt(3, stat.getKills());
                stmt.setInt(4, stat.getDeaths());
                stmt.setInt(5, stat.getKillstreak());
                stmt.setInt(6, stat.getMobkills());

                stmt.addBatch();
            }

            stmt.executeBatch();
            conn.commit();

            try (ResultSet generatedKeys = stmt.getGeneratedKeys()) {
                for (PlayerStats stat : stats) {
                    if (!generatedKeys.next()) {
                        break;
                    }

                    stat.setId(generatedKeys.getInt(1));
                }
            }
        } catch (Exception ex) {
            logger.error("Error inserting profiles", ex);
        }
    }

    /**
     * Starts saving all cache player stats and then clears the cache.
     */
    public void saveAll() {
        try {
            logger.info("Now saving the stats to the database. This could take a while.");

            //If pvpstats are enabled save all stats that are in the cache
            List<PlayerStats> toSave = Bukkit.getOnlinePlayers().stream()
                    .map(this::getStats)
                    .filter(Optional::isPresent)
                    .map(Optional::get)
                    .collect(Collectors.toList());

            if (!toSave.isEmpty()) {
                save(toSave);
            }

            dataSource.close();
        } finally {
            //Make rally sure we remove all even on error
            Bukkit.getOnlinePlayers()
                    .forEach(player -> player.removeMetadata(METAKEY, plugin));
        }
    }

    /**
     * Initialize a components and checking for an existing database
     */
    public void setupDatabase() {
        //Check if pvpstats should be enabled
        dbConfig.loadConfiguration();
        dataSource = new HikariDataSource(dbConfig.getServerConfig());

        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            String createTableQuery = "CREATE TABLE IF NOT EXISTS " + dbConfig.getTablePrefix() + "player_stats ( "
                    + "id integer PRIMARY KEY AUTO_INCREMENT, "
                    + "uuid varchar(40) NOT NULL, "
                    + "playername varchar(16) NOT NULL, "
                    + "kills integer NOT NULL, "
                    + "deaths integer NOT NULL, "
                    + "mobkills integer NOT NULL, "
                    + "killstreak integer NOT NULL, "
                    + "last_online timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP )";

            if (dbConfig.getServerConfig().getDriverClassName().contains("sqlite")) {
                createTableQuery = createTableQuery.replace("AUTO_INCREMENT", "");
                dataSource.setMaximumPoolSize(1);
            }

            stmt.execute(createTableQuery);
        } catch (Exception ex) {
            logger.error("Error creating database ", ex);
            return;
        }

        Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::updateTopList, 20 * 60 * 5, 0);
        Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
            Future<Collection<? extends Player>> syncPlayers = Bukkit.getScheduler()
                    .callSyncMethod(plugin, Bukkit::getOnlinePlayers);

            try {
                Collection<? extends Player> onlinePlayers = syncPlayers.get();

                List<PlayerStats> toSave = onlinePlayers.stream()
                        .map(this::getStats)
                        .filter(Optional::isPresent)
                        .map(Optional::get)
                        .collect(Collectors.toList());

                if (!toSave.isEmpty()) {
                    save(toSave);
                }
            } catch (CancellationException cancelEx) {
                //ignore it on shutdown
            } catch (Exception ex) {
                logger.error("Error fetching top list", ex);
            }
        }, 20 * 60, 20 * 60 * 5);

        registerEvents();
    }

    /**
     * Get the a map of the best players for a specific category.
     *
     * @return a iterable of the entries
     */
    public Iterable<Entry<String, Integer>> getTop() {
        synchronized (toplist) {
            return ImmutableMap.copyOf(toplist).entrySet();
        }
    }

    /**
     * Updates the toplist
     */
    private void updateTopList() {
        String type = Settings.getTopType();
        Map<String, Integer> newToplist;
        switch (type) {
            case "killstreak":
                newToplist = getTopList("killstreak", PlayerStats::getKillstreak);
                break;
            case "mob":
                newToplist = getTopList("mobkills", PlayerStats::getMobkills);
                break;
            default:
                newToplist = getTopList("kills", PlayerStats::getKills);
                break;
        }

        synchronized (toplist) {
            //set it after fetching so it's only blocking for a short time
            toplist.clear();
            toplist.putAll(newToplist);
        }
    }

    private Map<String, Integer> getTopList(String type, Function<PlayerStats, Integer> valueMapper) {
        if (dataSource == null) {
            return Collections.emptyMap();
        }

        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {
            try (ResultSet resultSet = stmt.executeQuery("SELECT * FROM player_stats ORDER BY " + type + " desc"
                    + " LIMIT " + Settings.getTopitems())) {
                Map<String, Integer> result = Maps.newHashMap();
                for (int i = 0; i < Settings.getTopitems(); i++) {
                    if (!resultSet.next()) {
                        return result;
                    }

                    PlayerStats stats = extractPlayerStats(resultSet);
                    if (!stats.isNew()) {
                        String entry = (i + 1) + ". " + stats.getPlayername();
                        result.put(entry, valueMapper.apply(stats));
                    }
                }

                return result;
            }
        } catch (SQLException ex) {
            logger.error("Error loading top list", ex);
        }

        return Collections.emptyMap();
    }

    private void registerEvents() {
        if (Bukkit.getPluginManager().isPluginEnabled("InSigns")) {
            //Register this listener if InSigns is available
            Bukkit.getPluginManager().registerEvents(new SignListener(plugin, this), plugin);
        }

        ReplaceManager replaceManager = ReplaceManager.getInstance();
        replaceManager.register(newVariable("kills", PlayerStats::getKills));
        replaceManager.register(newVariable("deaths", PlayerStats::getDeaths));
        replaceManager.register(newVariable("kdr", PlayerStats::getKdr));
        replaceManager.register(newVariable("mob-kills", PlayerStats::getMobkills));
        replaceManager.register(newVariable("killstreak", PlayerStats::getKillstreak));
        replaceManager.register(newVariable("current-streak", PlayerStats::getCurrentStreak));

        Bukkit.getPluginManager().registerEvents(new StatsListener(plugin, this), plugin);
    }

    private Replacer newVariable(String variable, Function<PlayerStats, Integer> fct) {
        return new Replacer(plugin, "kills")
                .scoreSupply(player -> getStats(player)
                        .map(fct)
                        .orElse(-1));
    }
}