public class RequestHandler {

    private static final Gson GSON = new GsonBuilder()
            .registerTypeAdapter(Locale.class, new LocaleAdapter())
            .registerTypeAdapter(Pattern.class, new PatternAdapter())
            .registerTypeAdapter(SessionKey.class, new SessionKeyAdapter())
            .registerTypeAdapter(Version.class, new VersionAdapter())
            .create();

    private static Autotip autotip;

    public static void setAutotip(Autotip autotip) {
        RequestHandler.autotip = autotip;
    }

    public static Optional<Reply> getReply(Request request, URI uri) {
        String json = null;
        try {
            HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
            conn.setRequestProperty("User-Agent", "Autotip v" + autotip.getVersion());

            InputStream input;
            if (conn.getResponseCode() < HttpURLConnection.HTTP_BAD_REQUEST) {
                input = conn.getInputStream();
            } else {
                input = conn.getErrorStream();
            }
            json = IOUtils.toString(input, StandardCharsets.UTF_8);
            Autotip.LOGGER.debug(request.getType() + " JSON: " + json);

            Reply reply = GSON.fromJson(json, (Type) request.getType().getReplyClass());

            return Optional.ofNullable(reply);
        } catch (IOException | JsonParseException e) {
            ErrorReport.reportException(e);
            Autotip.LOGGER.info(request.getType() + " JSON: " + json);
            return Optional.empty();
        }
    }

} public class LocaleReply extends Reply {

    private Locale lang;
    private JsonObject locale;

    public LocaleReply() {
    }

    public LocaleReply(boolean success) {
        super(success);
    }

    public LocaleHolder getLocaleHolder() {
        return new LocaleHolder(lang, locale);
    }

    @Override
    public RequestType getRequestType() {
        return RequestType.LOCALE;
    }

} public class SettingsReply extends Reply {

    private GlobalSettings settings;

    public SettingsReply() {
    }

    public SettingsReply(boolean success) {
        super(success);
    }

    public GlobalSettings getSettings() {
        return settings;
    }

    @Override
    public RequestType getRequestType() {
        return RequestType.SETTINGS;
    }

} public class LocaleRequest implements Request<LocaleReply> {

    private final Locale locale;
    private final Version version;

    private LocaleRequest(Autotip autotip) {
        this.locale = autotip.getConfig().getLocale();
        this.version = autotip.getVersion();
    }

    public static LocaleRequest of(Autotip autotip) {
        return new LocaleRequest(autotip);
    }

    @Override
    public LocaleReply execute() {
        HttpUriRequest request = GetBuilder.of(this)
                .addParameter("lang", this.locale.toLanguageTag())
                .addParameter("v", this.version.get())
                .build();

        Optional<Reply> optional = RequestHandler.getReply(this, request.getURI());
        return optional
                .map(reply -> (LocaleReply) reply)
                .orElseGet(() -> new LocaleReply(false));
    }

    @Override
    public RequestType getType() {
        return RequestType.LOCALE;
    }

} public class SettingsRequest implements Request<SettingsReply> {

    private final Version version;

    private SettingsRequest(Autotip autotip) {
        this.version = autotip.getVersion();
    }

    public static SettingsRequest of(Autotip autotip) {
        return new SettingsRequest(autotip);
    }

    @Override
    public SettingsReply execute() {
        HttpUriRequest request = GetBuilder.of(this)
                .addParameter("v", this.version.get())
                .build();

        Optional<Reply> optional = RequestHandler.getReply(this, request.getURI());
        return optional
                .map(reply -> (SettingsReply) reply)
                .orElseGet(() -> new SettingsReply(false));
    }

    @Override
    public RequestType getType() {
        return RequestType.SETTINGS;
    }

} public class LocaleHolder {

    private static final Pattern SPLIT_PATTERN = Pattern.compile("\\.");

    private final Map<String, String> cache = new ConcurrentHashMap<>();

    private final Locale locale;
    private final JsonObject root;

    public LocaleHolder(Locale locale, JsonObject root) {
        this.locale = locale;
        this.root = root;
    }

    public Locale getLocale() {
        return locale;
    }

    public JsonObject getRoot() {
        return root;
    }

    public String getKey(String key) {
        if (cache.containsKey(key)) {
            return cache.get(key);
        }
        String value = "<" + key + ">";
        try {
            value = this.resolveKey(key);
        } catch (IllegalArgumentException e) {
            ErrorReport.reportException(e);
        }
        cache.put(key, value);
        return value;
    }

    private String resolveKey(String key) {
        JsonObject obj = root;
        for (String path : SPLIT_PATTERN.split(key)) {
            if (!obj.has(path)) {
                throw new IllegalArgumentException("Invalid key: " + key);
            }
            JsonElement value = obj.get(path);
            if (value.isJsonObject()) {
                obj = value.getAsJsonObject();
                continue;
            }
            return value.getAsString();
        }
        if (!obj.isJsonPrimitive()) {
            throw new IllegalArgumentException("Incomplete key: " + key);
        }
        return obj.getAsString();
    }

} public class MessageUtil {

    private static final String PREFIX = "&6A&eT &8> &7";

    private final Autotip autotip;

    private final Queue<String> chatQueue = Queues.newConcurrentLinkedQueue();
    private final Queue<String> cmdQueue = Queues.newConcurrentLinkedQueue();

    public MessageUtil(Autotip autotip) {
        this.autotip = autotip;
    }

    public ChatComponentBuilder getBuilder(String text, Object... params) {
        return this.getBuilder(true, text, params);
    }

    public ChatComponentBuilder getBuilder(boolean prefix, String text, Object... params) {
        return ChatComponentBuilder.of(prefix, text, params);
    }

    public KeyHelper getKeyHelper(String rootKey) {
        return new KeyHelper(this, rootKey);
    }

    public void sendKey(String key, Object... params) {
        this.send(this.getKey(key), params);
    }

    public String getKey(String key) {
        return autotip.getLocaleHolder().getKey(key);
    }

    public void send(String msg, Object... params) {
        this.sendRaw(PREFIX + msg, params);
    }

    public void send(String msg, String url, String hoverText, Object... params) {
        UniversalUtil.addChatMessage(StringUtil.params(PREFIX + msg, params), url,
                StringUtil.format(hoverText));
    }

    public void separator() {
        this.sendRaw("&6&m&l----------------------------------");
    }

    public void sendRaw(String msg, Object... params) {
        msg = StringUtil.params(msg, params);
        if (this.isPlayerLoaded()) {
            this.flushQueues();
            UniversalUtil.addChatMessage(msg);
        } else {
            chatQueue.add(msg);
            Autotip.LOGGER.info("Queued chat message: " + msg);
        }
    }

    public void sendCommand(String command) {
        if (this.isPlayerLoaded()) {
            this.flushQueues();
            autotip.getMinecraft().thePlayer.sendChatMessage(command);
        } else {
            cmdQueue.add(command);
            Autotip.LOGGER.info("Queued command: " + command);
        }
    }

    public void flushQueues() {
        if (this.isPlayerLoaded()) {
            while (!chatQueue.isEmpty()) {
                this.sendRaw(chatQueue.poll());
            }
            while (!cmdQueue.isEmpty()) {
                this.sendCommand(cmdQueue.poll());
            }
        }
    }

    public void clearQueues() {
        chatQueue.clear();
        cmdQueue.clear();
    }

    private boolean isPlayerLoaded() {
        Minecraft minecraft = autotip.getMinecraft();
        return minecraft != null && minecraft.thePlayer != null;
    }

} public abstract class CommandAbstract extends CommandBase {

    public final Autotip autotip;

    public CommandAbstract(Autotip autotip) {
        this.autotip = autotip;
    }

    // Minecraft 1.8 | 1.8.8 | 1.8.9
    // func_71515_b
    public void processCommand(ICommandSender sender, String[] args) {
        autotip.getTaskManager().getExecutor().execute(() -> this.onCommand(sender, args));
    }

    // Minecraft 1.9 | 1.9.4 | 1.10 | 1.10.2 | 1.11 | 1.11.2 | 1.12 | 1.12.1 | 1.12.2
    // func_184881_a
    public void func_184881_a(MinecraftServer server, ICommandSender sender, String[] args)
            throws CommandException {
        autotip.getTaskManager().getExecutor().execute(() -> this.onCommand(sender, args));
    }

    // Minecraft 1.8 | 1.8.8 | 1.8.9
    // func_180525_a
    public List<String> addTabCompletionOptions(ICommandSender sender, String[] args,
            BlockPos pos) {
        return this.onTabComplete(sender, args);
    }

    // Minecraft 1.9 | 1.9.4 | 1.10 | 1.10.2 | 1.11 | 1.11.2 | 1.12 | 1.12.1 | 1.12.2
    // func_184883_a
    public List<String> func_184883_a(MinecraftServer server, ICommandSender sender, String[] args,
            @Nullable BlockPos pos) {
        return this.onTabComplete(sender, args);
    }

    public abstract void onCommand(ICommandSender sender, String[] args);

    public abstract List<String> onTabComplete(ICommandSender sender, String[] args);

} public class CommandAutotip extends CommandAbstract {

    private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("d/M/yyyy");
    private static final DateTimeFormatter SESSION_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
    private static final DateTimeFormatter WAVE_FORMAT = DateTimeFormatter.ofPattern("mm:ss");

    public CommandAutotip(Autotip autotip) {
        super(autotip);
    }

    @Override
    public String getCommandName() {
        return "autotip";
    }

    @Override
    public int getRequiredPermissionLevel() {
        return 0;
    }

    @Override
    public String getCommandUsage(ICommandSender sender) {
        return autotip.getLocaleHolder().getKey("command.usage");
    }

    @Override
    public List<String> getCommandAliases() {
        if (!autotip.getMcVersion().equals(MinecraftVersion.V1_8)) {
            return Collections.singletonList("at");
        } else {
            return Collections.emptyList();
        }
    }

    @Override
    public void onCommand(ICommandSender sender, String[] args) {
        Config config = autotip.getConfig();
        MessageUtil messageUtil = autotip.getMessageUtil();
        TaskManager taskManager = autotip.getTaskManager();
        SessionManager manager = autotip.getSessionManager();
        StatsManager stats = autotip.getStatsManager();
        GlobalSettings settings = autotip.getGlobalSettings();

        if (args.length <= 0) {
            messageUtil.sendKey("command.usage");
            return;
        }

        switch (args[0].toLowerCase()) {
            case "s":
            case "stats":
                LocalDate now = LocalDate.now();

                if (args.length <= 1) {
                    stats.get(now).print();
                    return;
                }

                String param = args[1].toLowerCase();
                switch (param) {
                    case "d":
                    case "day":
                    case "daily":
                    case "today":
                        stats.get(now).print();
                        break;
                    case "yd":
                    case "yesterday":
                        stats.get(now.minusDays(1)).print();
                        break;
                    case "w":
                    case "week":
                    case "weekly":
                        stats.getRange(now.with(DayOfWeek.MONDAY), now.with(DayOfWeek.SUNDAY))
                                .print();
                        break;
                    case "m":
                    case "month":
                    case "monthly":
                        stats.getRange(now.withDayOfMonth(1),
                                now.withDayOfMonth(now.lengthOfMonth())).print();
                        break;
                    case "y":
                    case "year":
                    case "yearly":
                        stats.getRange(now.withDayOfYear(1),
                                now.withDayOfYear(now.lengthOfYear())).print();
                        break;
                    case "a":
                    case "all":
                    case "total":
                    case "life":
                    case "lifetime":
                        stats.getAll().print();
                        break;
                    default:
                        if (param.contains("-")) {
                            List<LocalDate> dates = Arrays.stream(param.split("-"))
                                    .map(string -> {
                                        try {
                                            return LocalDate.parse(string, DATE_FORMAT);
                                        } catch (DateTimeParseException e) {
                                            return null;
                                        }
                                    })
                                    .filter(Objects::nonNull)
                                    .limit(2)
                                    .sorted()
                                    .collect(Collectors.toList());
                            if (dates.size() != 2) {
                                messageUtil.sendKey("command.stats.invalidRange");
                                return;
                            }
                            stats.getRange(dates.get(0), dates.get(1)).print();
                        } else if (param.contains("/")) {
                            try {
                                LocalDate date = LocalDate.parse(param, DATE_FORMAT);
                                stats.get(date).print();
                            } catch (DateTimeParseException e) {
                                messageUtil.sendKey("command.stats.invalidDate");
                            }
                        } else {
                            messageUtil.sendKey("command.stats.usage");
                        }
                        break;

                }
                break;
            case "?":
            case "info":
                StatsDaily today = stats.get();
                messageUtil.getKeyHelper("command.info")
                        .separator()
                        .sendKey("version", autotip.getVersion())
                        .withKey("credits", context -> context.getBuilder()
                                .setHover(context.getKey("creditsHover"))
                                .send())
                        .sendKey("status." + (config.isEnabled() ? "enabled" : "disabled"))
                        .sendKey("messages", config.getMessageOption())
                        .sendKey("tipsSent", today.getTipsSent())
                        .sendKey("tipsReceived", today.getTipsReceived())
                        .sendKey("statsCommand")
                        .separator();
                break;
            case "m":
            case "messages":
                try {
                    if (args.length > 1) {
                        MessageOption option = MessageOption.valueOfIgnoreCase(args[1]);
                        config.setMessageOption(option).save();
                    } else {
                        config.nextMessageOption().save();
                    }
                    messageUtil.sendKey("command.messages.next", config.getMessageOption());
                } catch (IllegalArgumentException e) {
                    messageUtil.sendKey("command.messages.error", args.length > 1 ? args[1] : null);
                }
                break;
            case "t":
            case "toggle":
                if (!manager.isOnHypixel()) {
                    config.toggleEnabled().save();
                    messageUtil.getKeyHelper("command.toggle")
                            .sendKey(config.isEnabled() ? "enabled" : "disabled");
                    return;
                }
                if (!config.isEnabled()) {
                    if (!manager.isLoggedIn()) {
                        taskManager.executeTask(TaskType.LOGIN, manager::login);
                        config.setEnabled(true).save();
                        messageUtil.sendKey("command.toggle.enabled");
                    } else {
                        messageUtil.sendKey("command.toggle.error");
                    }
                } else {
                    if (manager.isLoggedIn()) {
                        taskManager.executeTask(TaskType.LOGOUT, manager::logout);
                        config.setEnabled(false).save();
                        messageUtil.sendKey("command.toggle.disabled");
                    } else {
                        messageUtil.sendKey("command.toggle.error");
                    }
                }
                break;
            case "w":
            case "wave":
                if (!config.isEnabled()) {
                    messageUtil.sendKey("error.disabled");
                    return;
                }
                if (!manager.isOnHypixel()) {
                    messageUtil.sendKey("error.disabledHypixel");
                    return;
                }
                if (manager.getNextTipWave() == 0) {
                    messageUtil.sendKey("command.wave.error");
                    return;
                }

                long t = System.currentTimeMillis();
                String next = LocalTime.MIN.plusSeconds((manager.getNextTipWave() - t) / 1000 + 1)
                        .format(WAVE_FORMAT);
                String last = LocalTime.MIN.plusSeconds((t - manager.getLastTipWave()) / 1000)
                        .format(WAVE_FORMAT);

                messageUtil.getKeyHelper("command.wave")
                        .separator()
                        .sendKey("nextWave", next)
                        .sendKey("lastWave", last)
                        .separator();
                break;
            case "changelog":
                messageUtil.getKeyHelper("command.changelog")
                        .separator()
                        .sendKey("version", autotip.getVersion())
                        .withKey("entry", context -> settings.getVersionInfo(autotip.getVersion())
                                .getChangelog()
                                .forEach(context::send))
                        .separator();
                break;
            case "debug":
                EventClientConnection event = autotip.getEvent(EventClientConnection.class);
                Object header = event.getHeader();
                messageUtil.getKeyHelper("command.debug")
                        .separator()
                        .sendKey("serverIp", event.getServerIp())
                        .sendKey("mcVersion", autotip.getMcVersion())
                        .sendKey("header." + (header == null ? "none" : "present"),
                                UniversalUtil.getUnformattedText(header))
                        .separator();
                break;
            case "reload":
                try {
                    autotip.reloadGlobalSettings();
                    autotip.reloadLocale();
                    messageUtil.sendKey("command.reload.success");
                } catch (IllegalStateException e) {
                    messageUtil.sendKey("command.reload.error");
                }
                break;
            default:
                messageUtil.send(this.getCommandUsage(sender));
                break;
        }
    }

    @Override
    public List<String> onTabComplete(ICommandSender sender, String[] args) {
        switch (args.length) {
            case 1:
                return getListOfStringsMatchingLastWord(args, "stats", "info", "messages", "toggle",
                        "wave", "changelog");
            case 2:
                switch (args[0].toLowerCase()) {
                    case "s":
                    case "stats":
                        return getListOfStringsMatchingLastWord(args, "day", "yesterday", "week",
                                "month", "year", "lifetime");
                }
            default:
                return Collections.emptyList();
        }
    }

} public class CommandLimbo extends CommandAbstract {

    private boolean executed;

    public CommandLimbo(Autotip autotip) {
        super(autotip);
    }

    public boolean hasExecuted() {
        return executed;
    }

    public void setExecuted(boolean executed) {
        this.executed = executed;
    }

    @Override
    public String getCommandName() {
        return "limbo";
    }

    @Override
    public int getRequiredPermissionLevel() {
        return 0;
    }

    @Override
    public String getCommandUsage(ICommandSender sender) {
        return "/limbo";
    }

    @Override
    public void onCommand(ICommandSender sender, String[] args) {
        MessageUtil messageUtil = autotip.getMessageUtil();

        if (autotip.getSessionManager().isOnHypixel()) {
            this.executed = true;
            messageUtil.sendCommand("/achat \u00a7c");
        } else {
            messageUtil.send("&cYou must be on Hypixel to use this command!");
        }
    }

    @Override
    public List<String> onTabComplete(ICommandSender sender, String[] args) {
        return Collections.emptyList();
    }

} public class Config {

    @Exclude
    private final Autotip autotip;
    @Exclude
    private final File configFile;

    private boolean enabled = true;
    private Locale locale = Locale.forLanguageTag("en-US");
    private MessageOption messageOption = MessageOption.SHOWN;

    public Config(Autotip autotip) {
        this.autotip = autotip;
        this.configFile = autotip.getFileUtil().getFile("config.at");
    }

    public boolean isEnabled() {
        return enabled;
    }

    @CheckReturnValue
    public Config setEnabled(boolean enabled) {
        this.enabled = enabled;
        return this;
    }

    @CheckReturnValue
    public Config toggleEnabled() {
        this.enabled = !this.enabled;
        return this;
    }

    public Locale getLocale() {
        return locale;
    }

    @CheckReturnValue
    public Config setLocale(Locale locale) {
        this.locale = locale;
        return this;
    }

    public MessageOption getMessageOption() {
        return messageOption;
    }

    @CheckReturnValue
    public Config nextMessageOption() {
        this.messageOption = messageOption.next();
        return this;
    }

    @CheckReturnValue
    public Config setMessageOption(MessageOption messageOption) {
        this.messageOption = messageOption;
        return this;
    }

    public Config save() {
        try {
            String json = autotip.getGson().toJson(this);
            FileUtils.writeStringToFile(configFile, json, StandardCharsets.UTF_8);
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not write config to " + configFile, e);
        }
        return this;
    }

    public Config load() {
        try {
            String json = FileUtils.readFileToString(configFile);
            return this.merge(autotip.getGson().fromJson(json, Config.class)).save();
        } catch (FileNotFoundException e) {
            Autotip.LOGGER.info("config.at does not exist, creating...");
        } catch (JsonSyntaxException e) {
            Autotip.LOGGER.warn("config.at has invalid contents, resetting...");
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not read config.at!", e);
        }
        return this.save();
    }

    public Config migrate() {
        FileUtil fileUtil = autotip.getFileUtil();

        // Check if legacy config file exists
        File legacyFile = fileUtil.getFile("options.at");
        if (!legacyFile.exists()) {
            return this;
        }

        try {
            List<String> lines = Files.readAllLines(fileUtil.getPath("options.at"));
            if (lines.size() < 2) {
                return this;
            }

            this.enabled = Boolean.parseBoolean(lines.get(0));
            try {
                this.messageOption = MessageOption.valueOf(lines.get(1));
            } catch (IllegalArgumentException | NullPointerException e) {
                this.messageOption = MessageOption.SHOWN;
            }

            // Deletes old file to complete migration
            fileUtil.delete(legacyFile);

            return this.save();
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not read legacy options.at file!");
            return this.save();
        }
    }

    private Config merge(final Config that) {
        this.enabled = that.enabled;
        this.locale = that.locale == null ? this.locale : that.locale;
        this.messageOption = that.messageOption == null ? this.messageOption : that.messageOption;
        return this;
    }

} public class GlobalSettings {

    private Version latestVersion;
    private List<VersionInfo> versions;
    private String hypixelHeader;
    private int xpPerTipSent;
    private int xpPerTipReceived;
    private LocalDate xpChangeDate;
    private List<GameGroup> gameGroups;
    private List<GameAlias> gameAliases;
    private List<Message> messages;
    private List<StatsMessage> statsMessages;

    public Version getLatestVersion() {
        return latestVersion;
    }

    public VersionInfo getVersionInfo(Version version) {
        return versions.stream()
                .filter(v -> v.getVersion().equals(version))
                .findFirst()
                .orElse(new VersionInfo(version, Severity.OPTIONAL, "&cVersion not found."));
    }

    public List<VersionInfo> getHigherVersionInfo(Version lowest) {
        return versions.stream().filter(info -> {
            Version v = info.getVersion();
            return v.compareTo(lowest) > 0 && v.compareTo(this.latestVersion) < 1;
        }).collect(Collectors.toList());
    }


    public String getHypixelHeader() {
        return hypixelHeader;
    }

    public int getXpPerTipSent() {
        return xpPerTipSent;
    }

    public int getXpPerTipReceived() {
        return xpPerTipReceived;
    }

    public LocalDate getXpChangeDate() {
        return xpChangeDate;
    }

    public List<GameGroup> getGameGroups() {
        return gameGroups;
    }

    public List<GameAlias> getGameAliases() {
        return gameAliases;
    }

    public List<Message> getMessages() {
        return messages;
    }

    public List<StatsMessage> getStatsMessages() {
        return statsMessages;
    }

    public static class GameGroup {

        private String name;
        private Set<String> games;

        public String getName() {
            return name;
        }

        public Set<String> getGames() {
            return games;
        }

    }

    public static class GameAlias {

        private String alias;
        private List<String> aliases;

        private String game;
        private List<String> games;

        public List<String> getAliases() {
            if (alias != null) {
                return Collections.singletonList(alias);
            }
            return aliases;
        }

        public List<String> getGames() {
            if (game != null) {
                return Collections.singletonList(game);
            }
            return games;
        }

    }

} public class MigrationManager {

    private static final DateTimeFormatter OLD_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");

    private final Autotip autotip;
    private final FileUtil fileUtil;
    private final Config config;

    private final File upgradeDateFile;
    private final LocalDate xpChangeDate;

    public MigrationManager(Autotip autotip) {
        this.autotip = autotip;
        this.fileUtil = autotip.getFileUtil();
        this.config = autotip.getConfig();
        this.upgradeDateFile = fileUtil.getFile("upgrade-date.at");
        this.xpChangeDate = autotip.getGlobalSettings().getXpChangeDate();
    }

    public void migrateLegacyFiles() {
        if (fileUtil.exists("options.at")) {
            config.migrate();
        }
        if (fileUtil.exists("tipped.at")) {
            fileUtil.delete("tipped.at");
        }
        this.migrateStats();
        if (fileUtil.exists("upgrade-date.at")) {
            fileUtil.delete("upgrade-date.at");
        }
    }

    private void migrateStats() {
        try {
            Files.walk(fileUtil.getStatsDir(), 1)
                    .filter(path -> !path.toFile().isDirectory())
                    .map(path -> FilenameUtils.removeExtension(path.getFileName().toString()))
                    .map(filename -> {
                        try {
                            return Optional.of(LocalDate.parse(filename, OLD_FORMAT));
                        } catch (DateTimeParseException e) {
                            return Optional.empty();
                        }
                    })
                    .filter(Optional::isPresent)
                    .map(Optional::get)
                    .forEach(date -> new StatsDaily(autotip, (LocalDate) date).migrate());
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not migrate stats files", e);
        }
    }

    public LegacyState getLegacyState(LocalDate date) {
        if (date.isBefore(xpChangeDate)) {
            return LegacyState.BEFORE;
        } else if (date.isBefore(this.getUpgradeDate())) {
            return LegacyState.BACKTRACK;
        } else {
            return LegacyState.AFTER;
        }
    }

    private LocalDate getUpgradeDate() {
        if (!upgradeDateFile.exists()) {
            return xpChangeDate;
        }
        try {
            String date = FileUtils.readFileToString(upgradeDateFile, StandardCharsets.UTF_8);
            return LocalDate.parse(date, OLD_FORMAT);
        } catch (IOException | DateTimeParseException | NullPointerException e) {
            return xpChangeDate;
        }
    }

    public enum LegacyState {
        BEFORE, BACKTRACK, AFTER
    }

} public class SessionManager {

    private final Autotip autotip;
    private final MessageUtil messageUtil;
    private final TaskManager taskManager;

    private final Queue<Tip> tipQueue = new ConcurrentLinkedQueue<>();

    private LoginReply reply;
    private SessionKey sessionKey = null;

    private boolean onHypixel = false;
    private boolean loggedIn = false;

    private long lastTipWave;
    private long nextTipWave;

    public SessionManager(Autotip autotip) {
        this.autotip = autotip;
        this.messageUtil = autotip.getMessageUtil();
        this.taskManager = autotip.getTaskManager();
    }

    public SessionKey getKey() {
        return sessionKey;
    }

    public boolean hasKey() {
        return sessionKey != null;
    }

    public boolean isOnHypixel() {
        return onHypixel;
    }

    public void setOnHypixel(boolean onHypixel) {
        this.onHypixel = onHypixel;
    }

    public boolean isLoggedIn() {
        return loggedIn;
    }

    public long getLastTipWave() {
        return lastTipWave;
    }

    public long getNextTipWave() {
        return nextTipWave;
    }

    public void checkVersions() {
        List<VersionInfo> versions = autotip.getGlobalSettings()
                .getHigherVersionInfo(autotip.getVersion());
        if (versions.size() > 0) {
            messageUtil.separator();
            messageUtil.getKeyHelper("update")
                    .withKey("message", context -> context.getBuilder()
                            .setUrl(context.getKey("url"))
                            .setHover(context.getKey("hover"))
                            .send())
                    .sendKey("changelogHeader");
            versions.forEach(info -> {
                messageUtil.sendKey("update.version", info.getVersion(),
                        info.getSeverity().toColoredString());
                info.getChangelog().forEach(s -> messageUtil.sendKey("update.logEntry", s));
            });
            messageUtil.separator();
        }
    }

    public void login() {
        Session session = autotip.getMinecraft().getSession();
        GameProfile profile = session.getProfile();

        String uuid = profile.getId().toString().replace("-", "");
        String serverHash = HashUtil.hash(uuid + HashUtil.getNextSalt());

        int statusCode = this.authenticate(session.getToken(), uuid, serverHash);
        if (statusCode / 100 != 2) {
            messageUtil.send("&cError {} during authentication: Session servers down?", statusCode);
            return;
        }

        StatsRange all = autotip.getStatsManager().getAll();
        LoginRequest request = LoginRequest.of(autotip, profile, serverHash, all.getTipsTotalInt());

        long lastLogin = autotip.getEvent(EventClientConnection.class).getLastLogin();
        long delay = lastLogin + 5000 - System.currentTimeMillis();
        delay /= 1000;

        this.reply = taskManager.scheduleAndAwait(request::execute, (delay < 1) ? 1 : delay);
        if (reply == null || !reply.isSuccess()) {
            messageUtil.send("&cError during login: {}", reply == null ? "null" : reply.getCause());
            return;
        }

        this.sessionKey = reply.getSessionKey();

        this.loggedIn = true;

        long keepAlive = reply.getKeepAliveRate();
        long tipWave = reply.getTipWaveRate();

        taskManager.addRepeatingTask(TaskType.KEEP_ALIVE, this::keepAlive, keepAlive, keepAlive);
        taskManager.addRepeatingTask(TaskType.TIP_WAVE, this::tipWave, 0, tipWave);
    }

    public void logout() {
        if (!loggedIn) {
            return;
        }
        LogoutReply reply = LogoutRequest.of(sessionKey).execute();
        if (!reply.isSuccess()) {
            Autotip.LOGGER.warn("Error during logout: {}", reply.getCause());
        }

        this.loggedIn = false;
        this.sessionKey = null;

        taskManager.cancelTask(TaskType.KEEP_ALIVE);
        tipQueue.clear();
    }

    private void keepAlive() {
        if (!onHypixel || !loggedIn) {
            taskManager.cancelTask(TaskType.KEEP_ALIVE);
            return;
        }
        KeepAliveReply r = KeepAliveRequest.of(sessionKey).execute();
        if (!r.isSuccess()) {
            Autotip.LOGGER.warn("KeepAliveRequest failed: {}", r.getCause());
        }
    }

    private void tipWave() {
        if (!onHypixel || !loggedIn) {
            taskManager.cancelTask(TaskType.TIP_WAVE);
            return;
        }

        this.lastTipWave = System.currentTimeMillis();
        this.nextTipWave = System.currentTimeMillis() + reply.getTipWaveRate() * 1000;

        TipReply r = TipRequest.of(sessionKey).execute();
        if (r.isSuccess()) {
            tipQueue.addAll(r.getTips());
            Autotip.LOGGER.info("Current tip queue: {}",
                    StringUtils.join(tipQueue.iterator(), ", "));
        } else {
            tipQueue.addAll(TipReply.getDefault().getTips());
            Autotip.LOGGER.info("Failed to fetch tip queue, tipping 'all' instead.");
        }

        long tipCycle = reply.getTipCycleRate();
        taskManager.addRepeatingTask(TaskType.TIP_CYCLE, this::tipCycle, 0, tipCycle);
    }

    private void tipCycle() {
        if (tipQueue.isEmpty() || !onHypixel) {
            taskManager.cancelTask(TaskType.TIP_CYCLE);
            return;
        }

        Autotip.LOGGER.info("Attempting to tip: {}", tipQueue.peek().toString());
        messageUtil.sendCommand(tipQueue.poll().getAsCommand());
    }

    private int authenticate(String token, String uuid, String serverHash) {
        try {
            URL url = new URL("https://sessionserver.mojang.com/session/minecraft/join");
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            conn.setRequestMethod("POST");
            conn.setDoOutput(true);

            JsonObject obj = new JsonObject();
            obj.addProperty("accessToken", token);
            obj.addProperty("selectedProfile", uuid);
            obj.addProperty("serverId", serverHash);

            byte[] jsonBytes = obj.toString().getBytes(StandardCharsets.UTF_8);

            conn.setFixedLengthStreamingMode(jsonBytes.length);
            conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
            conn.connect();

            try (OutputStream out = conn.getOutputStream()) {
                out.write(jsonBytes);
            }

            return conn.getResponseCode();
        } catch (IOException e) {
            ErrorReport.reportException(e);
            return HttpStatus.SC_BAD_REQUEST;
        }
    }

} public class StatsManager {

    private final Autotip autotip;
    private final FileUtil fileUtil;
    private final Map<LocalDate, StatsDaily> cache = new ConcurrentHashMap<>();

    private LocalDate lastDate;
    private AtomicInteger ticks;

    public StatsManager(Autotip autotip) {
        this.autotip = autotip;
        this.fileUtil = autotip.getFileUtil();
        this.lastDate = LocalDate.now();
        this.ticks = new AtomicInteger(-1);
    }

    /**
     * Get the {@link StatsDaily} for today, based on {@link LocalDate#now()}. Same as {@link
     * #getToday(boolean)}, except that readOnly defaults to false.
     *
     * @return {@link StatsDaily} of today
     */
    public synchronized StatsDaily getToday() {
        return this.getToday(false);
    }

    /**
     * Get the {@link StatsDaily} for today, based on {@link LocalDate#now()}.
     *
     * @param readOnly Set to true to prevent the auto-save
     * @return {@link StatsDaily} of today
     */
    private synchronized StatsDaily getToday(boolean readOnly) {
        LocalDate now = LocalDate.now();
        if (!lastDate.isEqual(now)) {
            this.save(this.get(lastDate));
            lastDate = now;
        }
        if (!readOnly) {
            // Save after 7 seconds (20 ticks/sec) of no access
            ticks.set(7 * 20);
        }
        return this.get(lastDate);
    }

    /**
     * Get the {@link StatsDaily} for the current date without triggering the auto-save. This method
     * is similar to using {@link #get(LocalDate)} with the {@link LocalDate} being today.
     *
     * @return {@link StatsDaily} of today
     * @see #get(LocalDate)
     */
    public StatsDaily get() {
        return this.getToday(true);
    }

    /**
     * Get the {@link StatsDaily} for the specified date. This method uses a cache to reduce the
     * amount of read/write cycles.
     *
     * @param date The {@link LocalDate} of the StatsDaily you want to get
     * @return {@link StatsDaily} for the specified date
     */
    public StatsDaily get(LocalDate date) {
        if (cache.containsKey(date)) {
            return cache.get(date);
        }
        StatsDaily stats = this.load(new StatsDaily(autotip, date));
        cache.put(date, stats);
        return stats;
    }

    /**
     * Get the {@link StatsRange} for the specified date range. This method uses {@link
     * #get(LocalDate)} to get all the {@link StatsDaily} that are contained within this range.
     *
     * @param start The starting {@link LocalDate}
     * @param end The ending {@link LocalDate}
     * @return {@link StatsRange} for the specified date range
     */
    public StatsRange getRange(LocalDate start, LocalDate end) {
        if (start.isBefore(fileUtil.getFirstDate())) {
            start = fileUtil.getFirstDate();
        }
        if (end.isAfter(LocalDate.now())) {
            end = LocalDate.now();
        }
        StatsRange range = new StatsRange(autotip, start, end);
        Stream.iterate(start, date -> date.plusDays(1))
                .limit(ChronoUnit.DAYS.between(start, end) + 1)
                .forEach(date -> {
                    range.merge(this.get(date));
                });
        return range;
    }

    /**
     * Get the {@link StatsRange} for all the {@link StatsDaily} files in the current user
     * directory. This method uses {@link FileUtil} to get the starting date, and then calls {@link
     * #getRange(LocalDate, LocalDate) to get the specified StatsRange.
     *
     * @return {@link StatsRange} for the lifetime statistics from the current user directory
     */
    public StatsRange getAll() {
        return this.getRange(fileUtil.getFirstDate(), LocalDate.now());
    }

    /**
     * Save a {@link StatsDaily} to the current user directory.
     *
     * @param stats The {@link StatsDaily} that you want to save
     */
    public void save(StatsDaily stats) {
        cache.put(stats.getDate(), stats);
        File file = stats.getFile();
        try {
            String json = autotip.getGson().toJson(stats);
            FileUtils.writeStringToFile(file, json, StandardCharsets.UTF_8);
            Autotip.LOGGER.info("Saved " + stats.getFile().getName());
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not write to " + file, e);
        }
    }

    /**
     * Load a {@link StatsDaily} from the current user directory.
     *
     * @param stats The {@link StatsDaily} that you want to load
     * @return {@link StatsDaily} that contains the loaded stats, unchanged if there were errors
     */
    private StatsDaily load(StatsDaily stats) {
        File file = stats.getFile();
        try {
            String json = FileUtils.readFileToString(file);
            return stats.merge(autotip.getGson().fromJson(json, StatsDaily.class));
        } catch (FileNotFoundException e) {
            // Skip
            return stats;
        } catch (JsonSyntaxException | IllegalArgumentException e) {
            Autotip.LOGGER.warn(file.getName() + " has invalid contents, resetting...");
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not read " + file.getName() + "!", e);
        }
        this.save(stats);
        return stats;
    }

    /**
     * Method that is called each game-tick to trigger an auto-save for today's stats file.
     */
    public void saveCycle() {
        if (ticks.get() > 0) {
            ticks.decrementAndGet();
            return;
        }
        if (ticks.get() == 0) {
            this.save(this.get(lastDate));
            ticks.decrementAndGet();
        }
    }

} public class TaskManager {

    private final ExecutorService executor;
    private final ScheduledExecutorService scheduler;

    private final Map<TaskType, Future> tasks;

    public TaskManager() {
        this.executor = Executors.newCachedThreadPool(this.getFactory("AutotipThread"));
        this.scheduler = Executors.newScheduledThreadPool(3, this.getFactory("AutotipScheduler"));
        this.tasks = new ConcurrentHashMap<>();
    }

    public ExecutorService getExecutor() {
        return executor;
    }

    public void schedule(Runnable runnable, long delay) {
        try {
            scheduler.schedule(runnable, delay, SECONDS).get();
        } catch (InterruptedException | ExecutionException e) {
            ErrorReport.reportException(e);
        }
    }

    public <T> T scheduleAndAwait(Callable<T> callable, long delay) {
        try {
            return scheduler.schedule(callable, delay, SECONDS).get();
        } catch (InterruptedException | ExecutionException e) {
            ErrorReport.reportException(e);
            return null;
        }
    }

    public void executeTask(TaskType type, Runnable task) {
        if (tasks.containsKey(type)) {
            return;
        }
        Future<?> future = executor.submit(task);
        tasks.put(type, future);
        this.catchFutureException(type, future);
    }

    public void addRepeatingTask(TaskType type, Runnable command, long delay, long period) {
        if (tasks.containsKey(type)) {
            return;
        }
        ScheduledFuture future = scheduler.scheduleAtFixedRate(command, delay, period, SECONDS);
        tasks.put(type, future);
        this.catchFutureException(type, future);
    }

    public void cancelTask(TaskType type) {
        if (tasks.containsKey(type)) {
            tasks.get(type).cancel(true);
            tasks.remove(type);
        }
    }

    private void catchFutureException(TaskType type, Future future) {
        this.executor.execute(() -> {
            try {
                future.get();
            } catch (CancellationException ignored) {
                // Manual cancellation of a repeating task.
            } catch (InterruptedException | ExecutionException e) {
                ErrorReport.reportException(e);
            } finally {
                tasks.remove(type);
            }
        });
    }

    private ThreadFactory getFactory(String name) {
        return new ThreadFactoryBuilder()
                .setNameFormat(name)
                .setUncaughtExceptionHandler((t, e) -> ErrorReport.reportException(e))
                .build();
    }

    public enum TaskType {
        LOGIN, KEEP_ALIVE, TIP_WAVE, TIP_CYCLE, LOGOUT
    }

} public interface Event {

} public class EventChatReceived implements Event {

    private final Autotip autotip;

    public EventChatReceived(Autotip autotip) {
        this.autotip = autotip;
    }

    @SubscribeEvent
    public void onChat(ClientChatReceivedEvent event) {
        Config config = autotip.getConfig();

        if (!autotip.getSessionManager().isOnHypixel()) {
            return;
        }

        String msg = UniversalUtil.getUnformattedText(event);

        CommandLimbo limboCommand = autotip.getCommand(CommandLimbo.class);
        if (limboCommand.hasExecuted()) {
            if (msg.startsWith("A kick occurred in your connection")) {
                event.setCanceled(true);
            } else if (msg.startsWith("Illegal characters in chat")) {
                event.setCanceled(true);
                limboCommand.setExecuted(false);
            }
        }

        if (!config.isEnabled()) {
            return;
        }

        GlobalSettings settings = autotip.getGlobalSettings();
        MessageOption option = config.getMessageOption();

        for (Message message : settings.getMessages()) {
            MessageMatcher matcher = message.getMatcherFor(msg);
            if (matcher.matches()) {
                event.setCanceled(message.shouldHide(option));
                return;
            }
        }

        String hover = UniversalUtil.getHoverText(event);

        for (StatsMessage message : settings.getStatsMessages()) {
            StatsMessageMatcher matcher = message.getMatcherFor(msg);
            if (!matcher.matches()) {
                continue;
            }
            StatsDaily stats = this.getStats();
            matcher.applyStats(stats);
            message.applyHoverStats(hover, stats);
            event.setCanceled(message.shouldHide(option));
        }
    }

    private StatsDaily getStats() {
        return this.autotip.getStatsManager().getToday();
    }

} public class EventClientConnection implements Event {

    private static Field HEADER_FIELD;

    static {
        try {
            HEADER_FIELD = ReflectionUtil
                    .findField(ReflectionUtil.findClazz("net.labymod.core_implementation.mc18.gui.ModPlayerTabOverlay"),
                            "header");
        } catch (Exception e) {
            HEADER_FIELD = ReflectionUtil
                    .findField(GuiPlayerTabOverlay.class, "field_175256_i", "header");
        }
    }

    private final Autotip autotip;
    private final String hypixelHeader;

    private String serverIp;
    private long lastLogin;

    public EventClientConnection(Autotip autotip) {
        this.autotip = autotip;
        this.hypixelHeader = autotip.getGlobalSettings().getHypixelHeader();
    }

    public String getServerIp() {
        return serverIp;
    }

    public long getLastLogin() {
        return lastLogin;
    }

    public Object getHeader() {
        try {
            return HEADER_FIELD.get(autotip.getMinecraft().ingameGUI.getTabList());
        } catch (IllegalAccessException | NullPointerException e) {
            ErrorReport.reportException(e);
            return null;
        }
    }

    private void resetHeader() {
        try {
            HEADER_FIELD.set(autotip.getMinecraft().ingameGUI.getTabList(), null);
        } catch (IllegalAccessException e) {
            ErrorReport.reportException(e);
        }
    }

    @SubscribeEvent
    public void playerLoggedIn(ClientConnectedToServerEvent event) {
        TaskManager taskManager = autotip.getTaskManager();
        SessionManager manager = autotip.getSessionManager();

        autotip.getMessageUtil().clearQueues();

        this.serverIp = UniversalUtil.getRemoteAddress(event).toString().toLowerCase();
        this.lastLogin = System.currentTimeMillis();

        taskManager.getExecutor().execute(() -> {
            Object header;
            int attempts = 0;
            while ((header = this.getHeader()) == null) {
                if (attempts > 15) {
                    return;
                }
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ignored) {
                }
                attempts++;
            }

            if (UniversalUtil.getUnformattedText(header).equals(hypixelHeader)) {
                manager.setOnHypixel(true);
                if (autotip.getConfig().isEnabled()) {
                    taskManager.executeTask(TaskType.LOGIN, manager::login);
                    taskManager.schedule(manager::checkVersions, 5);
                }
            } else {
                manager.setOnHypixel(false);
            }
        });
    }

    @SubscribeEvent
    public void playerLoggedOut(ClientDisconnectionFromServerEvent event) {
        TaskManager taskManager = autotip.getTaskManager();
        SessionManager manager = autotip.getSessionManager();
        manager.setOnHypixel(false);
        taskManager.executeTask(TaskType.LOGOUT, manager::logout);
        this.resetHeader();
    }

} public class EventClientTick implements Event {

    private final Autotip autotip;

    public EventClientTick(Autotip autotip) {
        this.autotip = autotip;
    }

    @SubscribeEvent
    public void onClientTick(ClientTickEvent event) {
        if (event.phase == Phase.START) {
            return;
        }
        autotip.getMessageUtil().flushQueues();
        if (autotip.isInitialized()) {
            autotip.getStatsManager().saveCycle();
        }
    }

} public class ConfigCreator implements InstanceCreator<Config> {

    private final Autotip autotip;

    public ConfigCreator(Autotip autotip) {
        this.autotip = autotip;
    }

    @Override
    public Config createInstance(Type type) {
        return new Config(autotip);
    }

} public class StatsDailyCreator implements InstanceCreator<StatsDaily> {

    private final Autotip autotip;

    public StatsDailyCreator(Autotip autotip) {
        this.autotip = autotip;
    }

    @Override
    public StatsDaily createInstance(Type type) {
        return new StatsDaily(autotip);
    }

} public class AnnotationExclusionStrategy implements ExclusionStrategy {

    @Override
    public boolean shouldSkipField(FieldAttributes f) {
        return f.getAnnotation(Exclude.class) != null;
    }

    @Override
    public boolean shouldSkipClass(Class<?> clazz) {
        return false;
    }

} public class StatsDaily extends Stats {

    private static final Pattern TIPS_PATTERN = Pattern
            .compile("^(?<sent>\\d+)(:(?<received>\\d+))?$");
    private static final Pattern GAME_PATTERN = Pattern
            .compile("^(?<game>[\\w\\s]+):(?<sent>\\d+)(:(?<received>\\d+))?$");

    protected LocalDate date;

    public StatsDaily(Autotip autotip) {
        super(autotip);
    }

    public StatsDaily(Autotip autotip, LocalDate date) {
        super(autotip);
        this.date = date;
    }

    public LocalDate getDate() {
        return date;
    }

    public String getDateString() {
        return DATE_FORMATTER.format(date);
    }

    public File getFile() {
        return autotip.getFileUtil().getStatsFile(date);
    }

    @Override
    public StatsDaily merge(final Stats that) {
        if (!(that instanceof StatsDaily)) {
            throw new IllegalArgumentException("Cannot merge with StatsDaily");
        }
        StatsDaily stats = (StatsDaily) that;
        if (!date.isEqual(stats.date)) {
            throw new IllegalArgumentException("Dates do not match!");
        }
        super.merge(that);
        return this;
    }

    /**
     * Wrapped version of {@link me.semx11.autotip.core.StatsManager#save(StatsDaily)}.
     *
     * @see me.semx11.autotip.core.StatsManager#save(StatsDaily)
     */
    public void save() {
        autotip.getStatsManager().save(this);
    }

    public void migrate() {
        // Check if legacy stats file exists
        FileUtil fileUtil = autotip.getFileUtil();
        File file = fileUtil.getLegacyStatsFile(date);
        if (!file.exists()) {
            return;
        }

        LegacyState state = autotip.getMigrationManager().getLegacyState(date);

        try {
            // Reads the contents of the file. If the file has less than 2 lines, ignore file.
            List<String> lines = Files.readAllLines(file.toPath());
            if (lines.size() < 2) {
                fileUtil.delete(file);
                return;
            }

            // Parses the first line of the file to tips sent and received (e.g. "124:119").
            Matcher tipMatcher = TIPS_PATTERN.matcher(lines.get(0));
            if (tipMatcher.matches()) {
                this.tipsSent = Integer.parseInt(tipMatcher.group("sent"));
                if (tipMatcher.group("received") != null) {
                    this.tipsReceived = Integer.parseInt(tipMatcher.group("received"));
                }
            }

            // This is to fix the wrong tips count in the period between the XP change, and the Autotip fix.
            if (state == LegacyState.BACKTRACK) {
                this.tipsReceived /= 2;
            }

            // Every tip you send is worth 50 XP.
            this.xpSent = tipsSent * 50;
            // This is to account for tips received before the XP change, as they gave you 30 XP, not 60 XP.
            this.xpReceived = (state == LegacyState.BEFORE ? 30 : 60) * tipsReceived;

            // Parses each line with game-data (e.g. "Arcade:2900:2400") to a Map.
            this.gameStatistics = lines.stream()
                    .skip(2)
                    .filter(s -> GAME_PATTERN.matcher(s).matches())
                    .collect(Collectors.toMap(
                            s -> s.split(":")[0],
                            s -> {
                                String[] split = s.split(":");
                                int sent = Integer.parseInt(split[1]);
                                int received = split.length > 2 ? Integer.parseInt(split[2]) : 0;
                                return new Coins(sent, received);
                            }));

            // Remove grouped stats
            for (GameGroup group : settings.getGameGroups()) {
                if (gameStatistics.containsKey(group.getName())) {
                    Coins coins = gameStatistics.get(group.getName());
                    for (String game : group.getGames()) {
                        this.addCoins(game, coins);
                    }
                }
            }

            // Convert aliases
            for (GameAlias alias : settings.getGameAliases()) {
                for (String aliasAlias : alias.getAliases()) {
                    if (gameStatistics.containsKey(aliasAlias)) {
                        Coins coins = gameStatistics.get(aliasAlias);
                        for (String aliasGame : alias.getGames()) {
                            this.addCoins(aliasGame, coins);
                        }
                    }
                }
            }

            // Deletes old file to complete migration.
            fileUtil.delete(file);

            Autotip.LOGGER.info("Migrated legacy stats file " + file.getName());
            this.save();
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not read file " + file.getName(), e);
            this.save();
        }
    }

} public class UniversalUtil {

    private static Autotip autotip;

    private static Class<?> componentClass;
    private static Class<?> textComponentClass;

    private static Method addChatMethod;

    private static Class<?> chatStyleClass;

    private static Class<?> clickEventClass;
    private static Class<?> clickEventActionClass;

    private static Class<?> hoverEventClass;
    private static Class<?> hoverEventActionClass;

    public static void setAutotip(Autotip autotip) {
        UniversalUtil.autotip = autotip;
    }

    public static MinecraftVersion getMinecraftVersion() {
        try {
            Field f = findField(ForgeVersion.class, "mcVersion");
            if (f != null) {
                return MinecraftVersion.fromString((String) f.get(null));
            } else {
                return MinecraftVersion.V1_8;
            }
        } catch (IllegalAccessException e) {
            ErrorReport.reportException(e);
            return MinecraftVersion.V1_8;
        }
    }

    public static SocketAddress getRemoteAddress(ClientConnectedToServerEvent event) {
        SocketAddress address = null;
        try {
            Object networkManager = isLegacy()
                    ? findField(FMLNetworkEvent.class, "manager").get(event)
                    : findMethod(FMLNetworkEvent.class, new String[]{"getManager"})
                            .invoke(event);

            address = (SocketAddress) findMethod(
                    networkManager.getClass(),
                    new String[]{"func_74430_c", "getRemoteAddress"}
            ).invoke(networkManager);

        } catch (IllegalAccessException | InvocationTargetException e) {
            ErrorReport.reportException(e);
        }
        return address;
    }

    public static String getUnformattedText(ClientChatReceivedEvent event) {
        try {
            Object component = isLegacy()
                    ? findField(ClientChatReceivedEvent.class, "message").get(event)
                    : findMethod(ClientChatReceivedEvent.class, new String[]{"getMessage"})
                            .invoke(event);
            return getUnformattedText(component);
        } catch (InvocationTargetException | IllegalAccessException e) {
            ErrorReport.reportException(e);
            return "";
        }
    }

    public static String getUnformattedText(Object component) {
        if (component == null) {
            return null;
        }
        try {
            String text = (String) findMethod(
                    componentClass,
                    new String[]{"func_150260_c", "getUnformattedText"}
            ).invoke(component);
            return ChatColor.stripFormatting(text);
        } catch (IllegalAccessException | InvocationTargetException e) {
            ErrorReport.reportException(e);
            return null;
        }
    }

    public static String getHoverText(ClientChatReceivedEvent event) {
        try {
            Object component = isLegacy()
                    ? findField(ClientChatReceivedEvent.class, "message").get(event)
                    : findMethod(ClientChatReceivedEvent.class, new String[]{"getMessage"})
                            .invoke(event);
            return getHoverText(component);
        } catch (InvocationTargetException | IllegalAccessException e) {
            ErrorReport.reportException(e);
            return "";
        }
    }

    public static String getHoverText(Object component) {
        if (component == null) {
            return null;
        }
        try {
            Object style = findMethod(
                    componentClass,
                    new String[]{"func_150256_b", "getChatStyle"}
            ).invoke(component);
            Object hoverEvent = findMethod(
                    chatStyleClass,
                    new String[]{"func_150210_i", "getChatHoverEvent"}
            ).invoke(style);
            if (hoverEvent == null) {
                return null;
            }
            Object hoverComponent = findMethod(
                    hoverEventClass,
                    new String[]{"func_150702_b", "getValue"}
            ).invoke(hoverEvent);
            return getUnformattedText(hoverComponent);
        } catch (InvocationTargetException | IllegalAccessException e) {
            ErrorReport.reportException(e);
            return "";
        }
    }

    public static String getFormattedText(Object component) {
        try {
            return (String) findMethod(
                    componentClass,
                    new String[]{"func_150254_d", "getFormattedText"}
            ).invoke(component);
        } catch (IllegalAccessException | InvocationTargetException e) {
            ErrorReport.reportException(e);
            return "";
        }
    }

    public static void addChatMessage(String text) {
        addChatMessage(newComponent(text));
    }

    public static void addChatMessage(String text, String url, String hoverText) {
        addChatMessage(newComponent(text, url, hoverText));
    }

    private static void addChatMessage(Object component) {
        EntityPlayerSP thePlayer = autotip.getMinecraft().thePlayer;
        try {
            addChatMethod.invoke(thePlayer, component);
        } catch (InvocationTargetException | IllegalAccessException e) {
            ErrorReport.reportException(e);
        }
    }

    private static Object newComponent(String text) {
        try {
            return getConstructor(textComponentClass, String.class).newInstance(text);
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            ErrorReport.reportException(e);
            return null;
        }
    }

    private static Object newComponent(String text, String url, String hoverText) {
        try {
            Object chatStyle = chatStyleClass.newInstance();
            Object clickEvent;
            Object hoverEvent;

            if (url != null && !url.equals("")) {
                clickEvent = getConstructor(
                        clickEventClass,
                        clickEventActionClass,
                        String.class
                ).newInstance(getEnum(clickEventActionClass, "OPEN_URL"), url);

                findMethod(
                        chatStyleClass,
                        new String[]{"func_150241_a", "setChatClickEvent"}, // 1.8 - 1.12.2
                        clickEventClass
                ).invoke(chatStyle, clickEvent);
            }

            if (hoverText != null && !hoverText.equals("")) {
                hoverEvent = getConstructor(
                        hoverEventClass,
                        hoverEventActionClass,
                        componentClass
                ).newInstance(getEnum(hoverEventActionClass, "SHOW_TEXT"), newComponent(hoverText));

                findMethod(
                        chatStyleClass,
                        new String[]{"func_150209_a", "setChatHoverEvent"}, // 1.8 - 1.12.2
                        hoverEventClass
                ).invoke(chatStyle, hoverEvent);
            }

            Object chatComponent = newComponent(text);

            return findMethod(
                    textComponentClass,
                    new String[]{"func_150255_a", "setChatStyle"}, // 1.8 - 1.12.2
                    chatStyleClass
            ).invoke(chatComponent, chatStyle);

        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            ErrorReport.reportException(e);
            return null;
        }
    }

    private static boolean isLegacy() {
        switch (autotip.getMcVersion()) {
            case V1_8:
            case V1_8_8:
            case V1_8_9:
                return true;
            case V1_9:
            case V1_9_4:
            case V1_10:
            case V1_10_2:
            case V1_11:
            case V1_11_2:
            case V1_12:
            case V1_12_1:
            case V1_12_2:
                return false;
            default:
                return false;
        }
    }

    static {
        componentClass = findClazz(
                "net.minecraft.util.IChatComponent", // 1.8 - 1.8.9
                "net.minecraft.util.text.ITextComponent" // 1.9 - 1.12.2
        );
        textComponentClass = findClazz(
                "net.minecraft.util.ChatComponentText", // 1.8 - 1.8.9
                "net.minecraft.util.text.TextComponentString" // 1.9 - 1.12.2
        );
        addChatMethod = findMethod(
                EntityPlayerSP.class,
                new String[]{
                        "func_145747_a", // 1.8  - 1.8.9  | 1.11 - 1.12.2
                        "func_146105_b", // 1.9  - 1.10.2
                        "addChatMessage", // 1.8  - 1.8.9  | 1.11 - 1.12.2
                        "addChatComponentMessage", // 1.9  - 1.10.2
                        "sendMessage" // 1.11 - 1.12.2
                },
                componentClass
        );
        chatStyleClass = findClazz(
                "net.minecraft.util.ChatStyle", // 1.8 - 1.8.9
                "net.minecraft.util.text.Style" // 1.9 - 1.12.2
        );
        clickEventClass = findClazz(
                "net.minecraft.event.ClickEvent", // 1.8 - 1.8.9
                "net.minecraft.util.text.event.ClickEvent" // 1.9 - 1.12.2
        );
        clickEventActionClass = findClazz(
                "net.minecraft.event.ClickEvent$Action", // 1.8 - 1.8.9
                "net.minecraft.util.text.event.ClickEvent$Action" // 1.9 - 1.12.2
        );
        hoverEventClass = findClazz(
                "net.minecraft.event.HoverEvent", // 1.8 - 1.8.9
                "net.minecraft.util.text.event.HoverEvent" // 1.9 - 1.12.2
        );
        hoverEventActionClass = findClazz(
                "net.minecraft.event.HoverEvent$Action", // 1.8 - 1.8.9
                "net.minecraft.util.text.event.HoverEvent$Action" // 1.9 - 1.12.2
        );
    }

} public class ErrorReport {

    private static Autotip autotip;

    public static void setAutotip(Autotip autotip) {
        ErrorReport.autotip = autotip;
    }

    public static void reportException(Throwable t) {
        Autotip.LOGGER.error(t.getMessage(), t);
        try {
            URL url = new URL("https://api.autotip.pro/error_report.php");
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();

            conn.setRequestMethod("POST");
            conn.setDoOutput(true);

            JsonObjectBuilder builder = JsonObjectBuilder.newBuilder()
                    .addString("username", autotip.getGameProfile().getName())
                    .addString("uuid", autotip.getGameProfile().getId())
                    .addString("v", autotip.getVersion())
                    .addString("mc", autotip.getMcVersion())
                    .addString("os", System.getProperty("os.name"))
                    .addString("forge", ForgeVersion.getVersion())
                    .addString("stackTrace", ExceptionUtils.getStackTrace(t))
                    .addNumber("time", System.currentTimeMillis());

            if (autotip.isInitialized()) {
                EventClientConnection event = autotip.getEvent(EventClientConnection.class);
                builder.addString("sessionKey", autotip.getSessionManager().getKey())
                        .addString("serverIp", event.getServerIp());
            }

            byte[] jsonBytes = builder.build().toString().getBytes(StandardCharsets.UTF_8);

            conn.setFixedLengthStreamingMode(jsonBytes.length);
            conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
            conn.setRequestProperty("User-Agent", "Autotip v" + autotip.getVersion());
            conn.connect();

            try (OutputStream out = conn.getOutputStream()) {
                out.write(jsonBytes);
            }

            InputStream input;
            if (conn.getResponseCode() < HttpURLConnection.HTTP_BAD_REQUEST) {
                input = conn.getInputStream();
            } else {
                input = conn.getErrorStream();
            }
            String json = IOUtils.toString(input, StandardCharsets.UTF_8);
            Autotip.LOGGER.info("Error JSON: " + json);

        } catch (IOException e) {
            // Hmm... what would happen if I were to report this one?
            e.printStackTrace();
        }
    }

    private static class JsonObjectBuilder {

        private final JsonObject obj;

        private JsonObjectBuilder(JsonObject obj) {
            this.obj = obj;
        }

        static JsonObjectBuilder newBuilder() {
            return new JsonObjectBuilder(new JsonObject());
        }

        JsonObjectBuilder addString(String property, Object value) {
            obj.addProperty(property, String.valueOf(value));
            return this;
        }

        JsonObjectBuilder addNumber(String property, Number value) {
            obj.addProperty(property, value);
            return this;
        }

        JsonObject build() {
            return obj;
        }

    }

} public class FileUtil {

    private static final DateTimeFormatter OLD_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");

    private final Path userDir;
    private final Path statsDir;

    private LocalDate firstDate;

    public FileUtil(Autotip autotip) {
        this.userDir = this.getRawPath("mods/autotip/" + autotip.getGameProfile().getId());
        this.statsDir = this.getPath("stats");
    }

    public void createDirectories() throws IOException {
        if (!Files.exists(statsDir)) {
            Files.createDirectories(statsDir);
        }
    }

    public Path getUserDir() {
        return userDir;
    }

    public Path getStatsDir() {
        return statsDir;
    }

    public boolean exists(String path) {
        return Files.exists(this.getPath(path));
    }

    public void delete(String path) {
        this.delete(this.getPath(path));
    }

    public void delete(File file) {
        this.delete(file.toPath());
    }

    public void delete(Path path) {
        try {
            Files.delete(path);
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not delete file " + path);
        }
    }

    public File getLegacyStatsFile(LocalDate localDate) {
        return this.getFile(this.statsDir, localDate.format(OLD_FORMAT) + ".at");
    }

    public File getStatsFile(LocalDate localDate) {
        return this.getFile(this.statsDir, localDate.format(ISO_LOCAL_DATE) + ".at");
    }

    public LocalDate getFirstDate() {
        if (firstDate != null) {
            return firstDate;
        }
        try {
            return firstDate = Files.list(this.getStatsDir())
                    .map(this::getDateFromPath)
                    .filter(Objects::nonNull)
                    .findFirst()
                    .orElseGet(LocalDate::now);
        } catch (IOException e) {
            Autotip.LOGGER.error("Could not list files in stats dir.");
            return LocalDate.now();
        }
    }

    private LocalDate getDateFromPath(Path path) {
        String name = FilenameUtils.getBaseName(path.getFileName().toString());
        try {
            return LocalDate.parse(name);
        } catch (DateTimeParseException e) {
            return null;
        }
    }

    public File getFile(String path) {
        return this.getPath(path).toFile();
    }

    public Path getPath(String path) {
        return this.getPath(this.userDir, path);
    }

    private File getFile(Path directory, String path) {
        return this.getPath(directory, path).toFile();
    }

    private Path getPath(Path directory, String path) {
        return directory.resolve(this.separator(path));
    }

    private Path getRawPath(String path) {
        return Paths.get(this.separator(path));
    }

    private String separator(String s) {
        return s.replaceAll("///", File.separator);
    }

} public enum MinecraftVersion {
    V1_8, V1_8_8, V1_8_9, V1_9, V1_9_4, V1_10, V1_10_2, V1_11, V1_11_2, V1_12, V1_12_1, V1_12_2;

    public static MinecraftVersion fromString(String version) throws IllegalArgumentException {
        Pattern p = Pattern.compile(
                "^(1\\.8(\\.[8-9])?|1\\.9(\\.4)?|1\\.10(\\.2)?|1\\.11(\\.2)?|1\\.12(\\.[1-2])?)$");
        if (p.matcher(version).matches()) {
            return valueOf("V" + version.replaceAll("\\.", "_"));
        }
        return null;
    }

    public String toString() {
        return this.name().substring(1).replaceAll("_", ".");
    }

} public class Version implements Comparable<Version> {

    private String version;

    public Version(String version) {
        if (version == null) {
            throw new IllegalArgumentException("Version can not be null");
        }
        if (!version.matches("[0-9]+(\\.[0-9]+)*")) {
            throw new IllegalArgumentException("Invalid version format");
        }
        this.version = version;
    }

    public final String get() {
        return this.version;
    }

    @Override
    public int compareTo(Version that) {
        if (that == null) {
            return 1;
        }
        String[] thisParts = this.get().split("\\.");
        String[] thatParts = that.get().split("\\.");
        int length = Math.max(thisParts.length, thatParts.length);
        for (int i = 0; i < length; i++) {
            int thisPart = i < thisParts.length ?
                    Integer.parseInt(thisParts[i]) : 0;
            int thatPart = i < thatParts.length ?
                    Integer.parseInt(thatParts[i]) : 0;
            if (thisPart < thatPart) {
                return -1;
            }
            if (thisPart > thatPart) {
                return 1;
            }
        }
        return 0;
    }

    @Override
    public boolean equals(Object that) {
        return this == that || that != null && this.getClass() == that.getClass()
                && this.compareTo((Version) that) == 0;
    }

    @Override
    public String toString() {
        return this.get();
    }

}
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.mojang.authlib.GameProfile;
import me.semx11.autotip.api.RequestHandler;
import me.semx11.autotip.api.reply.impl.LocaleReply;
import me.semx11.autotip.api.reply.impl.SettingsReply;
import me.semx11.autotip.api.request.impl.LocaleRequest;
import me.semx11.autotip.api.request.impl.SettingsRequest;
import me.semx11.autotip.chat.LocaleHolder;
import me.semx11.autotip.chat.MessageUtil;
import me.semx11.autotip.command.CommandAbstract;
import me.semx11.autotip.command.impl.CommandAutotip;
import me.semx11.autotip.command.impl.CommandLimbo;
import me.semx11.autotip.config.Config;
import me.semx11.autotip.config.GlobalSettings;
import me.semx11.autotip.core.MigrationManager;
import me.semx11.autotip.core.SessionManager;
import me.semx11.autotip.core.StatsManager;
import me.semx11.autotip.core.TaskManager;
import me.semx11.autotip.event.Event;
import me.semx11.autotip.event.impl.EventChatReceived;
import me.semx11.autotip.event.impl.EventClientConnection;
import me.semx11.autotip.event.impl.EventClientTick;
import me.semx11.autotip.gson.creator.ConfigCreator;
import me.semx11.autotip.gson.creator.StatsDailyCreator;
import me.semx11.autotip.gson.exclusion.AnnotationExclusionStrategy;
import me.semx11.autotip.stats.StatsDaily;
import me.semx11.autotip.universal.UniversalUtil;
import me.semx11.autotip.util.ErrorReport;
import me.semx11.autotip.util.FileUtil;
import me.semx11.autotip.util.MinecraftVersion;
import me.semx11.autotip.util.Version;
import net.minecraft.client.Minecraft;
import net.minecraftforge.client.ClientCommandHandler;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.fml.common.FMLCommonHandler;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.Mod.EventHandler;
import net.minecraftforge.fml.common.event.FMLInitializationEvent;
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
package me.semx11.autotip;



