public class ShardManager
{
    private Integer shardTotal = null;                      // >0 sharding; =0 no sharding
    private ConcurrentMap<Integer, JDA> jdaShards = null;   // used only when sharded
    private JDA jda = null;                                 // used only when unsharded
    private JDABuilder builder;  // builder to be used as the template for starting/restarting shards

    /**
     * Populates the shard manager with initialized JDA shards (if sharding)
     * @param shards a list of integers, where each integer represents a shard ID
     *               The size of the list should never be greater than shardTotal
     * @param shardTotal the total number of shards to create
     */
    public ShardManager(List<Integer> shards, Integer shardTotal)
    {
        // initialize the list of 'Now Playing' games
        this.shardTotal = shardTotal;

        try // connect the bot to the discord API and initialize schedule components
        {
            // basic skeleton of a jda shard
            this.builder = new JDABuilder(AccountType.BOT)
                    .setToken(Main.getBotSettingsManager().getToken())
                    .setStatus(OnlineStatus.ONLINE)
                    .setChunkingFilter(ChunkingFilter.NONE)
                    //.disableIntents(GatewayIntent.GUILD_MEMBERS)
                    //.disableIntents(GatewayIntent.GUILD_BANS)
                    //.disableIntents(GatewayIntent.GUILD_EMOJIS)
                    //.disableIntents(GatewayIntent.GUILD_INVITES)
                    //.disableIntents(GatewayIntent.GUILD_VOICE_STATES)
                    //.disableIntents(GatewayIntent.GUILD_PRESENCES)
                    //.disableIntents(GatewayIntent.GUILD_MESSAGE_TYPING)
                    //.disableIntents(GatewayIntent.DIRECT_MESSAGE_TYPING)
                    //.enableIntents(GatewayIntent.GUILD_MESSAGES)
                    //.enableIntents(GatewayIntent.GUILD_MESSAGE_REACTIONS)
                    //.enableIntents(GatewayIntent.DIRECT_MESSAGES)
                    .setAutoReconnect(true);
            // set now playing status
            List<String> playing = Main.getBotSettingsManager().getNowPlayingList();
            if(!playing.isEmpty())
            {
                this.builder.setActivity(Activity.playing(playing.get(0)));
            }

            // EventListener handles all types of bot events
            this.builder.addEventListeners(new EventListener());

            // previous session queue mechanism was deprecated and has seemingly been replaced with
            //   this SessionController object
            this.builder.setSessionController(new SessionControllerAdapter()
            {
                @Override
                public void appendSession(SessionConnectNode node)
                {
                    System.out.println("[SessionController] Adding SessionConnectNode to Queue!");
                    super.appendSession(node);
                }
            });

            // handle sharding
            if(shardTotal > 0)
            {
                this.jdaShards = new ConcurrentHashMap<>();
                Logging.info(this.getClass(), "Starting shard " + shards.get(0) + ". . .");

                // build the first shard synchronously with Main
                // to block the initialization process until one shard is active
                if(shards.contains(0))
                {
                    // build primary shard (id 0)
                    JDA jda = this.builder
                            .useSharding(0, shardTotal)
                            .build().awaitReady();

                    this.jdaShards.put(0, jda);
                    shards.remove((Object) 0);  // remove '0' (not necessarily the first element of the list)
                }
                else
                {
                    // build whatever the first shard id in the list is
                    // -this ought to occur only if the bot is running on multiple systems
                    // -and the current system is not responsible for the primary (0) shard
                    JDA jda = this.builder
                            .useSharding(shards.get(0), shardTotal)
                            .build().awaitReady();

                    this.jdaShards.put(shards.get(0), jda);
                    shards.remove(shards.get(0));
                }

                // core functionality can now be initialized
                Main.getEntryManager().init();
                Main.getCommandHandler().init();

                // start additional shards
                for (Integer shardId : shards)
                {
                    Logging.info(this.getClass(), "Starting shard " + shardId + ". . .");
                    JDA shard = this.builder
                            .useSharding(shardId, shardTotal)
                            .build();
                    this.jdaShards.put(shardId, shard);
                }
            }
            else // no sharding
            {
                Logging.info(this.getClass(), "Starting bot without sharding. . .");
                this.jda = this.builder
                        .build().awaitReady();
                this.jda.setAutoReconnect(true);

                Main.getEntryManager().init();
                Main.getCommandHandler().init();
            }

            // executor service schedules shard-checking threads
            // restart any shards which are not in a CONNECTED state
            ScheduledExecutorService shardExecutor = Executors.newSingleThreadScheduledExecutor();
            shardExecutor.scheduleWithFixedDelay(() ->
            {
                Logging.info(this.getClass(), "Examining status of shards. . .");
                this.getShards().forEach((shard) ->
                {
                    JDA.Status status = shard.getStatus();
                    if (!status.equals(JDA.Status.CONNECTED))
                    {
                        Integer id = shard.getShardInfo().getShardId();
                        Logging.warn(this.getClass(), "Shard-"+id+" is not connected! ["+status+"]");

                        try
                        {
                            this.restartShard(id);
                        }
                        catch (LoginException | InterruptedException e)
                        {
                            Logging.warn(this.getClass(), "Failed to restart shard! ["+e.getMessage()+"]");
                        }
                        catch (Exception e)
                        {
                            Logging.exception(this.getClass(), e);
                            System.exit(-1);
                        }
                    }
                });
            }, 1, 1, TimeUnit.HOURS);
        }
        catch (Exception e)
        {
            Logging.exception(Main.class, e);
            System.exit(1);
        }
    }

    /**
     * Identifies if the bot is sharding enabled
     * @return bool
     */
    public boolean isSharding()
    {
        return jda == null;
    }

    /**
     * Retrieves the bot JDA if unsharded, otherwise take a shard from the shards collection
     * @return bot JDA
     */
    public JDA getJDA()
    {
        if(this.jda == null)
        {   // take a shard, any shard
            return this.jdaShards.values()
                    .iterator().next();
        }
        return this.jda;
    }

    /**
     * Retrieves the JDA responsible for a guild
     * @param guildId unique (snowflake) guild ID
     * @return JDA responsible for the guild
     */
    public JDA getJDA(String guildId)
    {
        return Main.getShardManager().isSharding() ?
                Main.getShardManager().getShard(guildId) : Main.getShardManager().getJDA();
    }

    /**
     * retrieves a specific JDA shard
     * Should only be used when sharding is enabled
     * @param shardId ID of JDA shard to retrieve
     * @return JDA shard
     */
    public JDA getShard(int shardId)
    {
        return jdaShards.get(shardId);
    }

    /**
     * Retrieves the shard responsible for a guild
     * Should only be used when sharding is enabled
     * @param guildId ID of guild
     * @return JDA shard
     */
    public JDA getShard(String guildId)
    {
        long id = MiscUtil.parseSnowflake(guildId);
        long shardId = (id >> 22) % Main.getBotSettingsManager().getShardTotal();
        return jdaShards.get((int) shardId);
    }


    /**
     * Retrieves all the JDA shards managed by this ShardManager
     * Should not be used when sharding is disabled
     * @return Collection of JDA Objects
     */
    public Collection<JDA> getShards()
    {
        if(this.isSharding())
        {
            return this.jdaShards.values();
        }
        else
        {
            return Collections.singletonList(this.jda);
        }
    }


    /**
     * Retrieves the full list of guilds attached to the application
     * Will not be accurate if the bot is sharded across multiple physical servers
     * @return List of Guild objects
     */
    public List<Guild> getGuilds()
    {
        if(jda == null)
        {
            List<Guild> guilds = new ArrayList<>();
            for(JDA jda : jdaShards.values())
            {
                guilds.addAll(jda.getGuilds());
            }
            return guilds;
        }

        return jda.getGuilds();
    }

