/*
 *    This file is a part of the SteamWar software.
 *
 *    Copyright (C) 2022  SteamWar.de-Serverteam
 *
 *    This program is free software: you can redistribute it and/or modify
 *    it under the terms of the GNU Affero General Public License as published by
 *    the Free Software Foundation, either version 3 of the License, or
 *    (at your option) any later version.
 *
 *    This program is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *    GNU Affero General Public License for more details.
 *
 *    You should have received a copy of the GNU Affero General Public License
 *    along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package de.steamwar.sql;

import de.steamwar.sql.internal.*;
import lombok.Getter;

import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.sql.Timestamp;
import java.util.*;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class SteamwarUser {
    private static final SecureRandom random = new SecureRandom();
    private static final SecretKeyFactory factory;

	static {
        try {
            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA512");
        } catch (NoSuchAlgorithmException e) {
            throw new SecurityException(e);
        }

        new SqlTypeMapper<>(UUID.class, "CHAR(36)", (rs, identifier) -> UUID.fromString(rs.getString(identifier)), (st, index, value) -> st.setString(index, value.toString()));
		new SqlTypeMapper<>(Locale.class, "VARCHAR(32)", (rs, identifier) -> {
			String l = rs.getString(identifier);
			return l != null ? Locale.forLanguageTag(l) : null;
		}, (st, index, value) -> st.setString(index, value.toLanguageTag()));
		new SqlTypeMapper<>(SteamwarUser.class, null, (rs, identifier) -> { throw new SecurityException("SteamwarUser cannot be used as type (recursive select)"); }, (st, index, value) -> st.setInt(index, value.id));
	}

	private static final Table<SteamwarUser> table = new Table<>(SteamwarUser.class, "UserData");
	private static final Statement insert = table.insertFields("UUID", "UserName");
	private static final SelectStatement<SteamwarUser> byID = table.selectFields("id");
	private static final SelectStatement<SteamwarUser> byUUID = table.selectFields("UUID");
	private static final SelectStatement<SteamwarUser> byName = table.selectFields("UserName");
	private static final SelectStatement<SteamwarUser> byDiscord = table.selectFields("DiscordId");
	private static final SelectStatement<SteamwarUser> byTeam = table.selectFields("Team");
	private static final SelectStatement<SteamwarUser> getUsersWithPerm = new SelectStatement<>(table, "SELECT S.* FROM UserData S JOIN UserPerm P ON S.id = P.User WHERE P.Perm = ?");
	private static final SelectStatement<SteamwarUser> getAll = new SelectStatement<SteamwarUser>(table, "SELECT * FROM UserData");

	private static final Statement updateName = table.update(Table.PRIMARY, "UserName");
    private static final Statement updatePassword = table.update(Table.PRIMARY, "Password");
	private static final Statement updateLocale = table.update(Table.PRIMARY, "Locale", "ManualLocale");
	private static final Statement updateTeam = table.update(Table.PRIMARY, "Team");
	private static final Statement updateLeader = table.update(Table.PRIMARY, "Leader");
	private static final Statement updateDiscord = table.update(Table.PRIMARY, "DiscordId");

	private static final Statement getPlaytime = new Statement("SELECT SUM(UNIX_TIMESTAMP(EndTime) - UNIX_TIMESTAMP(StartTime)) as Playtime FROM Session WHERE UserID = ?");
	private static final Statement getFirstjoin = new Statement("SELECT MIN(StartTime) AS FirstJoin FROM Session WHERE UserID = ?");
	private static final Statement getLastonline = new Statement("SELECT MAX(EndTime) AS LastOnline FROM Session WHERE UserID = ?");

	private static final Map<Integer, SteamwarUser> usersById = new HashMap<>();
	private static final Map<UUID, SteamwarUser> usersByUUID = new HashMap<>();
	private static final Map<String, SteamwarUser> usersByName = new HashMap<>();
	private static final Map<Long, SteamwarUser> usersByDiscord = new HashMap<>();
	public static void clear() {
		usersById.clear();
		usersByName.clear();
		usersByUUID.clear();
		usersByDiscord.clear();
	}

	public static void invalidate(int userId) {
		SteamwarUser user = usersById.remove(userId);
		if (user == null)
			return;
		usersByName.remove(user.getUserName());
		usersByUUID.remove(user.getUUID());
		usersByDiscord.remove(user.getDiscordId());
	}

	public static SteamwarUser get(String userName){
		SteamwarUser user = usersByName.get(userName.toLowerCase());
		if(user != null)
			return user;
		return byName.select(userName);
	}

	public static SteamwarUser get(UUID uuid){
		SteamwarUser user = usersByUUID.get(uuid);
		if(user != null)
			return user;
		return byUUID.select(uuid);
	}

	public static SteamwarUser get(int id) {
		SteamwarUser user = usersById.get(id);
		if(user != null)
			return user;
		return byID.select(id);
	}

	public static SteamwarUser get(Long discordId) {
		if(usersByDiscord.containsKey(discordId))
			return usersByDiscord.get(discordId);
		return byDiscord.select(discordId);
	}

	public static SteamwarUser getOrCreate(UUID uuid, String name, Consumer<UUID> newPlayer) {
		SteamwarUser user = get(uuid);

		if (user != null) {
			if (!user.userName.equals(name)) {
				updateName.update(name, user.id);
				user.userName = name;
			}

			return user;
		} else {
			insert.update(uuid, name);
			newPlayer.accept(uuid);
			return get(uuid);
		}
	}

	public static List<SteamwarUser> getUsersWithPerm(UserPerm userPerm) {
		return getUsersWithPerm.listSelect(userPerm);
	}

    public static List<SteamwarUser> getServerTeam() {
        return Stream.of(getUsersWithPerm(UserPerm.PREFIX_ADMIN),
                        getUsersWithPerm(UserPerm.PREFIX_DEVELOPER),
                        getUsersWithPerm(UserPerm.PREFIX_MODERATOR),
                        getUsersWithPerm(UserPerm.PREFIX_SUPPORTER),
                        getUsersWithPerm(UserPerm.PREFIX_BUILDER)
                )
                .flatMap(Collection::stream)
                .collect(Collectors.toList());
    }

	public static List<SteamwarUser> getTeam(int teamId) {
		return byTeam.listSelect(teamId);
	}

	public static void batchCache(Set<Integer> ids) {
		ids.removeIf(usersById::containsKey);
		if(ids.isEmpty())
			return;

		try (SelectStatement<SteamwarUser> batch = new SelectStatement<>(table, "SELECT * FROM UserData WHERE id IN (" + ids.stream().map(Object::toString).collect(Collectors.joining(", ")) + ")")) {
			batch.listSelect();
		}
	}

	@Getter
	@Field(keys = {Table.PRIMARY}, autoincrement = true)
	private final int id;
	@Field(keys = {"uuid"})
	private final UUID uuid;
	@Getter
	@Field
	private String userName;
    @Field(nullable = true)
    private String password;
    @Getter
	@Field(def = "0")
	private int team;
	@Getter
	@Field(def = "0")
	private boolean leader;
	@Field(nullable = true)
	private Locale locale;
	@Field(def = "0")
	private boolean manualLocale;
	@Getter
	@Field(keys = {"discordId"}, nullable = true)
	private Long discordId;

	private Map<Punishment.PunishmentType, Punishment> punishments = null;
	private Set<UserPerm> permissions = null;
	private UserPerm.Prefix prefix = null;

	public SteamwarUser(int id, UUID uuid, String userName, String password, int team, boolean leader, Locale locale, boolean manualLocale, Long discordId) {
		this.id = id;
		this.uuid = uuid;
		this.userName = userName;
        this.password = password;
		this.team = team;
		this.leader = leader;
		this.locale = locale;
		this.manualLocale = manualLocale;
		this.discordId = discordId != null && discordId != 0 ? discordId : null;

		usersById.put(id, this);
		usersByName.put(userName.toLowerCase(), this);
		usersByUUID.put(uuid, this);
		if (this.discordId != null) {
			usersByDiscord.put(discordId, this);
		}
	}

	public UUID getUUID() {
		return uuid;
	}

	public Locale getLocale() {
		if(locale != null)
			return locale;
		return Locale.getDefault();
	}

	public Punishment getPunishment(Punishment.PunishmentType type) {
		initPunishments();
		return punishments.getOrDefault(type, null);
	}

	public boolean isPunished(Punishment.PunishmentType punishment) {
		initPunishments();
		if (!punishments.containsKey(punishment)) {
			return false;
		}
		if (!punishments.get(punishment).isCurrent()) {
			if (punishment == Punishment.PunishmentType.Ban) {
				BannedUserIPs.unbanIPs(id);
			}
			punishments.remove(punishment);
			return false;
		}
		return true;
	}

	public boolean hasPerm(UserPerm perm) {
		initPerms();
		return permissions.contains(perm);
	}

	public Set<UserPerm> perms() {
		initPerms();
		return permissions;
	}

	public UserPerm.Prefix prefix() {
		initPerms();
		return prefix;
	}

	public double getOnlinetime() {
		return getPlaytime.select(rs -> {
			if (rs.next() && rs.getBigDecimal("Playtime") != null)
				return rs.getBigDecimal("Playtime").doubleValue();
			return 0.0;
		}, id);
	}

	public Timestamp getFirstjoin() {
		return getFirstjoin.select(rs -> {
			if (rs.next())
				return rs.getTimestamp("FirstJoin");
			return null;
		}, id);
	}

	public Timestamp getLastOnline() {
		return getLastonline.select(rs -> {
			if (rs.next())
				return rs.getTimestamp("LastOnline");
			return null;
		}, id);
	}

	public void punish(Punishment.PunishmentType punishment, Timestamp time, String banReason, int from, boolean perma) {
		initPunishments();
		punishments.remove(punishment);
		punishments.put(punishment, Punishment.createPunishment(id, from, punishment, banReason, time, perma));
	}

	public void setTeam(int team) {
		this.team = team;
		updateTeam.update(team, id);
		setLeader(false);
	}

	public void setLeader(boolean leader) {
		this.leader = leader;
		updateLeader.update(leader, id);
	}

	public void setLocale(Locale locale, boolean manualLocale) {
		if (locale == null || (this.manualLocale && !manualLocale))
			return;

		this.locale = locale;
		this.manualLocale = manualLocale;
		updateLocale.update(locale.toLanguageTag(), manualLocale, id);
	}

	public void setDiscordId(Long discordId) {
		usersByDiscord.remove(this.discordId);
		this.discordId = discordId;
		updateDiscord.update(discordId, id);
		if (discordId != null) {
			usersByDiscord.put(discordId, this);
		}
	}

    public void setPassword(String password) {
        try {
            byte[] salt = new byte[16];
            random.nextBytes(salt);
            String saltString = Base64.getEncoder().encodeToString(salt);

            byte[] hash = generateHash(password, salt);
            String hashString = Base64.getEncoder().encodeToString(hash);
            this.password = hashString + ":" + saltString;
            updatePassword.update(this.password, id);
        } catch (Exception e) {
            throw new SecurityException(e);
        }
    }

    public boolean verifyPassword(String password) {
        try {
            if (this.password == null) {
                return false;
            }

            String[] parts = this.password.split(":");
            if (parts.length != 2) {
                SQLConfig.impl.getLogger().log(Level.SEVERE ,"Invalid password hash for user {0} ({1})", new Object[]{userName, id});
                return false;
            }

            String hashString = parts[0];
            byte[] realHash = Base64.getDecoder().decode(hashString);
            String saltString = parts[1];
            byte[] salt = Base64.getDecoder().decode(saltString);
            byte[] hash = generateHash(password, salt);
            return Arrays.equals(realHash, hash);
        } catch (Exception e) {
            SQLConfig.impl.getLogger().log(Level.SEVERE, "Error while verifying password for user " + userName + " (" + id + ")", e);
            return false;
        }
    }

	public boolean hasPassword() {
		return this.password != null;
	}

    private byte[] generateHash(String password, byte[] salt)
            throws InvalidKeySpecException {
        PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, 65536, 512);
        return factory.generateSecret(spec).getEncoded();

    }

	private void initPunishments() {
		if(punishments != null)
			return;

		punishments = Punishment.getPunishmentsOfPlayer(id);
	}

	private void initPerms() {
		if(permissions != null)
			return;

		permissions = UserPerm.getPerms(id);
		prefix = permissions.stream().filter(UserPerm.prefixes::containsKey).findAny().map(UserPerm.prefixes::get).orElse(UserPerm.emptyPrefix);
	}

    public static List<SteamwarUser> getAll() {
        return getAll.listSelect();
    }
}
