@Mod(TwitchSpawn.MOD_ID)
public class TwitchSpawn {

    public static final String MOD_ID = "twitchspawn";
    public static final Logger LOGGER = LogManager.getLogger(TwitchSpawn.class);

    public static MinecraftServer SERVER;
    public static TraceManager TRACE_MANAGER;

    public TwitchSpawn() {
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::commonSetup);
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::clientSetup);
        FMLJavaModLoadingContext.get().getModEventBus().addListener(this::dedicatedServerSetup);

        MinecraftForge.EVENT_BUS.register(this);
    }

    private void commonSetup(final FMLCommonSetupEvent event) {
        try {
            ConfigManager.loadConfigs();
            NetworkManager.initialize();

            ArgumentTypes.register("twitchspawn:streamer", StreamerArgumentType.class,
                    new StreamerArgumentSerializer());
            ArgumentTypes.register("twitchspawn:ruleset", RulesetNameArgumentType.class,
                    new RulesetNameArgumentSerializer());

        } catch (TwitchSpawnLoadingErrors e) {
            e.bindFMLWarnings(ModLoadingStage.COMMON_SETUP);
            throw new RuntimeException("TwitchSpawn loading errors occurred");
        }
    }

    private void clientSetup(final FMLClientSetupEvent event) {
        NotepadUDLUpdater.attemptUpdate();
        MinecraftForge.EVENT_BUS.register(StatusIndicatorOverlay.class);
        MinecraftForge.EVENT_BUS.register(GlobalChatCooldownOverlay.class);
    }

    private void dedicatedServerSetup(final FMLDedicatedServerSetupEvent event) {}

    @SubscribeEvent
    public void registerSounds(RegistryEvent.Register<SoundEvent> event) {
        event.getRegistry().register(new SoundEvent(new ResourceLocation(TwitchSpawn.MOD_ID, "pop_in")));
        event.getRegistry().register(new SoundEvent(new ResourceLocation(TwitchSpawn.MOD_ID, "pop_out")));
    }

    @SubscribeEvent
    public void onRegisterCommands(RegisterCommandsEvent event) {
        TwitchSpawnCommand.register(event.getDispatcher());
    }

    @SubscribeEvent
    public void onServerAboutToStart(FMLServerAboutToStartEvent event) {
        SERVER = event.getServer();
        TRACE_MANAGER = new TraceManager();
    }

    @SubscribeEvent
    public void onServerStarting(FMLServerStartingEvent event) {
        if (ConfigManager.PREFERENCES.autoStart == PreferencesConfig.AutoStartEnum.ENABLED) {
            LOGGER.info("Auto-start is enabled. Attempting to start tracers.");
            TRACE_MANAGER.start();
        }
    }

    @SubscribeEvent
    public void onServerStopping(FMLServerStoppingEvent event) {
        SERVER = null;

        if (TRACE_MANAGER.isRunning())
            TRACE_MANAGER.stop(null, "Server stopping");

        ConfigManager.RULESET_COLLECTION.clearQueue();
    }

    @SubscribeEvent
    public void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
        ServerPlayer entity = (ServerPlayer) event.getPlayer();

        String translationKey = TRACE_MANAGER.isRunning() ?
                "commands.twitchspawn.status.on" : "commands.twitchspawn.status.off";

        entity.sendMessage(new TranslatableComponent(translationKey), entity.getUUID());

        if (TRACE_MANAGER.isRunning())
            TRACE_MANAGER.connectStreamer(entity.getName().getString());

        NetworkManager.CHANNEL.sendTo(new StatusChangedPacket(TRACE_MANAGER.isRunning()),
                entity.connection.connection,
                NetworkDirection.PLAY_TO_CLIENT);
    }

    @SubscribeEvent
    public void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
        ServerPlayer entity = (ServerPlayer) event.getPlayer();

        if (TRACE_MANAGER.isRunning())
            TRACE_MANAGER.disconnectStreamer(entity.getName().getString());
    }

} public class ConfigManager {

    public static final String CONFIG_DIR_PATH = FMLPaths.CONFIGDIR.get().toString() + File.separator + "TwitchSpawn";

    public static CredentialsConfig CREDENTIALS;
    public static TSLRulesetCollection RULESET_COLLECTION;
    public static TitlesConfig TITLES;
    public static SubtitlesConfig SUBTITLES;
    public static PreferencesConfig PREFERENCES;

    public static void loadConfigs() throws TwitchSpawnLoadingErrors {
        TwitchSpawn.LOGGER.info("Loading configs...");
        TwitchSpawnLoadingErrors errors = new TwitchSpawnLoadingErrors();

        File configDirectory = new File(CONFIG_DIR_PATH);

        if (!configDirectory.exists())
            configDirectory.mkdirs();

        accumulateExceptions(errors,
                () -> CREDENTIALS = CredentialsConfig.create(getPath("credentials.toml")));
        accumulateExceptions(errors,
                () -> RULESET_COLLECTION = RulesConfig.createRules(CONFIG_DIR_PATH));
        accumulateExceptions(errors,
                () -> TITLES = TitlesConfig.create(new File(getPath("messages.title.json"))));
        accumulateExceptions(errors,
                () -> SUBTITLES = SubtitlesConfig.create(new File(getPath("messages.subtitle.json"))));
        accumulateExceptions(errors,
                () -> PREFERENCES = PreferencesConfig.create(new File(getPath("preferences.toml"))));

        if(!errors.isEmpty())
            throw errors;

        TwitchSpawn.LOGGER.info("Configs loaded successfully!");
    }

    public static String getPath(String relativePath) {
        return CONFIG_DIR_PATH + File.separator + relativePath;
    }

    private static void accumulateExceptions(TwitchSpawnLoadingErrors container, ExceptionAccumulator task) {
        try {
            task.execute();
        } catch (Exception e) {
            container.addException(e);
        }
    }

    @FunctionalInterface
    private interface ExceptionAccumulator {
        void execute() throws Exception;
    }

} public class PreferencesConfig {

    public enum IndicatorDisplay {
        DISABLED, CIRCLE_ONLY, ENABLED
    }

    public enum MessageDisplay {
        DISABLED, TITLES, CHAT
    }

    public enum AutoStartEnum {
        DISABLED, ENABLED
    }

    public static PreferencesConfig create(File file) {
        try {
            // File is not there, create an empty file
            if (!file.exists()) {
                file.getParentFile().mkdirs();
                file.createNewFile();

                FileUtils.writeStringToFile(file, defaultScript(), StandardCharsets.UTF_8);
            }

            CommentedFileConfig config = CommentedFileConfig.builder(file).build();
            CommentedConfig defaultConfig = new TomlParser().parse(defaultScript());

            config.load();

            getSpecs().correct(config, (action, path, incorrectValue, correctedValue) -> {
                TwitchSpawn.LOGGER.info("[preferences.toml] Corrected {} to {}", incorrectValue, correctedValue);
                config.setComment(path, defaultConfig.getComment(path));
            });

//            config.save();
            save(config); // Here to put new line delimiters between entries & keep default comments

            PreferencesConfig preferencesConfig = new PreferencesConfig();
            preferencesConfig.indicatorDisplay = getEnum(config, "indicatorDisplay", IndicatorDisplay.class);
            preferencesConfig.messageDisplay = getEnum(config, "messageDisplay", MessageDisplay.class);
            preferencesConfig.notificationVolume = config.get("notificationVolume");
            preferencesConfig.notificationPitch = config.get("notificationPitch");
            preferencesConfig.notificationDelay = config.getInt("notificationDelay");
            preferencesConfig.autoStart = getEnum(config, "autoStart", AutoStartEnum.class);
            preferencesConfig.chatGlobalCooldown = config.get("chatGlobalCooldown");
            preferencesConfig.chatIndividualCooldown = config.get("chatIndividualCooldown");
            preferencesConfig.chatWarnings = config.get("chatWarnings").equals("enabled");

            config.close();

            return preferencesConfig;

        } catch (IOException e) {
            throw new InternalError("Tried to read from or save to a non-existing file");
        }
    }

    private static void save(CommentedFileConfig config) {
        StringBuilder data = new StringBuilder();

        for (CommentedConfig.Entry entry : config.entrySet()) {
            String[] commentLines = entry.getComment().split("\r?\n");
            String key = entry.getKey();
            Object value = entry.getValue();

            for (String commentLine : commentLines) {
                data.append("#").append(commentLine).append("\r\n");
            }

            data.append(key).append("=");

            // TODO: append other serialization rules, if needed
            if (value instanceof String)
                data.append('"').append(value).append('"');
            else if (value instanceof Enum)
                data.append('"').append(value.toString().toLowerCase()).append('"');
            else
                data.append(value.toString());

            data.append("\r\n\r\n");
        }

        try {
            FileUtils.writeStringToFile(config.getFile(), data.toString(), StandardCharsets.UTF_8);

        } catch (IOException e) {
            throw new InternalError("Failed writing config on " + config.getFile());
        }
    }

    private static ConfigSpec getSpecs() {
        ConfigSpec spec = new ConfigSpec();

//        spec.defineEnum("indicatorDisplay", IndicatorDisplay.ENABLED, EnumGetMethod.NAME_IGNORECASE);
//        spec.defineEnum("messageDisplay", MessageDisplay.TITLES, EnumGetMethod.NAME_IGNORECASE);
        defineEnum(spec, "indicatorDisplay", IndicatorDisplay.ENABLED, IndicatorDisplay.class);
        defineEnum(spec, "messageDisplay", MessageDisplay.TITLES, MessageDisplay.class);
        spec.defineInRange("notificationVolume", 1.0, 0.0, 1.0);
        spec.define("notificationPitch", 1.0, rawValue -> {
            if (!(rawValue instanceof Number))
                return false;
            double value = ((Number) rawValue).doubleValue();
            return (value == -1.0) || (0.0 <= value && value <= 1.0);
        });
        spec.defineInRange("notificationDelay", 5000, 0, Integer.MAX_VALUE);
        defineEnum(spec, "autoStart", AutoStartEnum.DISABLED, AutoStartEnum.class);
        spec.defineInRange("chatGlobalCooldown", 1000, 0, Integer.MAX_VALUE);
        spec.defineInRange("chatIndividualCooldown", 1000, 0, Integer.MAX_VALUE);
        spec.define("chatWarnings", "disabled", rawValue -> {
            if (!(rawValue instanceof String))
                return false;
            String value = ((String) rawValue);
            return value.equals("enabled") || value.equals("disabled");
        });

        return spec;
    }

    private static <T extends Enum<T>> T getEnum(CommentedFileConfig config, String path, Class<T> enumClass) {
        Object value = config.get(path);

        if (value instanceof String)
            return Enum.valueOf(enumClass, ((String) value).toUpperCase());

        if (enumClass.isInstance(value))
            return enumClass.cast(value);

        return null;
    }

    private static <T extends Enum<T>> void defineEnum(ConfigSpec spec, String path, T defaultValue, Class<T> enumClass) {
        spec.define(path, defaultValue, o -> {
            if (!(o instanceof String))
                return false;

            String enumName = ((String) o).toUpperCase();

            try { Enum.valueOf(enumClass, enumName); } catch (IllegalArgumentException e) {
                return false;
            }

            return true;
        });
    }

    private static String defaultScript() {
        try {
            URL location = Resources.getResource("assets/twitchspawn/default/preferences.toml");
            return Resources.toString(location, StandardCharsets.UTF_8);

        } catch (IOException e) {
            throw new InternalError("Missing default file: ../assets/twitchspawn/default/preferences.toml");
        }
    }

    /* ---------------------------------- */

    public IndicatorDisplay indicatorDisplay;

    public MessageDisplay messageDisplay;

    public double notificationVolume;
    public double notificationPitch;
    public int notificationDelay;

    public AutoStartEnum autoStart;

    public int chatGlobalCooldown;
    public int chatIndividualCooldown;
    public boolean chatWarnings;

} public interface TSLFlowNode {

    /**
     * Chains given node to this one
     *
     * @param next Next node
     * @return The next node if chained successfully.
     * Returns null otherwise
     */
    default TSLFlowNode chain(TSLFlowNode next) {
        throw new UnsupportedOperationException("TSLFlowNode::chain is not meant to be used on "
                + getClass().getSimpleName());
    }

    /**
     * Processes given event arguments.
     * Passes them to next flow node(s) if necessary
     *
     * @return True if successfully processed given args
     */
    boolean process(EventArguments args);

    /**
     * Checks whether given args will trigger an action or not
     *
     * @return True if given args will trigger an action to be performed
     */
    boolean willPerform(EventArguments args);

} public class EventArguments {

    public static EventArguments createRandom(String streamerNickname) {
        EventArguments eventArguments = new EventArguments(TSLEventKeyword.randomPair());

        eventArguments.streamerNickname = streamerNickname;
        eventArguments.randomize();

        return eventArguments;
    }

    /* ------------------------------ */

    public final String eventType;
    public final String eventAccount;
    public final String eventName;

    public String streamerNickname;
    public String actorNickname;
    public String message;

    public double donationAmount;
    public String donationCurrency;

    public int subscriptionMonths;
    public int subscriptionTier = -1; // 0=Prime, 1=T1, 2=T2, 3=T3
    public boolean gifted;

    public int viewerCount;
    public int raiderCount;

    public String rewardTitle;
    public Set<String> chatBadges;

    public EventArguments(String eventType, String eventAccount) {
        this.eventType = eventType;
        this.eventAccount = eventAccount;
        this.eventName = TSLEventKeyword.ofPair(eventType, eventAccount);
        this.chatBadges = new HashSet<>();
    }

    public EventArguments(TSLEventPair eventPair) {
        this(eventPair.getEventType(), eventPair.getEventAccount());
    }

    public void randomize() {
        randomize("RandomDude", "Random event message");
    }

    public void randomize(String actorNickname, String message) {
        Random random = new Random();

        this.actorNickname = actorNickname;
        this.message = message;
        this.donationAmount = random.nextDouble() * 1000;
        this.donationCurrency = new String[]{"USD", "TRY", "EUR"}[random.nextInt(3)];
        this.subscriptionMonths = random.nextInt(100 - 1) + 1;
        this.subscriptionTier = random.nextInt(3 + 1);
        this.gifted = random.nextBoolean();
        this.viewerCount = random.nextInt(100 - 1) + 1;
        this.raiderCount = random.nextInt(100 - 1) + 1;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("{");
        String delimiter = "";

        try {
            for (Field field : getClass().getFields()) {
                Object value = field.get(this);
                Object defaultValue = Defaults.defaultValue(field.getType());

                if (value == null)
                    continue;

                if (!value.equals(defaultValue)) {
                    sb.append(delimiter);
                    sb.append(field.getName()).append("=").append(value);
                    delimiter = ", ";
                }

                // Exception for tier field. (where default 0=Prime)
                else if (field.getName().equalsIgnoreCase("tier") && value.equals(0)) {
                    sb.append(delimiter);
                    sb.append(field.getName()).append("=").append(value);
                    delimiter = ", ";
                }
            }
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

        sb.append("}");

        return sb.toString();
    }

} public enum TSLActionKeyword {

    DROP(true, DropAction.class),
    SUMMON(true, SummonAction.class),
    THROW(true, ThrowAction.class),
    CLEAR(true, ClearAction.class),
    EXECUTE(true, ExecuteAction.class),
    SHUFFLE(true, ShuffleAction.class),
    CHANGE(true, ChangeAction.class),
    NOTHING(true, NothingAction.class),

    EITHER(false, EitherAction.class),
    BOTH(false, BothAction.class),
    FOR(false, ForAction.class),
    WAIT(true, WaitAction.class),
    REFLECT(false, ReflectAction.class),

    OS_RUN(true, OsRunAction.class),
    ;

    public static boolean exists(String actionName) {
        try {
            valueOf(actionName.toUpperCase());
            return true;
        } catch (IllegalArgumentException e) {
            return false;
        }
    }

    public static Class<? extends TSLAction> toClass(String actionName) {
        if (!exists(actionName))
            return null;

        return valueOf(actionName.toUpperCase()).actionClass;
    }

    public static String ofClass(Class<? extends TSLAction> actionClass) {
        for (TSLActionKeyword keyword : values()) {
            if (keyword.actionClass.equals(actionClass))
                return keyword.name();
        }
        return null;
    }

    /* --------------------------- */

    public final boolean displayable;
    public final Class<? extends TSLAction> actionClass;

    TSLActionKeyword(boolean displayable, Class<? extends TSLAction> actionClass) {
        this.displayable = displayable;
        this.actionClass = actionClass;
    }
} public class TSLRuleTokenizer {

    public static final String DISPLAY_KEYWORD = "DISPLAYING";
    public static final String EVENT_KEYWORD = "ON";
    public static final String PREDICATE_KEYWORD = "WITH";

    /* ---------------------------- */

    private List<String> words;
    private int wordCursor = 0;

    private String actionName;
    private List<String> actionParameters;
    private String eventName;
    private List<List<String>> predicateParameters;

    public TSLRuleTokenizer(List<String> words) {
        this.words = words;
    }

    public String getActionName() {
        return actionName;
    }

    public List<String> getActionParameters() {
        return actionParameters;
    }

    public String getEventName() {
        return eventName;
    }

    public List<List<String>> getPredicateParameters() {
        return predicateParameters;
    }

    private String currentWord() {
        if (wordCursor >= words.size())
            return null;
        return words.get(wordCursor);
    }

    /* ---------------------------- */

    public TSLRuleTokenizer intoParts() throws TSLSyntaxError {
        validate();
        tokenizeAction();
        tokenizeEvent();

        this.predicateParameters = new LinkedList<>();
        while (currentWord() != null)
            tokenizePredicateWords();

        return this;
    }

    private void tokenizeAction() {
        String word = currentWord();

        actionName = word;
        actionParameters = new LinkedList<>();

        wordCursor++; // Proceed to expected action arguments

        // <ACTION> foo bar baz [DISPLAYING ..] ON ...
        while (true) {
            word = currentWord();

            if (word.equalsIgnoreCase(EVENT_KEYWORD))
                break;

            wordCursor++;
            actionParameters.add(word);
        }
    }

    private void tokenizeEvent() throws TSLSyntaxError {
        if (!currentWord().equalsIgnoreCase(EVENT_KEYWORD))
            throw new TSLSyntaxError("Expected %s keyword, found word -> %s", EVENT_KEYWORD, currentWord());

        wordCursor++; // Proceed to expected Event name

        StringBuilder eventName = new StringBuilder();
        while (currentWord() != null && !currentWord().equalsIgnoreCase(PREDICATE_KEYWORD)) {
            eventName.append(currentWord()).append(TSLTokenizer.SPACE);
            wordCursor++;
        }

        this.eventName = eventName.toString().trim();

        if (this.eventName.isEmpty())
            throw new TSLSyntaxError("Expected event name after %s keyword", EVENT_KEYWORD);
    }

    public void tokenizePredicateWords() throws TSLSyntaxError {
        if (!currentWord().equalsIgnoreCase(PREDICATE_KEYWORD))
            throw new TSLSyntaxError("Expected %s keyword, but found -> %s", PREDICATE_KEYWORD, currentWord());

        wordCursor++; // Proceed to expected first predicate word

        List<String> predicateWords = new LinkedList<>();
        while (currentWord() != null && !currentWord().equalsIgnoreCase(PREDICATE_KEYWORD)) {
            predicateWords.add(currentWord());
            wordCursor++;
        }

        if (predicateWords.size() == 0)
            throw new TSLSyntaxError("Expected a word after %s keyword", PREDICATE_KEYWORD);

        predicateParameters.add(predicateWords);
    }

    /* ---------------------------- */

    private void validate() throws TSLSyntaxError {
        int indexDisplay = lastIndexOfWord(DISPLAY_KEYWORD);
        int indexEvent = lastIndexOfWord(EVENT_KEYWORD);
        int indexPredicate = lastIndexOfWord(PREDICATE_KEYWORD);

        if (indexEvent == -1)
            throw new TSLSyntaxError("Expected at least one %s statement.", EVENT_KEYWORD);

        if (indexDisplay != -1) {
            if (indexDisplay >= indexEvent)
                throw new TSLSyntaxError("Found %s statement on unexpected location.", DISPLAY_KEYWORD);
        }

        if (indexPredicate != -1) {
            if (indexEvent >= indexPredicate)
                throw new TSLSyntaxError("Found %s statement on unexpected location.", EVENT_KEYWORD);
        }

        if (words.size() < 3)
            throw new TSLSyntaxError("Unexpected length of words -> %d", words.size());
    }

    private int lastIndexOfWord(String word) {
        for (int i = words.size() - 1; i >= 0; i--) {
            if (words.get(i).equalsIgnoreCase(word))
                return i;
        }
        return -1;
    }

} public class TSLSyntaxError extends Exception {

    public static final int RULE_LENGTH_LIMIT = 50;

    private String associatedRule;

    public TSLSyntaxError(String messageFormat, Object... args) {
        super(args.length == 0 ? messageFormat : String.format(messageFormat, args));
    }

    public void setAssociatedRule(String associatedRule) {
        this.associatedRule = associatedRule;
    }

    public void setAssociatedRule(List<String> associatedWords) {
        this.associatedRule = TSLTokenizer.buildRule(associatedWords);
    }

    @Override
    public String getMessage() {
        if (associatedRule != null) {
            return super.getMessage()
                    + " \non rule \u00A7c\u00A7o"
                    + associatedRule();
        }

        return super.getMessage();
    }

    private String associatedRule() {
        if (associatedRule.length() <= RULE_LENGTH_LIMIT)
            return associatedRule;

        return associatedRule.substring(0, RULE_LENGTH_LIMIT / 2).trim() + "......"
                + associatedRule.substring(associatedRule.length() - RULE_LENGTH_LIMIT / 2).trim();
    }

} public class ExpressionEvaluator {

    public static final Pattern EXPRESSION_PATTERN = Pattern.compile("\\$\\{(.*?)\\}");

    public static String replaceExpressions(String input, Function<String, String> evaluator) {
        Matcher matcher = EXPRESSION_PATTERN.matcher(input);
        StringBuilder sb = new StringBuilder();
        int start = 0;

        while (matcher.find()) {
            String expression = matcher.group(1);

            // Append previous part
            sb.append(input, start, matcher.start());
            start = matcher.end();

            // Evaluate and append new value
            sb.append(evaluator.apply(expression));
        }

        // Append trailing chars
        sb.append(input, start, input.length());

        return sb.toString();
    }

    public static String fromArgs(String expression, EventArguments args) {
        if (expression.equals("event"))
            return args.eventName;

        if (expression.equals("message"))
            return JSONUtils.escape(args.message);

        if (expression.equals("message_unescaped"))
            return args.message;

        if (expression.equals("title"))
            return args.rewardTitle;

        if (expression.equals("actor"))
            return args.actorNickname;

        if (expression.equals("streamer"))
            return args.streamerNickname;

        if (expression.equals("amount") && args.donationAmount != 0.0)
            return String.valueOf(args.donationAmount);

        if (expression.equals("amount_i") && args.donationAmount != 0.0)
            return String.valueOf((int) args.donationAmount);

        if (expression.equals("amount_f") && args.donationAmount != 0.0)
            return String.format("%.2f", args.donationAmount);

        if (expression.equals("currency") && args.donationCurrency != null)
            return args.donationCurrency;

        if (expression.equals("months") && args.subscriptionMonths != 0)
            return String.valueOf(args.subscriptionMonths);

        if (expression.equals("tier") && args.subscriptionTier != -1)
            return args.subscriptionTier == 0 ? "Prime" : String.valueOf(args.subscriptionTier);

        if (expression.equals("gifted"))
            return String.valueOf(args.gifted);

        if (expression.equals("viewers") && args.viewerCount != 0)
            return String.valueOf(args.viewerCount);

        if (expression.equals("raiders") && args.raiderCount != 0)
            return String.valueOf(args.raiderCount);

        if (expression.equals("date"))
            return getDateFormat("dd-MM-yyyy", TimeZone.getDefault()).format(new Date());

        if (expression.equals("date_utc"))
            return getDateFormat("dd-MM-yyyy", TimeZone.getTimeZone("UTC")).format(new Date());

        if (expression.equals("time"))
            return getDateFormat("HH:mm:ss", TimeZone.getDefault()).format(new Date());

        if (expression.equals("time_utc"))
            return getDateFormat("HH:mm:ss", TimeZone.getTimeZone("UTC")).format(new Date());

        if (expression.equals("unix"))
            return String.valueOf(Instant.now().getEpochSecond());

        return null;
    }

    private static DateFormat getDateFormat(String format, TimeZone timezone) {
        DateFormat dateFormat = new SimpleDateFormat(format);
        dateFormat.setTimeZone(timezone);
        return dateFormat;
    }

} public class MCPHelpers {

    public static Component fromJsonLenient(String string) {
        return Component.Serializer.fromJsonLenient(string);
    }

    public static Component merge(Component c1, Component c2) {
        MutableComponent deepCopy = c1.copy();
        deepCopy.getSiblings().add(c2);
        return deepCopy;
    }

}
import com.google.gson.JsonArray;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.TextComponent;
import net.minecraft.network.protocol.game.ClientboundSetSubtitleTextPacket;
import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket;
import net.minecraft.network.protocol.game.ClientboundSetTitlesAnimationPacket;
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.phys.Vec3;
import net.programmer.igoodie.twitchspawn.TwitchSpawn;
import net.programmer.igoodie.twitchspawn.configuration.ConfigManager;
import net.programmer.igoodie.twitchspawn.configuration.PreferencesConfig;
import net.programmer.igoodie.twitchspawn.tslanguage.TSLFlowNode;
import net.programmer.igoodie.twitchspawn.tslanguage.event.EventArguments;
import net.programmer.igoodie.twitchspawn.tslanguage.keyword.TSLActionKeyword;
import net.programmer.igoodie.twitchspawn.tslanguage.parser.TSLRuleTokenizer;
import net.programmer.igoodie.twitchspawn.tslanguage.parser.TSLSyntaxError;
import net.programmer.igoodie.twitchspawn.util.ExpressionEvaluator;
import net.programmer.igoodie.twitchspawn.util.MCPHelpers;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
package net.programmer.igoodie.twitchspawn.tslanguage.action;



public abstract class TSLAction implements TSLFlowNode {

    private static final int DEFAULT_FADE_IN_TICKS = 10;
    private static final int DEFAULT_STAY_TICKS = 70;
    private static final int DEFAULT_FADE_OUT_TICKS = 20;

    /**
     * Determines whether action is a reflection or not
     */
    protected ServerPlayer reflectedUser;

    /**
     * Determines whether action should be notified to the player or not
     */
    protected boolean silent = false;

    /**
     * If message field is not null,
     * then the action is overriding the default subtitle message.
     */
    protected JsonArray message;

    /**
     * @return
     */
    public boolean isReflection() {
        return reflectedUser != null;
    }

    /**
     * Splits action part from message part on the display keyword
     *
     * @param words Words to be searched
     * @return Action part
     */
    protected List<String> actionPart(List<String> words) {
        return actionPart(words, TSLRuleTokenizer.DISPLAY_KEYWORD);
    }

    /**
     * Splits action part from message part
     *
     * @param words Words to be searched
     * @param until The word to stop collecting
     * @return Action part
     */
    protected List<String> actionPart(List<String> words, String until) {
        LinkedList<String> actionPart = new LinkedList<>();

        for (String word : words) {
            if (word.equalsIgnoreCase(until))
                break;
            actionPart.add(word);
        }

        return actionPart;
    }

    /**
     * Performs the Action on targeted player
     *
     * @param player Target player of the action
     * @param args   Arguments of the event
     */
    protected abstract void performAction(ServerPlayer player, EventArguments args);

    /**
     * Evaluates a value for given expression
     * that is used in the subtitle.
     *
     * @param expression Expression to be evaluated
     * @param args       Arguments of the event
     * @return Evaluated value of given expression
     */
    protected String subtitleEvaluator(String expression, EventArguments args) {
        return null;
    }

    /**
     * Some TSL actions might need other
     * action's subtitle json. In those cases,
     * subclass modifies this method.
     * (E.g {@link EitherAction})
     *
     * @return Associated subtitle action's name
     */
    protected String associatedSubtitleAction() {
        return TSLActionKeyword.ofClass(getClass());
    }

    /**
     * Processes the action node with given .
     * It fetches player entity with given nickname from the server
     * before trying to perform the action.
     *
     * @param args Arguments of the event (derived from Streamlabs Socket API)
     * @return True if processed successfully
     */
    @Override
    public boolean process(EventArguments args) {
        ServerPlayer player = this.isReflection()
                ? reflectedUser
                : getPlayer(args.streamerNickname);

        if (player == null) {
            TwitchSpawn.LOGGER.info("Player {} is not found. Skipping {} event.",
                    args.streamerNickname, this.getClass().getSimpleName());
            return false;
        }

        // If not silent, notify player
        if (!silent) {
            notifyPlayer(player, args);
        }

        // Perform action for found player
        performAction(player, args);

        TwitchSpawn.LOGGER.info("{} action performed for {}",
                TSLActionKeyword.ofClass(this.getClass()), args.streamerNickname);
        return true;
    }

    @Override
    public boolean willPerform(EventArguments args) {
        return true;
    }

    protected String titleMessage(EventArguments args) {
        String title = this.isReflection()
                ? ConfigManager.TITLES.getTextComponentRaw("reflection")
                : ConfigManager.TITLES.getTextComponentRaw(args.eventName);

        title = replaceExpressions(title, args);

        return title;
    }

    protected String subtitleMessage(EventArguments args) {
        String actionName = associatedSubtitleAction();

        String subtitle = message == null
                ? ConfigManager.SUBTITLES.getTextComponentRaw(actionName)
                : message.toString();

        subtitle = ExpressionEvaluator.replaceExpressions(subtitle, expression -> {
            String messageEvaluation = ExpressionEvaluator.fromArgs(expression, args);

            if (messageEvaluation != null)
                return messageEvaluation;

            // Not a common one, go for action specific routine
            String actionEvaluation = subtitleEvaluator(expression, args);
            return actionEvaluation != null ? actionEvaluation : "${" + expression + "}";
        });

        return subtitle;
    }

    protected String replaceExpressions(String input, EventArguments args) {
        return ExpressionEvaluator.replaceExpressions(input, expression -> {
            String evaluation = ExpressionEvaluator.fromArgs(expression, args);

            if (evaluation != null)
                return evaluation;

            return "${" + expression + "}";
        });
    }

    protected void notifyPlayer(ServerPlayer player, EventArguments args) {
        notifyPlayer(player, titleMessage(args), subtitleMessage(args));
    }

    protected void notifyPlayer(ServerPlayer player, String title, String subtitle) {
        // Form and send sound packet
        float volume = (float) ConfigManager.PREFERENCES.notificationVolume;
        float pitch = ConfigManager.PREFERENCES.notificationPitch == -1
                ? (float) Math.random() : (float) ConfigManager.PREFERENCES.notificationPitch;
        SoundEvent soundLocation = SoundEvents.PLAYER_LEVELUP;
        SoundSource category = SoundSource.MASTER;
        Vec3 position = player.position();
        ClientboundSoundPacket packetSound = new ClientboundSoundPacket(soundLocation, category, position.x, position.y, position.z, volume, pitch);
        player.connection.send(packetSound);

        if (ConfigManager.PREFERENCES.messageDisplay == PreferencesConfig.MessageDisplay.DISABLED)
            return; // Stop here since message displaying is disabled

        // Form text and subtext components
        Component text = MCPHelpers.fromJsonLenient(title);