    /**
     * Shuts down and recreates a JDA shard
     * @param shardId (Integer) shardID of the JDA shard
     */
    public void restartShard(Integer shardId) throws LoginException, InterruptedException {
        if (this.isSharding())
        {
            // do not handle shards not assigned to the current instance of the bot
            if (this.jdaShards.containsKey(shardId))
            {
                // shutdown the shard
                Logging.info(this.getClass(), "Shutting down shard-" + shardId + ". . .");
                this.getShard(shardId).shutdownNow();
                this.jdaShards.remove(shardId);

                // configure the builder from the template
                Logging.info(this.getClass(), "Starting shard-" + shardId + ". . .");
                JDABuilder shardBuilder;
                if (shardId == 0)
                {
                    shardBuilder = this.builder
                            //.setCorePoolSize(primaryPoolSize)
                            .useSharding(shardId, shardTotal);
                }
                else
                {
                    shardBuilder = this.builder
                            //.setCorePoolSize(secondaryPoolSize)
                            .useSharding(shardId, shardTotal);
                }

                // restart the shard (asynchronously)
                JDA shard = shardBuilder.build();
                this.jdaShards.put(shardId, shard);
            }
        }
        else
        {
            Logging.info(this.getClass(), "Restarting bot JDA. . .");
            this.jda.shutdownNow();
            this.jda = this.builder
                    //.setCorePoolSize(primaryPoolSize)
                    .build().awaitReady();
        }
    }
} public class CommandHandler
{
    private final CommandParser commandParser = new CommandParser();      // parses command strings into containers
    private final ExecutorService executor = Executors.newCachedThreadPool(
            new ThreadFactoryBuilder().setNameFormat("CommandHandler-%d").build()
    ); // thread pool for running commands
    private final RateLimiter rateLimiter = new RateLimiter();
    private final HashMap<String, Command> commands;         // maps Command to invoke string
    private final HashMap<String, Command> adminCommands;    // ^^ but for admin commands
    private boolean initialized;

    public CommandHandler()
    {
        commands = new HashMap<>();
        adminCommands = new HashMap<>();
        initialized = false;
    }

    /**
     * Loads all commands into the command table
     */
    public void init()
    {
        // add bot commands with their lookup name
        commands.put((new HelpCommand()).name(), new HelpCommand());
        commands.put((new InitCommand()).name(), new InitCommand());
        commands.put((new CreateCommand()).name(), new CreateCommand());
        commands.put((new DeleteCommand()).name(), new DeleteCommand());
        commands.put((new EditCommand()).name(), new EditCommand());
        commands.put((new ConfigCommand()).name(), new ConfigCommand());
        commands.put((new TimeZonesCommand()).name(), new TimeZonesCommand());
        commands.put((new TestCommand()).name(), new TestCommand());
        commands.put((new SortCommand()).name(), new SortCommand());
        commands.put((new ListCommand()).name(), new ListCommand());
        commands.put((new GuildCommand()).name(), new GuildCommand());
        commands.put((new OAuthCommand()).name(), new OAuthCommand());
        commands.put((new SkipCommand()).name(), new SkipCommand());
        commands.put((new EventsCommand()).name(), new EventsCommand());
        commands.put((new SchedulesCommand()).name(), new SchedulesCommand());
        commands.put((new AnnouncementsCommand()).name(), new AnnouncementsCommand());
        commands.put((new ManageCommand()).name(), new ManageCommand());
        commands.put((new PurgeCommand().name()), new PurgeCommand());
        commands.put((new DiagnoseCommand()).name(), new DiagnoseCommand());

        // add administrator commands with their lookup name
        adminCommands.put((new GlobalMsgCommand()).name(), new GlobalMsgCommand());
        adminCommands.put((new StatsCommand()).name(), new StatsCommand());
        adminCommands.put((new ReloadSettingsCommand()).name(), new ReloadSettingsCommand());
        adminCommands.put((new ClearLocksCommand()).name(), new ClearLocksCommand());
        adminCommands.put((new ShardsCommand()).name(), new ShardsCommand());
        adminCommands.put((new AvatarCommand()).name(), new AvatarCommand());

        initialized = true;
    }

    /**
     * Processes a MessageReceivedEvent into a command using the command parser
     * and executes the command
     * @param event (MessageReceivedEvent) containing the command
     * @param type (Integer) the type of command, 0 for public, 1 for admin
     * @param prefix (String) the prefix of the command (depends on guild)
     */
    public void handleCommand(MessageReceivedEvent event, Integer type, String prefix)
    {
        // if the command handler has not yet been initialized, send a special error
        if (!initialized)
        {
            String msg = "I have not yet finished booting up! Please try again in a moment.";
            if (event.isFromType(ChannelType.PRIVATE))
            {   // send message to DM channel
                MessageUtilities.sendPrivateMsg(msg, event.getAuthor(), null);
            }
            else
            {   // send message to channel the message was received on
                MessageUtilities.sendMsg(msg, event.getTextChannel(), null);
            }
            return;
        }

        // otherwise handle the received command
        CommandParser.CommandContainer cc = commandParser.parse(event, prefix);
        if (type == 0)
        {
            String identifier = event.getAuthor().getId();
            if (event.isFromType(ChannelType.TEXT))
                identifier +=  event.getGuild().getId();
            if (rateLimiter.check(identifier))
            {
                String alert;
                if (event.getChannelType().equals(ChannelType.PRIVATE))
                {
                    alert = "@" + event.getAuthor().getName() +
                            " [" + event.getAuthor().getId() + "] was rate limited using the '" +
                            cc.invoke + "' command via DM!";
                }
                else
                {
                    alert = "@" + event.getAuthor().getName() +
                            " [" + event.getAuthor().getId() + "] was rate limited on '" +
                            event.getGuild().getName() +"' [" + event.getGuild().getId() + "] using the '" +
                            cc.invoke + "' command!";
                }

                // alert admin
                Logging.warn(this.getClass(), alert);
                User admin = event.getJDA().getUserById(Main.getBotSettingsManager().getAdminId());
                if(admin != null)
                {
                    MessageUtilities.sendPrivateMsg(alert, admin, null);
                }
                return;
            }
            handleGeneralCommand(cc);
        }
        else if (type == 1)
        {
            handleAdminCommand(cc);
        }

    }

    /**
     * Executes a public/general command
     * @param cc (CommandContainer) holding the command information
     */
    private void handleGeneralCommand(CommandParser.CommandContainer cc)
    {
        // if the invoking command appears in commands
        if(commands.containsKey(cc.invoke))
        {
            try // catch any errors which occur while parsing user input
            {
                String err = commands.get(cc.invoke).verify(cc.prefix, cc.args, cc.event);
                // do command action if valid arguments
                if(err.isEmpty())
                {
                    executor.submit( () ->
                    {
                        try
                        {
                            commands.get(cc.invoke).action(cc.prefix, cc.args, cc.event);

                            String info = "Executed command [" + cc.event.getMessage().getContentRaw() +
                                    "] by " + cc.event.getAuthor().getName() + " [" + cc.event.getMessage().getAuthor().getId()+ "]";
                            if(cc.event.getGuild() != null)
                                info += " on " + cc.event.getGuild().getName()+ " [" + cc.event.getGuild().getId() + "]";
                            Logging.cmd(this.getClass(), info);
                        }
                        catch(Exception e)
                        {
                            Logging.exception(commands.get(cc.invoke).getClass(), e);
                        }
                    });
                }
                // otherwise send error message
                else
                {
                    String msg = "**Error** : " + err;
                    MessageUtilities.sendMsg(msg, cc.event.getChannel(), null);
                }
            }
            catch(Exception e)
            {
                User admin = cc.event.getJDA().getUserById(Main.getBotSettingsManager().getAdminId());
                if(admin != null)
                {
                    MessageUtilities.sendPrivateMsg(e.toString(), admin, null);
                }
                Logging.exception(this.getClass(), e);
            }
        }
        else
        {   // command is not a valid command
            String msg = "**" + cc.invoke + "** is not a command!";
            MessageUtilities.sendMsg(msg, cc.event.getChannel(), null);
        }
    }

    /**
     * Executes administrative commands
     * @param cc (CommandContainer) holding the command information
     */
    private void handleAdminCommand(CommandParser.CommandContainer cc)
    {
        // for admin commands
        if (adminCommands.containsKey(cc.invoke))
        {
            try // catch any errors which occur while parsing user input
            {
                String err = adminCommands.get(cc.invoke).verify(cc.prefix + cc.invoke, cc.args, cc.event);

                // do command action if valid arguments
                if (err.equals(""))
                {
                    executor.submit( () ->
                    {
                        try
                        {
                            adminCommands.get(cc.invoke).action(cc.prefix + cc.invoke, cc.args, cc.event);
                        }
                        catch(Exception e)
                        {
                            Logging.exception(adminCommands.get(cc.invoke).getClass(), e);
                        }
                    });
                }
            }
            catch(Exception e)
            {
                User admin = cc.event.getJDA().getUserById(Main.getBotSettingsManager().getAdminId());
                if(admin != null)
                {
                    MessageUtilities.sendPrivateMsg(e.getLocalizedMessage(), admin, null);
                }
                Logging.exception(this.getClass(), e);
            }
        }
    }

    public Collection<Command> getCommands()
    {
        return commands.values();
    }

    public Collection<String> getCommandNames()
    {
        return commands.keySet();
    }

    public Command getCommand(String invoke)
    {
        // check if command exists, if so return it
        // otherwise return null
        return commands.getOrDefault(invoke, null);
    }

    public void putSync()
    {
        commands.put("sync", new SyncCommand());
    }
} public class Driver
{
    private MongoDatabase db;

    public void init()
    {
        // for a connection to the Mongo database
        // connection properties should be configured via the URI used in the bot toml file
        MongoClient mongoClient = new MongoClient(new MongoClientURI(Main.getBotSettingsManager().getMongoURI()));
        db = mongoClient.getDatabase("saberDB");

        // schedule a thread to prune disconnected guild, schedules, and events from the database
        ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
        executor.scheduleAtFixedRate(new Pruner(), 12, 12, TimeUnit.HOURS);
    }

    public MongoCollection<Document> getScheduleCollection()
    {
        return db.getCollection("schedules");
    }

    public MongoCollection<Document> getEventCollection()
    {
        return db.getCollection("events");
    }

    public MongoCollection<Document> getGuildCollection()
    {
        return db.getCollection("guilds");
    }
} public class CalendarConverter
{

    public void init()
    {
        try
        {
            // Build a new authorized API client service.
            // Note: Do not confuse this class with the
            //   com.google.api.services.calendar.model.Calendar class.
            // if this fails, do not enable schedule synchronization
            Calendar service = GoogleAuth.getCalendarService(GoogleAuth.authorize());

            Main.getCommandHandler().putSync(); // enable the sync command
            Main.getScheduleManager().init(); // start the schedule sync timer
        }
        catch( IOException e )
        {
            Logging.warn(this.getClass(), e.getMessage());
        }
        catch( Exception e )
        {
            Logging.exception(this.getClass(), e);
        }
    }


    /**
     * verifies that an address url is a valid Calendar address Saber can
     * sync with
     * @param address (String) google calendar address
     * @param service connected calendar service with user credentials
     * @return (boolean) true if valid
     */
    public boolean checkValidAddress(String address, Calendar service)
    {
        try
        {
            service.events().list(address)
                    .setTimeMin(new DateTime(ZonedDateTime.now().format(EventRecurrence.RFC3339_FORMATTER)))
                    .setTimeMax(new DateTime(ZonedDateTime.now().plusDays(7).format(EventRecurrence.RFC3339_FORMATTER)))
                    .setOrderBy("startTime")
                    .setSingleEvents(true)
                    .setMaxResults(Main.getBotSettingsManager().getMaxEntries())
                    .execute();

            return true;
        }
        catch(Exception e)
        {
            return false;
        }

    }


    /**
     * exports a discord schedule to a google calendar Calendar
     * @param address (String) valid address of calendar
     * @param channel (MessageChannel) channel to sync with
     * @param service connected calendar service with user credentials
     * @return boolean indicating if the export was successful
     */
    public boolean exportCalendar(String address, TextChannel channel, Calendar service)
    {
        if(channel == null || address == null) return false;
        if(!Main.getScheduleManager().isSchedule(channel.getId()))
        {   // safety check to insure exportCalendar is being applied to a valid channel
            return false;
        }

        Integer failure[] = { 0 };
        Collection<ScheduleEntry> entries = Main.getEntryManager().getEntriesFromChannel(channel.getId());
        entries.forEach(se->
        {
            // compose the event's description
            String description = String.join("\n", se.getComments())+"\n";
            if (se.getImageUrl() != null)     description += "\nimage: " + se.getImageUrl();
            if (se.getThumbnailUrl() != null) description += "\nthumbnail: " + se.getThumbnailUrl();
            if (se.getDeadline() != null)     description += "\ndeadline: " + se.getDeadline().format(DateTimeFormatter.ISO_LOCAL_DATE);
            if (se.getTitleUrl() != null)     description += "\nurl: " + se.getTitleUrl();
            for (String key : se.getRsvpLimits().keySet())
            {
                description += "\nlimit: " + key + " " + se.getRsvpLimit(key);
            }

            // setup the event's start and end times
            ZoneId zone = Main.getScheduleManager().getTimeZone(channel.getId());
            EventDateTime start = new EventDateTime()
                    .setDateTime(new DateTime(Date.from(se.getStart().toInstant())))
                    .setTimeZone(zone.getId());
            EventDateTime end = new EventDateTime()
                    .setDateTime(new DateTime(Date.from(se.getEnd().toInstant())))
                    .setTimeZone(zone.getId());
            EventDateTime origStart = new EventDateTime()
                    .setDateTime(new DateTime(Date.from(se.getRecurrence().getOriginalStart().toInstant())))
                    .setTimeZone(zone.getId());

            // create the event
            Event event = new Event();
            event.setDescription(description)
                    .setSummary(se.getTitle())
                    .setRecurrence(se.getRecurrence().toRFC5545())
                    .setStart(start)
                    .setEnd(end)
                    .setOriginalStartTime(origStart);

            try // interface with google calendar api
            {
                boolean differentCalendars = false;
                if (Main.getScheduleManager().getAddress(channel.getId()).equalsIgnoreCase(address)) differentCalendars = true;
                if (se.getGoogleId() != null && differentCalendars)
                {
                    event.setId(se.getGoogleId());
                    service.events().update(address, se.getGoogleId(), event).execute();
                }
                else
                {
                    Event e = service.events().insert(address, event).execute();
                    Main.getEntryManager().updateEntry(se.setGoogleId(e.getId()), false);
                }
            }
            catch (Exception e)
            {
                Logging.warn(this.getClass(), "Unable to export calendar:" +e.getMessage());
                failure[0] = 1;
            }
        });
        return failure[0]==0;
    }


    /**
     * Purges a schedule from entries and adds events (after conversion)
     * from the next 7 day span of a Google Calendar
     * @param address (String) valid address of calendar
     * @param channel (MessageChannel) channel to sync with
     * @param service connected calendar service with user credentials
     */
    public void importCalendar(String address, TextChannel channel, Calendar service)
    {
        // sanity checks
        if(channel == null || address == null) return;
        if(!Main.getScheduleManager().isSchedule(channel.getId())) return;

        // query the google calendar address for the list of events
        Events events;
        try
        {
            ZonedDateTime min = ZonedDateTime.now();
            ZonedDateTime max = min.plusDays(Main.getScheduleManager().getSyncLength(channel.getId()));
            events = service.events().list(address)
                    .setTimeMin(new DateTime(min.format(EventRecurrence.RFC3339_FORMATTER)))
                    .setTimeMax(new DateTime(max.format(EventRecurrence.RFC3339_FORMATTER)))
                    .setOrderBy("startTime")
                    .setSingleEvents(true)
                    .setMaxResults(Main.getBotSettingsManager().getMaxEntries())
                    .execute();
        }
        catch (Exception e)
        {
            Logging.exception(this.getClass(), e);
            return;
        }

        try // convert the list of Google Events into discord event entries
        {
            channel.sendTyping().queue(); // send 'is typing' while the sync is in progress

            /* lock the schedule for syncing; schedule is unlocked in finally block */
            Main.getScheduleManager().lock(channel.getId());

            // change the zone to match the calendar
            // only if the zone has not been manually set for that schedule
            ZoneId zone = ZoneId.of( events.getTimeZone() );
            Boolean syncZone = Main.getDBDriver().getScheduleCollection().find(eq("_id", channel.getId()))
                    .first().getBoolean("timezone_sync", false);
            if(syncZone)
            {
                Main.getScheduleManager().setTimeZone( channel.getId(), zone );
            }

            // a set of all unique (not child of a recurring event) events
            HashSet<String> uniqueEvents = new HashSet<>();

            // process events
            for(Event event : events.getItems())
            {
                try // convert the list of Google Events into discord event entries
                {

                    channel.sendTyping().queue();   // continue to send 'is typing'

                    // if the unique google event ID does not appear in the already processed events
                    // convert the event and add it to the schedule
                    String recurrenceId = event.getRecurringEventId();
                    String googleId = recurrenceId==null ? event.getId() : recurrenceId;
                    if(!uniqueEvents.contains(googleId))
                    {
                        // declare and initialize event parameters
                        ZonedDateTime start, end;
                        String title;
                        ArrayList<String> comments      = new ArrayList<>();
                        int repeat                      = 0;
                        ZonedDateTime expire            = null;
                        String imageUrl                 = null;
                        String thumbnailUrl             = null;
                        ZonedDateTime rsvpDeadline      = null;
                        String titleUrl                 = null;
                        Map<String, Integer> rsvpLimits = new HashMap<>();


                        if(event.getStart().getDateTime() == null)
                        {   /* parse start and end dates for all day events */
                            start = ZonedDateTime.of(
                                    LocalDate.parse(event.getStart().getDate().toStringRfc3339()),
                                    LocalTime.MIN,
                                    zone);
                            end = ZonedDateTime.of(
                                    LocalDate.parse(event.getEnd().getDate().toStringRfc3339()),
                                    LocalTime.MIN,
                                    zone);
                        } else
                        {   /* parse start and end times for normal events */
                            start = ZonedDateTime.parse(event.getStart().getDateTime().toStringRfc3339(), EventRecurrence.RFC3339_FORMATTER)
                                    .withZoneSameInstant(zone);
                            end = ZonedDateTime.parse(event.getEnd().getDateTime().toStringRfc3339(), EventRecurrence.RFC3339_FORMATTER)
                                    .withZoneSameInstant(zone);
                        }

                        // get event title
                        if(event.getSummary() == null) title = "(No title)";
                        else title = event.getSummary();

                        // process event description into event comments or other settings
                        if (event.getDescription() != null)
                        {
                            // process the description line by line
                            String description = HTMLStripper.cleanDescription(
                                    event.getDescription().replace("\n", "<br>"));
                            for (String comment : description.split("\n"))
                            {
                                comment = comment.trim();
                                Logging.info(this.getClass(), comment);
                                String lowerCase = comment.toLowerCase();

                                // image
                                if (lowerCase.startsWith("image:"))
                                {
                                    String[] tmp = comment.split(":",2); // split to limit:
                                    if(tmp.length > 1)
                                    {
                                        imageUrl = tmp[1].trim().replaceAll(" ","");
                                        if (!VerifyUtilities.verifyUrl(imageUrl)) imageUrl = null;
                                    }
                                }
                                // thumbnail
                                else if (lowerCase.startsWith("thumbnail:"))
                                {
                                    String[] tmp = comment.split(":",2);
                                    if(tmp.length > 1)
                                    {
                                        thumbnailUrl = tmp[1].trim().trim().replaceAll(" ","");
                                        if(!VerifyUtilities.verifyUrl(thumbnailUrl)) thumbnailUrl = null;
                                    }
                                }
                                // limit
                                else if (lowerCase.startsWith("limit:"))
                                {
                                    String[] tmp = comment.split(":",2); // split to limit:
                                    if(tmp.length > 1)
                                    {
                                        String[] str = tmp[1].trim().split("[^\\S\n\r]+"); // split into white space separated segments
                                        if(str.length >= 2)
                                        {
                                            // rebuild the rsvp group name
                                            StringBuilder name = new StringBuilder();
                                            for(int i=0; i<str.length-1; i++)
                                            {
                                                name.append(str[i]);
                                                if(i != str.length-2) name.append(" ");
                                            }

                                            // parse the limit
                                            Integer limit = -1;
                                            if(VerifyUtilities.verifyInteger(str[str.length-1]))
                                                limit = Integer.parseInt(str[str.length-1]);

                                            rsvpLimits.put(name.toString(), limit);
                                        }
                                    }

                                }
                                // title url
                                else if (lowerCase.startsWith("url:"))
                                {
                                    String[] tmp = comment.split(":",2);
                                    if(tmp.length > 1 && VerifyUtilities.verifyUrl(tmp[1].trim().replaceAll(" ","")))
                                        titleUrl = tmp[1].trim().replaceAll(" ","");
                                }
                                // deadline
                                else if (lowerCase.startsWith("deadline:"))
                                {
                                    String tmp = lowerCase.replace("deadline:","")
                                            .trim().replaceAll(" ","");
                                    if(VerifyUtilities.verifyDate(tmp))
                                        rsvpDeadline = ZonedDateTime.of(ParsingUtilities
                                                .parseDate(tmp, zone), LocalTime.MAX, zone);
                                }
                                // plaintext comment
                                else if(!comment.trim().isEmpty())
                                {
                                    comments.add(comment);
                                }
                            }
                        }

                        // get the event recurrence information
                        List<String> recurrence = event.getRecurrence();
                        if(recurrenceId != null)
                            recurrence = service.events().get(address, recurrenceId).execute().getRecurrence();

                        // parse the event recurrence information
                        if(recurrence != null)
                        {   // determine the start date
                            ZonedDateTime dtStart = event.getOriginalStartTime() == null ? start :     // if orig is null, use start
                                        (event.getOriginalStartTime().getDateTime() == null ? start :
                                            ZonedDateTime.parse(event.getOriginalStartTime()
                                                    .getDateTime().toStringRfc3339(), EventRecurrence.RFC3339_FORMATTER)
                                                    .withZoneSameInstant(zone));
                            EventRecurrence eventRecurrence = new EventRecurrence(recurrence, dtStart);
                            expire = eventRecurrence.getExpire();
                            repeat = eventRecurrence.getRepeat();
                        }

                        // if the google event already exists as a saber event on the schedule, update it
                        // otherwise add as a new saber event
                        Document doc = Main.getDBDriver().getEventCollection()
                                .find(and(
                                        eq("channelId", channel.getId()),
                                        eq("googleId", googleId))).first();

                        // should the event be flagged as already started?
                        boolean hasStarted = start.isBefore(ZonedDateTime.now());

                        if(doc != null && (new ScheduleEntry(doc)).getMessageObject() != null)
                        {   /* update an existing event */
                            ScheduleEntry se = (new ScheduleEntry(doc))
                                    .setTitle(title)
                                    .setStart(start)
                                    .setEnd(end)
                                    .setRepeat(repeat)
                                    .setGoogleId(googleId)
                                    .setExpire(expire)
                                    .setStarted(hasStarted)
                                    .setComments(comments)
                                    .setLocation(event.getLocation());

                            // set special attributes if not null
                            if (titleUrl!=null)
                                se.setTitleUrl(titleUrl);
                            if (imageUrl!=null)
                                se.setImageUrl(imageUrl);
                            if (thumbnailUrl!=null)
                                se.setThumbnailUrl(thumbnailUrl);
                            if (rsvpDeadline!=null)
                                se.setRsvpDeadline(rsvpDeadline);
                            if (rsvpLimits.keySet().size()>0)
                                se.setRsvpLimits(rsvpLimits);

                            // update event reminders using schedule default settings
                            se.reloadReminders(Main.getScheduleManager().getReminders(se.getChannelId()))
                                    .reloadEndReminders(Main.getScheduleManager().getEndReminders(se.getChannelId()))
                                    .regenerateAnnouncementOverrides();

                            Main.getEntryManager().updateEntry(se, false);
                        }
                        else
                        {   /* create a new event */
                            ScheduleEntry se = (new ScheduleEntry(channel, title, start, end))
                                    .setTitleUrl(titleUrl!=null ? titleUrl:event.getHtmlLink())
                                    .setRepeat(repeat)
                                    .setGoogleId(googleId)
                                    .setExpire(expire)
                                    .setStarted(hasStarted)
                                    .setComments(comments)
                                    .setLocation(event.getLocation());

                            // set special attributes if not null
                            if (imageUrl!=null)
                                se.setImageUrl(imageUrl);
                            if (thumbnailUrl!=null)
                                se.setThumbnailUrl(thumbnailUrl);
                            if (rsvpDeadline!=null)
                                se.setRsvpDeadline(rsvpDeadline);
                            if (rsvpLimits.keySet().size()>0)
                                se.setRsvpLimits(rsvpLimits);

                            Main.getEntryManager().newEntry(se, false);
                        }

                        // add to google ID to unique event mapping
                        uniqueEvents.add(recurrenceId==null ? event.getId() : recurrenceId);
                    }
                }
                catch(Exception e)
                {
                    Logging.exception(this.getClass(), e);
                }
            }

            // purge channel of all entries on schedule that aren't in uniqueEvents
            Bson query = and(   eq("channelId", channel.getId()),
                                nin("googleId", uniqueEvents));
            Main.getDBDriver().getEventCollection().find(query)
                    .forEach((Consumer<? super Document>) document ->
                    {
                        ScheduleEntry entry = Main.getEntryManager().getEntry((Integer) document.get("_id"));
                        Message msg = entry.getMessageObject();
                        if( msg==null ) return;

                        Main.getEntryManager().removeEntry((Integer) document.get("_id"));
                        MessageUtilities.deleteMsg(msg, null);
                    });

            // set channel topic
            JDA jda = Main.getShardManager().getJDA(channel.getGuild().getId());
            String calLink = "https://calendar.google.com/calendar/embed?src=" + address;
            boolean hasPerms = channel.getGuild().getMember(jda.getSelfUser())
                    .hasPermission(channel, Permission.MANAGE_CHANNEL);
            if(hasPerms) channel.getManager().setTopic(calLink).queue();
        }
        catch(Exception e)
        {
            Logging.exception(this.getClass(), e);
        }
        finally
        {
            Main.getScheduleManager().unlock(channel.getId()); // syncing done, unlock the channel
        }

        // auto-sort
        EntryManager.autoSort(true, channel.getId());
    }
} public class EntryManager
{
    private Random generator;
    public enum type { PROCESS, UPDATE1, UPDATE2, UPDATE3 }

    /** construct EntryManager and seed random from OS random source */
    public EntryManager()
    {   // use system random to seed to avoid repeat seed values on bot restart
        this.generator = new Random(new SecureRandom().nextLong());
    }

    /**
     * creates the scheduledExecutor thread pool and starts schedule timers which
     * check for expired entry timers and adjust the message display timer
     */
    public void init()
    {
        /* thread to process events */
        ScheduledExecutorService announcementScheduler = Executors.newSingleThreadScheduledExecutor();
        announcementScheduler.scheduleWithFixedDelay(
                new EntryProcessor(type.PROCESS),
                15, 15, TimeUnit.SECONDS);

        // scheduler for threads to adjust entry display timers
        ScheduledExecutorService updateDisplayScheduler = Executors.newSingleThreadScheduledExecutor();
        // updates events with times >24h
        updateDisplayScheduler.scheduleWithFixedDelay(
                new EntryProcessor(type.UPDATE3),
                12, 6, TimeUnit.HOURS);
        // updates events with times >1 hour (but <24h)
        updateDisplayScheduler.scheduleWithFixedDelay(
                new EntryProcessor(type.UPDATE2),
                30, 15, TimeUnit.MINUTES);
        // update events with times <1 hour
        //updateDisplayScheduler.scheduleWithFixedDelay(
        //        new EntryProcessor(type.UPDATE1),
        //        5, 3, TimeUnit.MINUTES);
    }

    /**
     * Create a new entry on a schedule
     * @param se (ScheduleEntry) the base ScheduleEntry object to use
     */
    public Integer newEntry(ScheduleEntry se, boolean sort)
    {
        // identify which shard is responsible for the schedule
        String guildId = se.getGuildId();
        String channelId = se.getChannelId();
        JDA jda = Main.getShardManager().getJDA(guildId);

        // generate the reminders
        se.reloadReminders(Main.getScheduleManager().getReminders(se.getChannelId()))
                .reloadEndReminders(Main.getScheduleManager().getEndReminders(se.getChannelId()));

        // process expiration date
        Date expire = null;
        if (se.getExpire() != null)
        {
            expire = Date.from(se.getExpire().toInstant());
        }

        // process deadline
        Date deadline = null;
        if (se.getDeadline() != null)
        {
            deadline = Date.from(se.getDeadline().toInstant());
        }

        // is rsvp enabled on the channel set empty rsvp lists
        if (Main.getScheduleManager().isRSVPEnabled(se.getChannelId()) )
        {
            for (String type : Main.getScheduleManager().getRSVPOptions(se.getChannelId()).values())
            {
                se.setRsvpMembers(type, new ArrayList<>());
            }
        }

        // generate event display message
        se.setId(this.newId());
        Message message = MessageGenerator.generate(se);

        // send message to schedule
        TextChannel channel = jda.getTextChannelById(channelId);
        Date finalExpire = expire;
        Date finalDeadline = deadline;
        MessageUtilities.sendMsg(message, channel, msg ->
        {
            try
            {
                // add reaction options if rsvp is enabled
                if (Main.getScheduleManager().isRSVPEnabled(channelId))
                {
                    Map<String, String> map = Main.getScheduleManager().getRSVPOptions(channelId);
                    addRSVPReactions(map, Main.getScheduleManager().getRSVPClear(channelId), msg, se);
                }

                // add new document
                Document entryDocument =
                        new Document("_id", se.getId())
                                .append("title", se.getTitle())
                                .append("start", Date.from(se.getStart().toInstant()))
                                .append("end", Date.from(se.getEnd().toInstant()))
                                .append("comments", se.getComments())
                                .append("recurrence", se.getRepeat())
                                .append("reminders", se.getReminders())
                                .append("end_reminders", se.getEndReminders())
                                .append("url", se.getTitleUrl())
                                .append("hasStarted", se.hasStarted())
                                .append("messageId", msg.getId())
                                .append("channelId", se.getChannelId())
                                .append("googleId", se.getGoogleId())
                                .append("rsvp_members", se.getRsvpMembers())
                                .append("rsvp_limits", se.getRsvpLimits())
                                .append("image", se.getImageUrl())
                                .append("thumbnail", se.getThumbnailUrl())
                                .append("orig_start", Date.from(se.getRecurrence().getOriginalStart().toInstant()))
                                .append("count", se.getRecurrence().getCount())
                                .append("start_disabled", false)
                                .append("end_disabled", false)
                                .append("reminders_disabled", false)
                                .append("expire", finalExpire)
                                .append("deadline", finalDeadline)
                                .append("guildId", se.getGuildId())
                                .append("location", se.getLocation())
                                .append("description", se.getDescription())
                                .append("color", se.getColor());

                Main.getDBDriver().getEventCollection().insertOne(entryDocument);

                // auto-sort the schedule if configured
                autoSort(sort, channelId);
            }
            catch(Exception e)
            {
                Logging.exception(EntryManager.class, e);
            }
        });

        return se.getId();
    }

    /**
     * Update an entry with a new configuration
     * All schedule entry parameters should be filled.
     * The ID of the ScheduleEntry must not have been changed.
     * @param se (ScheduleEntry) the new schedule entry object
     * @return true if successful, otherwise false
     */
    public boolean updateEntry(ScheduleEntry se, boolean sort)
    {
        // process expiration date
        Date expire = null;
        if (se.getExpire() != null)
        {
            expire = Date.from(se.getExpire().toInstant());
        }

        // process deadline
        Date deadline = null;
        if (se.getDeadline() != null)
        {
            deadline = Date.from(se.getDeadline().toInstant());
        }

        // update message display
        Date finalExpire = expire;
        Date finalDeadline = deadline;

        try
        {
            // replace whole document
            Document entryDocument =
                    new Document("_id", se.getId())
                            .append("title", se.getTitle())
                            .append("start", Date.from(se.getStart().toInstant()))
                            .append("end", Date.from(se.getEnd().toInstant()))
                            .append("comments", se.getComments())
                            .append("recurrence", se.getRepeat())
                            .append("reminders", se.getReminders())
                            .append("end_reminders", se.getEndReminders())
                            .append("url", se.getTitleUrl())
                            .append("hasStarted", se.hasStarted())
                            .append("messageId", se.getMessageId())
                            .append("channelId", se.getChannelId())
                            .append("googleId", se.getGoogleId())
                            .append("rsvp_members", se.getRsvpMembers())
                            .append("rsvp_limits", se.getRsvpLimits())
                            .append("start_disabled", se.isQuietStart())
                            .append("end_disabled", se.isQuietEnd())
                            .append("reminders_disabled", se.isQuietRemind())
                            .append("expire", finalExpire)
                            .append("orig_start", Date.from(se.getRecurrence().getOriginalStart().toInstant()))
                            .append("count", se.getRecurrence().getCount())
                            .append("image", se.getImageUrl())
                            .append("thumbnail", se.getThumbnailUrl())
                            .append("deadline", finalDeadline)
                            .append("guildId", se.getGuildId())
                            .append("announcements", new ArrayList<>(se.getAnnouncements()))
                            .append("announcement_dates", se.getAnnouncementDates())
                            .append("announcement_times", se.getAnnouncementTimes())
                            .append("announcement_messages", se.getAnnouncementMessages())
                            .append("announcement_targets", se.getAnnouncementTargets())
                            .append("location", se.getLocation())
                            .append("description", se.getDescription())
                            .append("color", se.getColor());

            UpdateResult res = Main.getDBDriver().getEventCollection()
                    .replaceOne(eq("_id", se.getId()), entryDocument);
            if (!res.wasAcknowledged())
            {
                Logging.warn(this.getClass(), "Attempt to update '"+se.getTitle()+"' was unacknowledged!");
                return false; // return false, might result in skipped announcement or other issues
            }

            // update the event message with the information changes (if any)
            // this may (is) over-aggressive, however it is convenient and easier to manage
            // (ie. avoid updating the display in other code sections)
            se.reloadDisplay();

            // auto-sort the schedule if configured
            // may be necessary if the start time was changed
            autoSort(sort, se.getChannelId());
            return true;
        }
        catch(Exception e)
        {
            Logging.exception(EntryManager.class, e);
            return false;
        }
    }

    /**
     * update the event's database entry's hasStarted flag to true
     * @param se schedule entry which has started
     * @return true if successful, otherwise false
     */
    public boolean startEvent(ScheduleEntry se)
    {
        try
        {
            UpdateResult res = Main.getDBDriver().getEventCollection()
                    // using the 'update many' call seems to work more effectively
                    .updateMany(eq("_id", se.getId()), set("hasStarted", true));
            if (!res.wasAcknowledged())
            {
                Logging.warn(this.getClass(), "Attempt to update '"+se.getTitle()+"' was unacknowledged!");
                return false; // might result in skipped announcements or other issues
            }
            se.reloadDisplay();
            return true;
        }
        catch(MongoException e)
        {
            Logging.exception(this.getClass(), e);
            return false;
        }
    }

    /**
     * adds rsvp reactions to a message
     * @param options (Map) mapping of rsvp emojis to rsvp names
     * @param clearEmoji unicode emoji to use for the clear action (empty if clear is disabled)
     * @param message (Message) discord message object
     * @param se the schedule entry object
     */
    public static void addRSVPReactions
    (Map<String, String> options, String clearEmoji, Message message, ScheduleEntry se)
    {   // add all RSVP emoji's
        for(String emoji : options.keySet())
        {   // don't add the reaction for categories with 0 limit
            if (se.getRsvpLimit(options.get(emoji)) != 0)
                addRSVPReaction(emoji, message);
        }
        // add clear emoji if configured
        if(!clearEmoji.isEmpty())
        {
            addRSVPReaction(clearEmoji, message);
        }
    }

    /**
     * helper to addRSVPReactions(..)
     * @param emoji string emoticon, or emote ID
     * @param message message object to react to
     */
    private static void addRSVPReaction(String emoji, Message message)
    {
        if (EmojiManager.isEmoji(emoji))
        {
            message.addReaction(emoji).queue();
        }
        else
        {
            Emote emote;
            for(JDA shard : Main.getShardManager().getShards())
            {
                emote = shard.getEmoteById(emoji);
                if(emote != null)
                {
                    message.addReaction(emote).queue();
                    break;
                }
            }
        }
    }


    /**
     * Handles automatic sorting of a channel
     * @param sort (boolean) should the channel be sorted?
     * @param channelId (String) ID of the channel to sort
     */
    public static void autoSort(boolean sort, String channelId)
    {
        if(sort)
        {
            int sortType = Main.getScheduleManager().getAutoSort(channelId);
            if(sortType == 1)
            {
                Main.getScheduleManager().sortSchedule(channelId, false);
            }
            if(sortType == 2)
            {
                Main.getScheduleManager().sortSchedule(channelId, true);
            }
        }
    }

    /**
     * removes an entry by id from the db
     * @param entryId (Integer) ID of event entry
     * @return true if the remove was acknowledged (safe), otherwise false
     */
    public boolean removeEntry(Integer entryId)
    {
        DeleteResult res = Main.getDBDriver().getEventCollection()
                .deleteMany(eq("_id", entryId));
        return res.wasAcknowledged();
    }

    /**
     * regenerates the displayed Message text for a schedule entry
     * @param eId integer Id
     */
    public void reloadEntry(Integer eId)
    {
        ScheduleEntry se = getEntry(eId);
        if(se == null) return;
        se.reloadDisplay();
    }

    /**
     * generates a new ID randomly from a 32bit space
     * @return (Integer) new, unused id
     */
    private Integer newId()
    {
        // try first to use the requested Id
        Integer ID;
        ID = this.generator.nextInt(Integer.MAX_VALUE);

        // if the Id is in use, generate a new one until a free one is found
        Bson document = Main.getDBDriver().getEventCollection().find(eq("_id", ID)).first();
        while (document != null)
        {
            ID = this.generator.nextInt();
            document = Main.getDBDriver().getEventCollection().find(eq("_id", ID)).first();
        }

        return ID;
    }

    /**
     * Finds an event and returns it's newly created class object if it exists
     * @param entryId (Integer) event ID
     * @return (EventEntry) of event if exists, otherwise null
     */
    public ScheduleEntry getEntry(Integer entryId)
    {
        Document entryDocument = Main.getDBDriver().getEventCollection()
                .find(eq("_id", entryId)).first();
        if (entryDocument != null)
        {
            return new ScheduleEntry(entryDocument);
        }
        else    // otherwise return null
        {
            return null;
        }
    }

    /**
     * Finds an event by id that also belongs to a specific guild
     * @param entryId (Integer) event ID
     * @param guildId (String) guild ID
     * @return (EventEntry) if exists, otherwise null
     */
    public ScheduleEntry getEntryFromGuild(Integer entryId, String guildId)
    {
        Document entryDocument = Main.getDBDriver().getEventCollection()
                .find(and(eq("_id", entryId), eq("guildId",guildId))).first();
        if (entryDocument != null)
        {
            return new ScheduleEntry(entryDocument);
        }
        else    // otherwise return null
        {
            return null;
        }
    }

    /**
     * Retrieves all entries associated with a guild
     * @param guildId snowflake ID of guild
     * @return all active entries
     */
    public Collection<ScheduleEntry> getEntriesFromGuild(String guildId)
    {
        MongoIterable<ScheduleEntry> entries = Main.getDBDriver().getEventCollection()
                .find(eq("guildId", guildId)).map(ScheduleEntry::new);
        return entries.into(new ArrayList<>());
    }

    /**
     * Retrieves all entries on a channel
     * @param channelId snowflake ID of channel
     * @return all active entries
     */
    public Collection<ScheduleEntry> getEntriesFromChannel(String channelId)
    {
        MongoIterable<ScheduleEntry> entries = Main.getDBDriver().getEventCollection()
                .find(eq("channelId", channelId)).map(ScheduleEntry::new);
        return entries.into(new ArrayList<>());
    }

    /**
     * has a guild reached it's maximum event limit?
     * @param gId (String) guild ID
     * @return (boolean)
     */
    public boolean isLimitReached(String gId)
    {
        long count = Main.getDBDriver().getEventCollection().count(eq("guildId",gId));
        return Main.getBotSettingsManager().getMaxEntries() < count;
    }
} public class BotSettingsManager
{
    private static final String FILENAME = "saber.toml";
    private BotSettings settings;

    /**
     * attempts to read the settings file,
     * on failure, generate a new default file
     */
    public BotSettingsManager()
    {
        InputStream input = null;
        try
        {
            input = new FileInputStream("./" + FILENAME);
            settings = (new Toml()).read(input).to(BotSettings.class);
        }
        catch (IOException ex)
        {
            this.generateFile();
            settings = null;
        }
        finally
        {
            if (input != null)
            {
                try
                {
                    input.close();
                }
                catch (IOException e)
                {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * write out to file a new toml file with default settings
     */
    private void generateFile()
    {
        OutputStream output = null;
        try
        {
            output = new FileOutputStream(FILENAME);
            (new TomlWriter()).write(new BotSettings(), output);
        }
        catch (IOException io)
        {
            io.printStackTrace();
        }
        catch (Exception e)
        {
            Logging.exception(this.getClass(), e);
        }
        finally
        {
            if (output != null)
            {
                try
                {
                    output.close();
                }
                catch (IOException e)
                {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * reload settings from toml file
     */
    public void reloadSettings()
    {
        InputStream input = null;
        try
        {
            input = new FileInputStream("./" + FILENAME);
            settings = (new Toml()).read(input).to(BotSettings.class);
        }
        catch (Exception e)
        {
            Logging.exception(this.getClass(), e);
        }
        finally
        {
            if (input != null)
            {
                try
                {
                    input.close();
                }
                catch (IOException e)
                {
                    e.printStackTrace();
                }
            }
        }
    }

    /**
     * Class to store bot settings information
     */
    private class BotSettings
    {
        String discord_token;
        String web_token;
        String mongodb;
        String prefix;
        String admin_prefix;
        String admin_id;
        String bot_control_chan;
        int max_entries;
        int max_schedules;
        String default_announce_chan;
        String default_announce_msg;
        String default_clock_format;
        String default_time_zone;
        List<String> nowplaying_list;
        Set<String> blacklist;
        long cooldown_threshold;
        String rsvp_yes;
        String rsvp_no;
        String rsvp_clear;
        String google_service_key;
        String google_oauth_secret;
        int log_level;
        List<Integer> shards;
        int shard_total;

        BotSettings()
        {
            discord_token = "BOT_TOKEN";
            web_token = null;
            google_service_key = "./saber-g-id.json";
            google_oauth_secret = "./oath2-secret";
            mongodb = "mongodb://localhost:27017/?w=majority";
            log_level = 4;

            shards = new ArrayList<>();
            shard_total = 0;

            prefix = "!";
            admin_prefix = "s.";
            admin_id = "ADMIN_USER_ID";
            max_entries = 25;
            max_schedules = 5;
            bot_control_chan = "saber_control";

            default_announce_chan = "general";
            default_time_zone = "America/New_York";
            default_clock_format = "12";
            default_announce_msg = "Event %a: ``%t``";

            nowplaying_list = new ArrayList<>();
            blacklist = new HashSet<>();
            cooldown_threshold = 1000;

            rsvp_yes = "\u2705";
            rsvp_no = "\u274c";
            rsvp_clear = "\u2754";
        }
    }

    public boolean hasSettings()
    {
        return settings == null;
    }

    public String getToken()
    {
        return settings.discord_token;
    }

    public String getWebToken()
    {
        return settings.web_token;
    }

    public String getAdminId()
    {
        return settings.admin_id;
    }

    public int getMaxEntries()
    {
        return settings.max_entries;
    }

    public String getCommandPrefix()
    {
        return settings.prefix;
    }

    public String getAdminPrefix()
    {
        return settings.admin_prefix;
    }

    public String getAnnounceChan()
    {
        return settings.default_announce_chan;
    }

    public String getControlChan()
    {
        return settings.bot_control_chan;
    }

    public String getAnnounceFormat()
    {
        return settings.default_announce_msg;
    }

    public String getClockFormat()
    {
        return settings.default_clock_format;
    }

    public String getTimeZone()
    {
        return settings.default_time_zone;
    }

    public String getMongoURI()
    {
        return settings.mongodb;
    }

    public List<String> getNowPlayingList()
    {
        return settings.nowplaying_list;
    }

    public Set<String> getBlackList()
    {
        return settings.blacklist;
    }

    public long getCooldownThreshold()
    {
        return settings.cooldown_threshold;
    }

    public int getMaxSchedules()
    {
        return settings.max_schedules;
    }

    public String getYesEmoji()
    {
        return settings.rsvp_yes;
    }

    public String getNoEmoji()
    {
        return settings.rsvp_no;
    }

    public String getClearEmoji()
    {
        return settings.rsvp_clear;
    }

    public String getGoogleServiceKey()
    {
        return settings.google_service_key;
    }

    public String getGoogleOAuthSecret()
    {
        return settings.google_oauth_secret;
    }

    public int getLogLevel()
    {
        return settings.log_level;
    }

    public List<Integer> getShards()
    {
        return settings.shards;
    }

    public int getShardTotal()
    {
        return settings.shard_total;
    }
} public class ScheduleManager
{
    private Set<String> locks = new HashSet<>(); // locks channels from running multiple sorts simultaneously
    private Integer MAX_SIZE_TO_SYNC = 15;  // do not sort schedules more than this number of events

    /**
     * starts a scheduled thread responsible for synchronizing channels with their linked google calendar counterparts
     * init() need not be called if the bot has not been configured to use a google service account
     */
    public void init()
    {   // every 15 minutes create a thread to check for schedules to sync
        ScheduledExecutorService syncScheduler = Executors.newScheduledThreadPool(1);
        syncScheduler.scheduleAtFixedRate(new ScheduleSyncer(), 30, 30, TimeUnit.MINUTES);
    }

    /**
     * Create a new schedule and it's associated schedule channel, if the bot cannot create the
     * new channel no schedule will be created
     * @param gId (String) guild ID
     * @param optional (String) optional name of schedule channel
     */
    public void createSchedule(String gId, String optional)
    {
        JDA jda = Main.getShardManager().getJDA(gId);

        // bot self permissions
        Collection<Permission> channelPerms = Stream.of(Permission.MESSAGE_ADD_REACTION,
                Permission.MESSAGE_EMBED_LINKS, Permission.MESSAGE_MANAGE, Permission.MESSAGE_HISTORY,
                Permission.MESSAGE_READ, Permission.MESSAGE_WRITE, Permission.MESSAGE_ATTACH_FILES)
                .collect(Collectors.toList());

        // create channel and get ID
        String cId;
        try
        {
            Guild guild = jda.getGuildById(gId);
            cId = guild.createTextChannel(optional!=null ? optional : "new_schedule")
                    .addPermissionOverride(guild.getMember(jda.getSelfUser()),          // allow self permissions
                            channelPerms, new ArrayList<>())
                    .addPermissionOverride(guild.getPublicRole(), new ArrayList<>(),    // disable @everyone message write
                            Collections.singletonList(Permission.MESSAGE_WRITE))
                    .complete().getId();
        }
        catch (PermissionException e)
        {
            String m = e.getMessage() + ": Guild ID " + gId;
            Logging.warn(this.getClass(), m);
            return;
        }
        catch (Exception e)
        {
            Logging.exception(this.getClass(), e);
            return;
        }

        // create the schedule database entry
        createNewSchedule(cId, gId);
    }


    /**
     * Convert a pre-existing discord channel to a new saber schedule channel
     * @param channel (TextChannel) a pre-existing channel to convert to a schedule
     */
    public void createSchedule(TextChannel channel)
    {
        JDA jda = Main.getShardManager().getJDA(channel.getGuild().getId());

        // bot self permissions
        Collection<Permission> channelPerms = Stream.of(Permission.MESSAGE_ADD_REACTION,
                Permission.MESSAGE_EMBED_LINKS, Permission.MESSAGE_MANAGE, Permission.MESSAGE_HISTORY,
                Permission.MESSAGE_READ, Permission.MESSAGE_WRITE, Permission.MESSAGE_ATTACH_FILES)
                .collect(Collectors.toList());

        // attempt to set the channel permissions
        try
        {
            channel.createPermissionOverride(channel.getGuild().getMember(jda.getSelfUser())) // self perms
                    .setAllow(channelPerms).queue();
            channel.createPermissionOverride(channel.getGuild().getPublicRole())              // @everyone perms
                    .setDeny(Collections.singleton(Permission.MESSAGE_WRITE)).queue();
        }
        catch (PermissionException e)
        {
            String m = e.getMessage() + ": Guild ID " + channel.getGuild().getId();
            Logging.warn(this.getClass(), m);
        }
        catch (Exception e)
        {
            Logging.exception(this.getClass(), e);
        }

        // create the schedule database entry
        createNewSchedule(channel.getId(), channel.getGuild().getId());
    }

    /**
     * initializes default values and creates a new database entry to represent the schedule
     * @param channelId the channel (and now schedule) ID; should be a unique Snowflake
     * @param guildId unique snowflake for the guild to which the channel/schedule belongs
     */
    private void createNewSchedule(String channelId, String guildId)
    {
        // default reminders
        List<Integer> default_reminders = new ArrayList<>();
        default_reminders.add(10);

        // default rsvp options
        Map<String, String> default_rsvp = new LinkedHashMap<>();
        default_rsvp.put(Main.getBotSettingsManager().getYesEmoji(), "Yes");
        default_rsvp.put(Main.getBotSettingsManager().getNoEmoji(), "No");
        default_rsvp.put(Main.getBotSettingsManager().getClearEmoji(), "Undecided");

        // create DB entry
        Document schedule =
                new Document("_id", channelId)
                        .append("guildId", guildId)
                        .append("announcement_channel", Main.getBotSettingsManager().getAnnounceChan())
                        .append("announcement_format", Main.getBotSettingsManager().getAnnounceFormat())
                        .append("clock_format", Main.getBotSettingsManager().getClockFormat())
                        .append("timezone", Main.getBotSettingsManager().getTimeZone())
                        .append("sync_time", Date.from(ZonedDateTime.of(LocalDate.now().plusDays(1),
                                LocalTime.now().truncatedTo(ChronoUnit.HOURS), ZoneId.systemDefault()).toInstant()))
                        .append("default_reminders", default_reminders)
                        .append("rsvp_enabled", false)
                        .append("display_style", "full")
                        .append("sync_length", 7)
                        .append("auto_sort", 0)
                        .append("sync_address", "off")
                        .append("rsvp_options", default_rsvp);

        Main.getDBDriver().getScheduleCollection().insertOne(schedule);
    }

    /**
     * Removes a schedule and attempts to delete the schedule's channel
     * @param cId (String) ID of channel / schedule (synonymous)
     */
    public void deleteSchedule(String cId)
    {
        // identify which shard is responsible for the schedule
        Document doc = Main.getDBDriver().getScheduleCollection()
                .find(eq("_id", cId))
                .projection(fields(include("guildId")))
                .first();
        JDA jda = Main.getShardManager().getJDA(doc.getString("guildId"));

        try
        {
            jda.getTextChannelById(cId).delete().complete();
        }
        catch(PermissionException e)
        {
            String m = e.getMessage() + ": " + e.getPermission();
            Logging.warn(this.getClass(), m);
        }
        catch(Exception e)
        {
            Logging.exception(this.getClass(), e);
        }

        Main.getDBDriver().getEventCollection().deleteMany(eq("channelId", cId));
        Main.getDBDriver().getScheduleCollection().deleteOne(eq("_id", cId));
    }

    /**
     * is a channel an initialized schedule?
     * @param cId (String) channel ID, synonymous to schedule id
     * @return (boolean) true if channel ID maps to a schedule
     */
    public boolean isSchedule(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        return settings != null;
    }

    /**
     * has a guild reached it's maximum schedule limit?
     * @param gId (String) guild ID
     * @return (boolean) true if guild has reached bot limit on schedule number
     */
    public boolean isLimitReached(String gId)
    {
        long count = Main.getDBDriver().getScheduleCollection().count(eq("guildId",gId));
        return Main.getBotSettingsManager().getMaxSchedules() < count;
    }

    private long getScheduleSize(String cId)
    {
        return Main.getDBDriver().getScheduleCollection().count(eq("channelId",cId));
    }

    /**
     * check to see if the channel is locked
     * @param cId (String) channel ID
     * @return (boolean) true if the channel is locked for sorting
     */
    public boolean isLocked(String cId)
    {
        return this.locks.contains(cId);
    }

    /**
     * locks a schedule (user cannot add/edit new events)
     * @param cId (String) channel ID
     */
    public void lock(String cId)
    {
        this.locks.add(cId); // lock the channel
    }

    /**
     * unlocks a schedule (user is free to add/edit events)
     * @param cId (String) channel ID
     */
    public void unlock(String cId)
    {
        this.locks.remove(cId); // unlock the channel
    }

    // band-aid
    public void clearLocks()
    {
        this.locks = new HashSet<>();
    }

    /**
     * Reorders the schedule so that entries are displayed by start datetime ascending order in
     * the discord schedule channel
     * @param cId schedule ID
     * @param reverseOrder (boolean) whether or not to reverse the sort order
     */
    public void sortSchedule(String cId, boolean reverseOrder)
    {
        if(this.getScheduleSize(cId) > MAX_SIZE_TO_SYNC) return;
        if(this.isLocked(cId)) return;

        this.lock(cId); // lock the channel

        // encapsulate in try block,
        // always unlock the schedule at finish regardless of success or failure
        try
        {
            // identify which shard is responsible for the schedule
            Document doc = Main.getDBDriver().getScheduleCollection()
                    .find(eq("_id", cId))
                    .projection(fields(include("guildId")))
                    .first();
            JDA jda = Main.getShardManager().getJDA(doc.getString("guildId"));

            // find the message channel and send the 'is typing' while processing
            MessageChannel chan = jda.getTextChannelById(cId);
            chan.sendTyping().queue();

            int sortOrder = 1;
            if(reverseOrder)
                sortOrder = -1;

            LinkedList<ScheduleEntry> unsortedEntries = new LinkedList<>();
            Main.getDBDriver().getEventCollection().find(eq("channelId", cId))
                    .sort(new Document("start", sortOrder))
                    .forEach((Consumer<? super Document>) document -> unsortedEntries.add(new ScheduleEntry(document)));

            // selection sort the entries by timestamp
            while (!unsortedEntries.isEmpty())
            {
                chan.sendTyping().queue();   // continue to send 'is typing'

                ScheduleEntry top = unsortedEntries.pop();
                ScheduleEntry min = top;
                for (ScheduleEntry cur : unsortedEntries)
                {
                    Message minMsg = min.getMessageObject();
                    Message topMsg = cur.getMessageObject();
                    if (minMsg!=null && topMsg!=null)
                    {
                        OffsetDateTime a = minMsg.getTimeCreated();
                        OffsetDateTime b = topMsg.getTimeCreated();
                        if (a.isAfter(b))
                        {
                            min = cur;
                        }
                    }

                }
                // swap messages and update db
                if (!(min==top))
                {
                    Message tmp = top.getMessageObject();
                    top.setMessageObject(min.getMessageObject());
                    Main.getDBDriver().getEventCollection().updateOne(
                            eq("_id", top.getId()),
                            new Document("$set", new Document("messageId", min.getMessageObject().getId())));

                    min.setMessageObject(tmp);
                    Main.getDBDriver().getEventCollection().updateOne(
                            eq("_id", min.getId()),
                            new Document("$set", new Document("messageId", tmp.getId())));
                }

                // reload display
                top.reloadDisplay();
            }
        }
        catch(PermissionException e)
        {
            String m = e.getMessage() + ": Channel ID " + cId;
            Logging.warn(this.getClass(), m);
        }
        catch(Exception e)
        {
            Logging.exception(this.getClass(), e);
        }
        finally
        {
            this.unlock(cId); // always unlock
        }
    }


    /*
     *
     * Getters and Setters
     *
     */

    public boolean isRSVPEnabled(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }
        return settings.getBoolean("rsvp_enabled", false);
    }

    public boolean isRSVPConfirmationsEnabled(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }
        return settings.getBoolean("rsvp_confirmations", false);
    }

    public boolean isEndFormatOverridden(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }

        String format = (String) settings.get("announcement_format_end");
        return !(format == null);
    }

    public boolean isEndChannelOverridden(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }

        String format = (String) settings.get("announcement_channel_end");
        return !(format == null);
    }

    public boolean isRemindFormatOverridden(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }

        String format = (String) settings.get("reminder_format");
        return !(format == null);
    }

    public boolean isRemindChanOverridden(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return false;
        }

        String format = (String) settings.get("reminder_channel");
        return !(format == null);
    }

    public boolean isRSVPExclusive(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        return settings == null || settings.getBoolean("rsvp_exclusivity", true);
    }

    /*
     * Getters
     * Should never return null
     */

    public List<String> getSchedulesForGuild(String gId)
    {
        List<String> list = new ArrayList<>();
        for (Document document : Main.getDBDriver().getScheduleCollection().find(eq("guildId", gId)))
        {
            list.add(document.getString("_id"));
        }
        return list;
    }

    public String getStartAnnounceChan(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceChan();
        }
        String channel = settings.getString("announcement_channel");
        if(channel == null)
        {
            return Main.getBotSettingsManager().getAnnounceChan();
        }
        return channel;
    }

    public String getStartAnnounceFormat(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceFormat();
        }
        String format = settings.getString("announcement_format");
        if(format == null)
        {
            return Main.getBotSettingsManager().getAnnounceFormat();
        }
        return format;
    }

    public String getEndAnnounceChan(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceChan();
        }
        String chan = settings.getString("announcement_channel_end");
        if(chan == null)
        {
            return settings.getString("announcement_channel");
        }
        return chan;
    }

    public String getEndAnnounceFormat(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceFormat();
        }
        String format = settings.getString("announcement_format_end");
        if(format == null)
        {
            return settings.getString("announcement_format");
        }
        return format;
    }

    public String getClockFormat(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getClockFormat();
        }
        String clock = settings.getString("clock_format");
        if(clock == null)
        {
            return Main.getBotSettingsManager().getClockFormat();
        }
        return clock;
    }

    public ZoneId getTimeZone(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return ZoneId.of(Main.getBotSettingsManager().getTimeZone());
        }
        ZoneId zone = ZoneId.of(settings.getString("timezone"));
        if(zone == null)
        {
            return ZoneId.of(Main.getBotSettingsManager().getTimeZone());
        }
        return zone;
    }

    public List<ZoneId> getAltZones(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return new ArrayList<>();
        }
        List<String> zones = (List<String>) settings.get("alt_zones");
        if(zones == null)
        {
            return new ArrayList<>();
        }
        return zones.stream().map(ZoneId::of).collect(Collectors.toList());
    }

    public String getAddress(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return "off";
        }

        String address = settings.getString("sync_address");
        if(address == null)
        {
            return "off";
        }
        return address;
    }

    public Date getSyncTime(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Date.from(ZonedDateTime.of(LocalDate.now().plusDays(1),
                    LocalTime.MIDNIGHT, ZoneId.systemDefault()).toInstant());
        }

        Date syncTime = settings.getDate("sync_time");
        if(syncTime == null)
        {
            return Date.from(ZonedDateTime.of(LocalDate.now().plusDays(1),
                    LocalTime.MIDNIGHT, ZoneId.systemDefault()).toInstant());
        }
        return syncTime;
    }

    @SuppressWarnings("unchecked")
    public List<Integer> getReminders(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return new ArrayList<>();
        }

        List<Integer> reminders = (List<Integer>) settings.get("default_reminders");
        if(reminders == null)
        {
            return new ArrayList<>();
        }
        return reminders;
    }

    public String getReminderChan(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceChan();
        }

        String chan_name = (String) settings.get("reminder_channel");
        if(chan_name == null )
        {
            return (String) settings.get("announcement_channel");
        }
        return chan_name;

    }

    public String getReminderFormat(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if( settings == null )
        {
            return Main.getBotSettingsManager().getAnnounceFormat();
        }

        String format = (String) settings.get("reminder_format");
        if(format == null )
        {
            return (String) settings.get("announcement_format");
        }
        return format;
    }

    public String getStyle(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return "FULL";
        }

        String style = (String) settings.get("display_style");
        if(style == null)
        {
            return "FULL";
        }
        return style;
    }

    public int getSyncLength(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return 7;
        }

        Integer len = (Integer) settings.get("sync_length");
        if(len == null)
        {
            return 7;
        }
        return len;
    }

    public String getSyncUser(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return null;
        }

        String user = settings.getString("sync_user");
        if(user == null)
        {
            return null;
        }
        return user;
    }

    public int getAutoSort(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return 0;
        }

        Integer sort = (Integer) settings.get("auto_sort");
        if(sort == null)
        {
            return 0;
        }
        return sort;
    }

    public Map<String, String> getRSVPOptions(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return new HashMap<>();
        }

        Map<String, String> map = (Map) settings.get("rsvp_options");
        if(map == null)
        {
            map = new LinkedHashMap<>();
            map.put(Main.getBotSettingsManager().getYesEmoji(), "Yes");
            map.put(Main.getBotSettingsManager().getNoEmoji(), "No");
            map.put(Main.getBotSettingsManager().getClearEmoji(), "Undecided");
        }
        return map;
    }

    public String getRSVPClear(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return "";
        }
        String emoji = settings.getString("rsvp_clear");
        if(emoji == null)
        {
            return "";
        }
        return emoji;
    }

    public String getRSVPLogging(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return "";
        }
        String loggingChannel = settings.getString("rsvp_logging");
        if(loggingChannel == null)
        {
            return "";
        }
        return loggingChannel;
    }

    public List<Integer> getEndReminders(String cId)
    {
        Document settings = Main.getDBDriver().getScheduleCollection().find(eq("_id",cId)).first();
        if(settings == null)
        {
            return new ArrayList<>();
        }
        List<Integer> reminders = (List<Integer>) settings.get("end_reminders");
        if(reminders == null)
        {
            return new ArrayList<>();
        }
        return reminders;
    }

    /*
     * setters
     */

    /**
     * Sets the default announcement target channel for a schedule
     */
    public void setAnnounceChan(String cId, String chan )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("announcement_channel", chan));
    }

    /**
     * Sets the default announcement message format for a schedule
     */
    public void setAnnounceFormat(String cId, String format )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("announcement_format", format));
    }

    /**
     * Sets the end announcement target channel for a schedule
     */
    public void setEndAnnounceChan(String cId, String chan )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("announcement_channel_end", chan));
    }

    /**
     * Sets the end announcement message format for a schedule
     */
    public void setEndAnnounceFormat(String cId, String format )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("announcement_format_end", format));
    }

    /**
     * Sets the display format for event times
     */
    public void setClockFormat(String cId, String clock )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("clock_format", clock));
    }

    /**
     * Sets the schedule's timezone
     */
    public void setTimeZone(String cId, ZoneId zone)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("timezone", zone.toString()));
    }

    /**
     * sets alternative zones
     */
    public void setAltZones(String cId, List<ZoneId> zoneIds)
    {
        List<String> zones = zoneIds.stream().map(zoneId -> zoneId.toString()).collect(Collectors.toList());
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("alt_zones"))
        {
            doc.append("alt_zones", zones);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("alt_zones", zones));
        }
    }

    /**
     * Sets the google calendar address to be used for synchronization
     */
    public void setAddress(String cId, String address)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("sync_address", address));
    }

    /**
     * Sets the daily time in which a schedule synced with a google calendar should re-sync
     */
    public void setSyncTime(String cId, Date syncTime)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("sync_time", syncTime));
    }

    /**
     * Sets the time offsets for reminders
     */
    public void setReminders(String cId, List<Integer> reminders)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("default_reminders", reminders));
    }

    /**
     * Sets the time offsets for end reminders
     */
    public void setEndReminders(String cId, List<Integer> reminders)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("end_reminders"))
        {
            doc.append("end_reminders", reminders);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("end_reminders", reminders));
        }
    }

    /**
     * Sets the reminder target channel for a schedule
     */
    public void setReminderChan(String cId, String chan )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("reminder_channel", chan));
    }

    /**
     * Sets the reminder message format for a schedule
     */
    public void setReminderFormat(String cId, String format )
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("reminder_format", format));
    }

    /**
     * Sets whether or not to allow RSVP for events on a schedule
     */
    public void setRSVPEnable(String cId, boolean value)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("rsvp_enabled", value));
    }

    /**
     * Sets the style type to be used by events on a schedule
     * "full"- all information
     * "narrow"- truncated, less information
     */
    public void setStyle(String cId, String style)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("display_style", style));
    }

    /**
     * Sets the number of days a schedule should sync when syncing to a google calendar
     */
    public void setSyncLength(String cId, int len)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("sync_length", len));
    }

    /**
     * Sets if/how a schedule should auto sort events
     * 0- off; 1- asc; 2- desc
     */
    public void setAutoSort(String cId, int type)
    {
        Main.getDBDriver().getScheduleCollection().updateOne(eq("_id",cId), set("auto_sort", type));
    }

    /**
     * Sets the mapping of emoji->rsvp_groups for a schedule
     */
    public void setRSVPOptions(String cId, Map<String, String> options)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("rsvp_options"))
        {
            doc.append("rsvp_options", options);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("rsvp_options", options));
        }
    }

    /**
     * Sets whether or not a schedule should add a 'clear all RSVPs' emoji reaction to events
     */
    public void setRSVPClear(String cId, String emoji)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("rsvp_clear"))
        {
            doc.append("rsvp_clear", emoji);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("rsvp_clear", emoji));
        }
    }

    /**
     * Sets whether or not a schedule should allow users to RSVP for multiple group
     */
    public void setRSVPExclusivity(String cId, Boolean bool)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("rsvp_exclusivity"))
        {
            doc.append("rsvp_exclusivity", bool);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("rsvp_exclusivity", bool));
        }
    }

    /**
     * Sets whether or not a schedule should notify users when they RSVP
     */
    public void setRSVPConfirmations(String cId, Boolean bool)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("rsvp_confirmations"))
        {
            doc.append("rsvp_confirmations", bool);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("rsvp_confirmations", bool));
        }
    }

    /**
     * Sets a schedule's channel to be used for RSVP logging
     */
    public void setRSVPLoggingChannel(String cId, String channelIdentifier)
    {
        Document doc = Main.getDBDriver().getScheduleCollection().find(eq("_id", cId)).first();
        if(!doc.containsKey("rsvp_logging"))
        {
            doc.append("rsvp_logging", channelIdentifier);
            Main.getDBDriver().getScheduleCollection().replaceOne(eq("_id", cId), doc);
        } else
        {
            Main.getDBDriver().getScheduleCollection().updateOne(eq("_id", cId), set("rsvp_logging", channelIdentifier));
        }
    }
} public class GuildSettingsManager
{
    /**
     * retrieves the guild settings object for a guild
     * @param guildId ID of guild
     * @return GuildSettings object (never null)
     */
    public GuildSettings getGuildSettings(String guildId)
    {
        Document guildDoc = Main.getDBDriver().getGuildCollection().find(eq("_id", guildId)).first();

        if (guildDoc == null) // create a new guild document and add to db
        {
            // unrestricted commands are commands that may be used outside of the command channel
            ArrayList<String> unrestrictedCommands = new ArrayList<>(Arrays.asList(
                    new ListCommand().name(),
                    new HelpCommand().name(),
                    new SchedulesCommand().name(),
                    new EventsCommand().name(),
                    new DiagnoseCommand().name())); // defaults

            // initialize with defaults
            guildDoc = new Document()
                    .append("_id", guildId)
                    .append("prefix", Main.getBotSettingsManager().getCommandPrefix())
                    .append("unrestricted_commands", unrestrictedCommands)
                    .append("late_threshold", 15);

            Main.getDBDriver().getGuildCollection().insertOne(guildDoc);
        }

        return new GuildSettings(guildDoc);
    }

    /**
     * object for getting and setting guild options
     */
    @SuppressWarnings("unchecked")
    public static class GuildSettings
    {
        String guildId;
        String commandPrefix;
        ArrayList<String> unrestrictedCommands;
        String commandChannelId;
        Integer lateThreshold;

        GuildSettings(Document guildDocument)
        {
            guildId = guildDocument.getString("_id");
            commandPrefix = guildDocument.getString("prefix");
            commandChannelId = guildDocument.get("command_channel") != null ?
                    guildDocument.getString("command_channel") : null;
            unrestrictedCommands = (ArrayList<String>) guildDocument.get("unrestricted_commands");
            lateThreshold = guildDocument.get("late_threshold") != null ?
                    guildDocument.getInteger("late_threshold") : 15;
        }

        // **** getters ****

        public String getPrefix()
        {
            return this.commandPrefix;
        }

        public String getCommandChannelId()
        {
            return this.commandChannelId;
        }

        public ArrayList<String> getUnrestrictedCommands()
        {
            return unrestrictedCommands;
        }

        public ArrayList<String> getRestrictedCommands()
        {
            ArrayList<String> commands = new ArrayList<>(Main.getCommandHandler().getCommandNames());
            commands.removeAll(unrestrictedCommands);
            return commands;
        }

        public Integer getLateThreshold()
        {
            return this.lateThreshold;
        }

        // **** setters ****

        public void setPrefix(String prefix)
        {
            Main.getDBDriver().getGuildCollection()
                    .updateOne(eq("_id", guildId), set("prefix", prefix));
            this.commandPrefix = prefix;
        }

        public void setCommandChannelId(String channelId)
        {
            Main.getDBDriver().getGuildCollection()
                    .updateOne(eq("_id", guildId), set("command_channel", channelId));
            this.commandChannelId = channelId;
        }

        public void setUnrestrictedCommands(ArrayList<String> unrestrictedCommands)
        {
            Main.getDBDriver().getGuildCollection().updateOne(eq("_id", guildId),
                    set("unrestricted_commands", unrestrictedCommands));
            this.unrestrictedCommands = unrestrictedCommands;
        }

        public void setLateThreshold(Integer minutes)
        {
            Main.getDBDriver().getGuildCollection()
                    .updateOne(eq("_id", guildId), set("late_threshold", minutes));
            this.lateThreshold = minutes;
        }

    }
} public class HttpUtilities
{
    private static LocalDateTime lastUpdate = LocalDateTime.MIN;

    /**
     * Updates bot metrics for any connected metric tracking services
     */
    public static void updateStats(Integer shardId)
    {
        String auth = Main.getBotSettingsManager().getWebToken();
        if (auth != null)
        {
            HttpUtilities.updateStats_abal(auth, shardId);
        }
    }


    /**
     * updates bot metrics for bots.discord.pw tracking
     * @param auth the abal authentication token for the bot
     */
    private static void updateStats_abal(String auth, Integer shardId)
    {
        if (lastUpdate.until(LocalDateTime.now(), ChronoUnit.SECONDS) > 60)
        {
            JSONObject json;
            if (Main.getShardManager().isSharding() && shardId != null)
            {   // if the bot is sharding send shard information
                int count = Main.getShardManager().getShard(shardId).getGuilds().size();
                int total = Main.getBotSettingsManager().getShardTotal();
                json = new JSONObject().put("shard_count", total).put("shard_id", shardId).put("server_count", count);
            }
            else
            {   // otherwise send only the server count
                int count = Main.getShardManager().getGuilds().size();
                json = new JSONObject().put("server_count", count);
            }

            try
            {   // send the API request
                Unirest.post("https://bots.discord.pw/api/bots/" + Main.getShardManager().getJDA().getSelfUser().getId() + "/stats")
                        .header("Authorization", auth)
                        .header("Content-Type", "application/json")
                        .body(json).asJson();
            }
            catch (UnirestException e)
            {
                Logging.warn(HttpUtilities.class, e.getMessage());
            }
            catch (Exception e)
            {
                Logging.exception(HttpUtilities.class, e);
            }
        }
    }
} public class Logging
{
    public static final String ANSI_RESET = "\u001B[0m";
    public static final String ANSI_BLACK = "\u001B[30m";
    public static final String ANSI_RED = "\u001B[31m";
    public static final String ANSI_GREEN = "\u001B[32m";
    public static final String ANSI_YELLOW = "\u001B[33m";
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";
    public static final String ANSI_CYAN = "\u001B[36m";
    public static final String ANSI_WHITE = "\u001B[37m";

    public static final String ANSI_BLACK_BACKGROUND = "\u001B[40m";
    public static final String ANSI_RED_BACKGROUND = "\u001B[41m";
    public static final String ANSI_GREEN_BACKGROUND = "\u001B[42m";
    public static final String ANSI_YELLOW_BACKGROUND = "\u001B[43m";
    public static final String ANSI_BLUE_BACKGROUND = "\u001B[44m";
    public static final String ANSI_PURPLE_BACKGROUND = "\u001B[45m";
    public static final String ANSI_CYAN_BACKGROUND = "\u001B[46m";
    public static final String ANSI_WHITE_BACKGROUND = "\u001B[47m";

    /**
     * used for most general logging (level 5)
     * @param caller the java class from which the command is called
     * @param msg the message to log
     */
    public static void info(Class caller, String msg)
    {
        if(Main.getBotSettingsManager().getLogLevel() < 5) return;

        String now = LocalTime.now().truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME);
        String content = "[" + now + "] [Info]" +
                ANSI_RESET + " " + ANSI_CYAN_BACKGROUND + ANSI_BLACK +
                "[" + caller.getSimpleName() + "]" +
                ANSI_RESET + " " + msg +
                ANSI_RESET;

        System.out.println(content);
    }

    /**
     * used only for event announcing (level 4)
     * @param caller the java class from which the command is called
     * @param msg the message to log
     */
    public static void event(Class caller, String msg)
    {
        if(Main.getBotSettingsManager().getLogLevel() < 4) return;

        String now = LocalTime.now().truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME);
        String content = "[" + now + "] [Info]" +
                ANSI_RESET + " " + ANSI_BLUE_BACKGROUND + ANSI_BLACK +
                "[" + caller.getSimpleName() + "]" +
                ANSI_RESET + " " + msg +
                ANSI_RESET;

        System.out.println(content);
    }

    /**
     * used for logging command usage (level 2)
     * @param caller the java class from which the command is called
     * @param msg the message to log
     */
    public static void cmd(Class caller, String msg)
    {
        if(Main.getBotSettingsManager().getLogLevel() < 3) return;

        String now = LocalTime.now().truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME);
        String content = "[" + now + "] [Cmnd]" +
                ANSI_RESET + " " + ANSI_GREEN_BACKGROUND + ANSI_BLACK +
                "[" + caller.getSimpleName() + "]" +
                ANSI_RESET + " " + msg +
                ANSI_RESET;

        System.out.println(content);
    }

    /**
     * used logging minor (possibly expected) errors (level 2)
     * @param caller the java class from which the command is called
     * @param msg the message to log
     */
    public static void warn(Class caller, String msg)
    {
        if(Main.getBotSettingsManager().getLogLevel() < 2) return;

        String now = LocalTime.now().truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME);
        String content = "[" + now + "] " +
                ANSI_RED + "[Warn]" + ANSI_RESET + " " +
                ANSI_YELLOW_BACKGROUND + ANSI_BLACK +
                "[" + caller.getSimpleName() + "]" +
                ANSI_RESET + " " + ANSI_RED + msg +
                ANSI_RESET;

        System.out.println(content);
    }

    /**
     * used for logging significant (unexpected) errors (level 1)
     * @param caller the java class from which the command is called
     * @param error the error
     */
    public static void exception(Class caller, Throwable error)
    {
        if(Main.getBotSettingsManager().getLogLevel() < 1) return;

        String now = LocalTime.now().truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_TIME);
        String content = "[" + now + "] " +
                ANSI_PURPLE + "[Excp]" + ANSI_RESET + " " +
                ANSI_PURPLE_BACKGROUND + ANSI_BLACK +
                "[" + caller.getSimpleName() + "]" +
                ANSI_RESET + " " + ANSI_PURPLE + error.getMessage() +
                ANSI_RESET;
        System.out.println(content);
        error.printStackTrace();
    }
}
import ws.nmathe.saber.core.ShardManager;
import ws.nmathe.saber.core.command.CommandHandler;
import ws.nmathe.saber.core.database.Driver;
import ws.nmathe.saber.core.google.CalendarConverter;
import ws.nmathe.saber.core.schedule.EntryManager;
import ws.nmathe.saber.core.settings.BotSettingsManager;
import ws.nmathe.saber.core.schedule.ScheduleManager;
import ws.nmathe.saber.core.settings.GuildSettingsManager;
import ws.nmathe.saber.utils.HttpUtilities;
import ws.nmathe.saber.utils.Logging;
package ws.nmathe.saber;


/**
 * Load point for the bot application
 * Used to connect the various important elements together
 */
public class Main
{
    private static ShardManager shardManager;
    private static BotSettingsManager botSettingsManager     = new BotSettingsManager();
    private static EntryManager entryManager                 = new EntryManager();
    private static ScheduleManager scheduleManager           = new ScheduleManager();
    private static CommandHandler commandHandler             = new CommandHandler();
    private static CalendarConverter calendarConverter       = new CalendarConverter();
    private static GuildSettingsManager guildSettingsManager = new GuildSettingsManager();
    private static Driver mongoDriver                        = new Driver();