@Mod(modid = Autotip.MOD_ID, name = Autotip.NAME, version = Autotip.VERSION, acceptedMinecraftVersions = Autotip.ACCEPTED_VERSIONS, clientSideOnly = true)
public class Autotip {

    public static final Logger LOGGER = LogManager.getLogger("Autotip");

    static final String MOD_ID = "autotip";
    static final String NAME = "Autotip";
    static final String VERSION = "3.0.1";
    static final String ACCEPTED_VERSIONS = "[1.8, 1.12.2]";

    private final List<Event> events = new ArrayList<>();
    private final List<CommandAbstract> commands = new ArrayList<>();

    private boolean initialized = false;

    private Minecraft minecraft;
    private MinecraftVersion mcVersion;
    private Version version;

    private Gson gson;

    private FileUtil fileUtil;
    private MessageUtil messageUtil;

    private Config config;
    private GlobalSettings globalSettings;
    private LocaleHolder localeHolder;

    private TaskManager taskManager;
    private SessionManager sessionManager;
    private MigrationManager migrationManager;
    private StatsManager statsManager;

    public boolean isInitialized() {
        return initialized;
    }

    public Minecraft getMinecraft() {
        return minecraft;
    }

    public GameProfile getGameProfile() {
        return minecraft.getSession().getProfile();
    }

    public MinecraftVersion getMcVersion() {
        return mcVersion;
    }

    public Version getVersion() {
        return version;
    }

    public Gson getGson() {
        return gson;
    }

    public FileUtil getFileUtil() {
        return fileUtil;
    }

    public MessageUtil getMessageUtil() {
        return messageUtil;
    }

    public Config getConfig() {
        return config;
    }

    public GlobalSettings getGlobalSettings() {
        return globalSettings;
    }

    public LocaleHolder getLocaleHolder() {
        return localeHolder;
    }

    public TaskManager getTaskManager() {
        return taskManager;
    }

    public SessionManager getSessionManager() {
        return sessionManager;
    }

    public MigrationManager getMigrationManager() {
        return migrationManager;
    }

    public StatsManager getStatsManager() {
        return statsManager;
    }

    @EventHandler
    public void preInit(FMLPreInitializationEvent event) {
        ErrorReport.setAutotip(this);
        RequestHandler.setAutotip(this);
        UniversalUtil.setAutotip(this);