fll.scheduler.TournamentSchedule.java Source code

Java tutorial

Introduction

Here is the source code for fll.scheduler.TournamentSchedule.java

Source

/*
 * Copyright (c) 2000-2008 HighTechKids.  All rights reserved
 * HighTechKids is on the web at: http://www.hightechkids.org
 * This code is released under GPL; see LICENSE.txt for details.
 */
package fll.scheduler;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Serializable;
import java.io.Writer;
import java.net.MalformedURLException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Time;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.Duration;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Formatter;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;

import javax.xml.XMLConstants;
import javax.xml.transform.Source;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;

import org.apache.commons.io.IOUtils;
import org.apache.log4j.Logger;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

import com.itextpdf.text.BaseColor;
import com.itextpdf.text.Chunk;
import com.itextpdf.text.Document;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Font;
import com.itextpdf.text.FontFactory;
import com.itextpdf.text.Paragraph;
import com.itextpdf.text.pdf.PdfPCell;
import com.itextpdf.text.pdf.PdfPTable;

import au.com.bytecode.opencsv.CSVWriter;
import fll.Team;
import fll.Tournament;
import fll.TournamentTeam;
import fll.Utilities;
import fll.db.Queries;
import fll.documents.elements.SheetElement;
import fll.documents.writers.SubjectivePdfWriter;
import fll.util.CSVCellReader;
import fll.util.CellFileReader;
import fll.util.ExcelCellReader;
import fll.util.FLLInternalException;
import fll.util.FLLRuntimeException;
import fll.util.LogUtils;
import fll.util.PdfUtils;
import fll.util.SimpleFooterHandler;
import fll.web.playoff.ScoresheetGenerator;
import fll.xml.ChallengeDescription;
import fll.xml.ScoreCategory;
import fll.xml.XMLUtils;
import net.mtu.eggplant.util.sql.SQLFunctions;

/**
 * Tournament schedule. Can parse the schedule from a spreadsheet or CSV file.
 * When using the schedule from inside the web application, note that the team
 * information isn't updated when the main database is modified, so you can end
 * up with teams in the schedule that are no longer in the tournament and you
 * can end up with teams that are in the tournament, but not in the schedule.
 * <p>
 * Note to developers: The comments prefixed with "constraint:" refer back to
 * the scheduling document.
 * </p>
 */
public class TournamentSchedule implements Serializable {
    private static final Logger LOGGER = LogUtils.getLogger();

    /**
     * Header on team number column.
     */
    public static final String TEAM_NUMBER_HEADER = "Team #";

    public static final String TEAM_NAME_HEADER = "Team Name";

    public static final String ORGANIZATION_HEADER = "Organization";

    /**
     * Should use AWARD_GROUP_HEADER now, only kept for old schedules
     */
    @Deprecated
    private static final String DIVISION_HEADER = "Div";

    public static final String AWARD_GROUP_HEADER = "Awawrd Group";

    public static final String JUDGE_GROUP_HEADER = "Judging Group";

    public static final String BASE_PERF_HEADER = "Perf #";

    /**
     * Used with {@link String#format(String, Object...)} to create a performance
     * round header.
     */
    public static final String PERF_HEADER_FORMAT = BASE_PERF_HEADER + "%d";

    /**
     * Used with {@link String#format(String, Object...)} to create a performance
     * table header.
     */
    public static final String TABLE_HEADER_FORMAT = "Perf %d Table";

    private final int numRounds;

    /**
     * Number of rounds in this schedule.
     */
    public int getNumberOfRounds() {
        return numRounds;
    }

    /**
     * XML format for time type.
     */
    private static final DateTimeFormatter XML_TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");

    /**
     * Always output without 24-hour time and without AM/PM.
     */
    private static final DateTimeFormatter OUTPUT_TIME_FORMAT = DateTimeFormatter.ofPattern("h:mm");

    /**
     * Parse times as 24-hour and then use
     * {@link TournamentSchedule#EARLIEST_HOUR} to decide if it's really
     * AM or PM.
     */
    private static final DateTimeFormatter INPUT_TIME_FORMAT = DateTimeFormatter.ofPattern("H:mm");

    /**
     * Any date with an hour before this needs to have 12 added to it as it must
     * be in the afternoon.
     */
    private static final int EARLIEST_HOUR = 7;

    /**
     * The format used inside Excel spreadsheets.
     */
    private static final DateTimeFormatter TIME_FORMAT_AM_PM_SS = DateTimeFormatter.ofPattern("hh:mm:ss a");

    /**
     * Parse a time from a schedule. This method allows only hours and minutes to
     * be
     * specified and still have times in the afternoon. If there is any time that
     * has an hour before {@link #EARLIEST_HOUR} the time is assumed to be in the
     * afternoon.
     * 
     * @param str a string representing a schedule time, if null the return value
     *          is null, if empty the return value is null
     * @return the {@link LocalTime} object for the string
     * @throws DateTimeParseException if the string could not be parsed as a time
     *           for a schedule
     */
    public static LocalTime parseTime(final String str) throws DateTimeParseException {
        if (null == str || str.trim().isEmpty()) {
            return null;
        }

        try {
            // first try with the generic parser
            final LocalTime time = LocalTime.parse(str);
            return time;
        } catch (final DateTimeParseException e) {
            // try with seconds and AM/PM
            try {
                final LocalTime time = LocalTime.parse(str, TIME_FORMAT_AM_PM_SS);
                return time;
            } catch (final DateTimeParseException ampme) {
                // then try with 24-hour clock
                final LocalTime time = LocalTime.parse(str, INPUT_TIME_FORMAT);
                if (time.getHour() < EARLIEST_HOUR) {
                    // no time should be this early, it must be the afternoon.
                    return time.plusHours(12);
                } else {
                    return time;
                }
            }
        }
    }

    /**
     * Conver the time to a string that will be parsed by {@link #parseTime(String)}.
     * 
     * @param time the time to format, may be null
     * @return the formatted time, null converts to ""
     */
    public static String formatTime(final LocalTime time) {
        if (null == time) {
            return "";
        } else {
            return time.format(OUTPUT_TIME_FORMAT);
        }
    }

    public static final ThreadLocal<DateFormat> DATE_FORMAT_AM_PM_SS = new ThreadLocal<DateFormat>() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("hh:mm:ss a");
        }
    };

    // time->table->team
    private final HashMap<LocalTime, Map<String, List<TeamScheduleInfo>>> _matches = new HashMap<>();

    /* package */Map<LocalTime, Map<String, List<TeamScheduleInfo>>> getMatches() {
        // TODO should make read-only somehow
        return _matches;
    }

    private final HashSet<String> _tableColors = new HashSet<String>();

    public Set<String> getTableColors() {
        return Collections.unmodifiableSet(_tableColors);
    }

    private final HashSet<String> _awardGroups = new HashSet<String>();

    public Set<String> getAwardGroups() {
        return Collections.unmodifiableSet(_awardGroups);
    }

    private final HashSet<String> _judgingGroups = new HashSet<String>();

    public Set<String> getJudgingGroups() {
        return Collections.unmodifiableSet(_judgingGroups);
    }

    private final LinkedList<TeamScheduleInfo> _schedule = new LinkedList<TeamScheduleInfo>();

    private final HashSet<String> subjectiveStations = new HashSet<String>();

    /**
     * Name of this tournament.
     */
    private final String name;

    public String getName() {
        return name;
    }

    /**
     * The list of subjective stations for this schedule.
     */
    public Set<String> getSubjectiveStations() {
        return Collections.unmodifiableSet(subjectiveStations);
    }

    /**
     * @return An unmodifiable copy of the schedule.
     */
    public List<TeamScheduleInfo> getSchedule() {
        return Collections.unmodifiableList(_schedule);
    }

    /**
     * Get the {@link TeamScheduleInfo} for the specified team number.
     * 
     * @return null if cannot be found
     */
    public TeamScheduleInfo getSchedInfoForTeam(final int teamNumber) {
        for (final TeamScheduleInfo si : _schedule) {
            if (si.getTeamNumber() == teamNumber) {
                return si;
            }
        }
        return null;
    }

    /**
     * Read the tournament schedule from a spreadsheet.
     * 
     * @param name the name of the tournament
     * @param stream how to access the spreadsheet
     * @param sheetName the name of the worksheet the data is on
     * @param subjectiveHeaders the headers for the subjective columns
     * @throws ScheduleParseException if there is an error parsing the schedule
     */
    public TournamentSchedule(final String name, final InputStream stream, final String sheetName,
            final Collection<String> subjectiveHeaders)
            throws IOException, ParseException, InvalidFormatException, ScheduleParseException {
        this(name, new ExcelCellReader(stream, sheetName), subjectiveHeaders);
    }

    /**
     * Read the tournament schedule from a CSV file.
     * 
     * @param csvFile where to read the schedule from
     * @param subjectiveHeaders the headers for the subjective columns
     * @throws ScheduleParseException
     * @throws ParseException
     */
    public TournamentSchedule(final String name, final File csvFile, final Collection<String> subjectiveHeaders)
            throws IOException, ParseException, ScheduleParseException {
        this(name, new CSVCellReader(csvFile), subjectiveHeaders);
    }

    /**
     * Common construction.
     * 
     * @throws IOException
     * @throws ScheduleParseException
     * @throws ParseException
     */
    private TournamentSchedule(final String name, final CellFileReader reader,
            final Collection<String> subjectiveHeaders) throws IOException, ParseException, ScheduleParseException {
        this.name = name;
        final ColumnInformation columnInfo = findColumns(reader, subjectiveHeaders);
        numRounds = columnInfo.getNumPerfs();
        parseData(reader, columnInfo);
        reader.close();
        this.subjectiveStations.clear();
        this.subjectiveStations.addAll(subjectiveHeaders);
    }

    /**
     * Load a tournament from the database.
     * 
     * @param connection
     * @param tournamentID
     * @throws SQLException
     */
    public TournamentSchedule(final Connection connection, final int tournamentID) throws SQLException {
        final Tournament currentTournament = Tournament.findTournamentByID(connection, tournamentID);
        name = currentTournament.getName();

        PreparedStatement getSched = null;
        ResultSet sched = null;
        PreparedStatement getPerfRounds = null;
        ResultSet perfRounds = null;
        PreparedStatement getNumRounds = null;
        ResultSet numRounds = null;
        PreparedStatement getSubjective = null;
        ResultSet subjective = null;
        PreparedStatement getSubjectiveStations = null;
        ResultSet stations = null;
        try {
            getSubjectiveStations = connection
                    .prepareStatement("SELECT DISTINCT name from sched_subjective WHERE tournament = ?");
            getSubjectiveStations.setInt(1, tournamentID);
            stations = getSubjectiveStations.executeQuery();
            while (stations.next()) {
                final String name = stations.getString(1);
                subjectiveStations.add(name);
            }
            SQLFunctions.close(stations);
            stations = null;
            SQLFunctions.close(getSubjectiveStations);
            getSubjectiveStations = null;

            getNumRounds = connection
                    .prepareStatement("SELECT MAX(round) FROM sched_perf_rounds WHERE tournament = ?");
            getNumRounds.setInt(1, tournamentID);
            numRounds = getNumRounds.executeQuery();
            if (numRounds.next()) {
                this.numRounds = numRounds.getInt(1) + 1;
            } else {
                throw new RuntimeException("No rounds found for tournament: " + tournamentID);
            }

            getSched = connection.prepareStatement("SELECT team_number, judging_station" + " FROM schedule"//
                    + " WHERE tournament = ?");
            getSched.setInt(1, tournamentID);

            getPerfRounds = connection.prepareStatement("SELECT round, perf_time, table_color, table_side" //
                    + " FROM sched_perf_rounds" //
                    + " WHERE tournament = ? AND team_number = ?" //
                    + " ORDER BY round ASC");
            getPerfRounds.setInt(1, tournamentID);

            getSubjective = connection.prepareStatement("SELECT name, subj_time" //
                    + " FROM sched_subjective" //
                    + " WHERE tournament = ? AND team_number = ?");
            getSubjective.setInt(1, tournamentID);

            sched = getSched.executeQuery();
            while (sched.next()) {
                final int teamNumber = sched.getInt(1);
                final String judgingStation = sched.getString(2);

                final TeamScheduleInfo ti = new TeamScheduleInfo(this.numRounds, teamNumber);
                ti.setJudgingGroup(judgingStation);

                getSubjective.setInt(2, teamNumber);
                subjective = getSubjective.executeQuery();
                while (subjective.next()) {
                    final String name = subjective.getString(1);
                    final Time subjTime = subjective.getTime(2);
                    ti.addSubjectiveTime(new SubjectiveTime(name, subjTime.toLocalTime()));
                }

                getPerfRounds.setInt(2, teamNumber);
                perfRounds = getPerfRounds.executeQuery();
                int prevRound = -1;
                while (perfRounds.next()) {
                    final int round = perfRounds.getInt(1);
                    if (round != prevRound + 1) {
                        throw new RuntimeException(
                                "Rounds must be consecutive and start at 1. Tournament: " + tournamentID + " team: "
                                        + teamNumber + " round: " + (round + 1) + " prevRound: " + (prevRound + 1));
                    }
                    final LocalTime perfTime = perfRounds.getTime(2).toLocalTime();
                    final String tableColor = perfRounds.getString(3);
                    final int tableSide = perfRounds.getInt(4);
                    if (tableSide != 1 && tableSide != 2) {
                        throw new RuntimeException("Tables sides must be 1 or 2. Tournament: " + tournamentID
                                + " team: " + teamNumber);
                    }
                    final PerformanceTime performance = new PerformanceTime(perfTime, tableColor, tableSide);
                    ti.setPerf(round, performance);

                    prevRound = round;
                }
                final String eventDivision = Queries.getEventDivision(connection, teamNumber, tournamentID);
                ti.setDivision(eventDivision);

                final Team team = Team.getTeamFromDatabase(connection, teamNumber);
                ti.setOrganization(team.getOrganization());
                ti.setTeamName(team.getTeamName());

                cacheTeamScheduleInformation(ti);
            }

        } finally {
            SQLFunctions.close(stations);
            stations = null;
            SQLFunctions.close(getSubjectiveStations);
            getSubjectiveStations = null;
            SQLFunctions.close(sched);
            sched = null;
            SQLFunctions.close(getSched);
            getSched = null;
            SQLFunctions.close(perfRounds);
            perfRounds = null;
            SQLFunctions.close(getPerfRounds);
            getPerfRounds = null;
            SQLFunctions.close(numRounds);
            numRounds = null;
            SQLFunctions.close(getNumRounds);
            getNumRounds = null;
            SQLFunctions.close(subjective);
            subjective = null;
            SQLFunctions.close(getSubjective);
            getSubjective = null;
        }
    }

    /**
     * Check if this line is a header line. This checks for key headers and
     * returns true if they are found.
     */
    private static boolean isHeaderLine(final String[] line) {
        boolean retval = false;
        for (int i = 0; i < line.length; ++i) {
            if (TEAM_NUMBER_HEADER.equals(line[i])) {
                retval = true;
            }
        }

        return retval;
    }

    /**
     * Find the index of the columns. Reads lines until the headers are found or
     * EOF is reached.
     * 
     * @return the column information
     * @throws IOException
     * @throws RuntimeException if a header row cannot be found
     */
    public static ColumnInformation findColumns(final CellFileReader reader,
            final Collection<String> subjectiveHeaders) throws IOException {
        while (true) {
            final String[] line = reader.readNext();
            if (null == line) {
                throw new RuntimeException("Cannot find header line and reached EOF");
            }

            if (isHeaderLine(line)) {
                return parseHeader(subjectiveHeaders, line);
            }
        }
    }

    /**
     * Figure out how many performance rounds exist in this header line. This
     * method also checks that the corresponding table header exists for each
     * round and that the round numbers are contiguous starting at 1.
     * 
     * @throws FLLRuntimeException if there are problems with the performance
     *           round headers found
     */
    private static int countNumRounds(final String[] line) {
        final SortedSet<Integer> perfRounds = new TreeSet<Integer>();
        for (int i = 0; i < line.length; ++i) {
            if (null != line[i] && line[i].startsWith(BASE_PERF_HEADER)
                    && line[i].length() > BASE_PERF_HEADER.length()) {
                final String perfNumberStr = line[i].substring(BASE_PERF_HEADER.length());
                final Integer perfNumber = Integer.valueOf(perfNumberStr);
                if (!perfRounds.add(perfNumber)) {
                    throw new FLLRuntimeException("Found performance rounds num " + perfNumber
                            + " twice in the header: " + Arrays.asList(line));
                }
            }
        }

        /*
         * check that the values start at 1, are contiguous, and that the
         * corresponding table header exists
         */
        int expectedValue = 1;
        for (Integer value : perfRounds) {
            if (null == value) {
                throw new FLLInternalException("Found null performance round in header!");
            }
            if (expectedValue != value.intValue()) {
                throw new FLLRuntimeException(
                        "Performance rounds not contiguous after " + (expectedValue - 1) + " found " + value);
            }

            final String tableHeader = String.format(TABLE_HEADER_FORMAT, expectedValue);
            if (!checkHeaderExists(line, tableHeader)) {
                throw new FLLRuntimeException("Couldn't find header for round " + expectedValue
                        + ". Looking for header '" + tableHeader + "'");
            }

            ++expectedValue;
        }

        return perfRounds.size();
    }

    private static boolean checkHeaderExists(final String[] line, final String header) {
        return null != columnForHeader(line, header);
    }

    /**
     * Find the column that contains the specified header.
     * 
     * @return the column, null if not found
     */
    private static Integer columnForHeader(final String[] line, final String header) {
        for (int i = 0; i < line.length; ++i) {
            if (header.equals(line[i])) {
                return i;
            }
        }
        return null;
    }

    /**
     * Get the column number or throw {@link MissingColumnException} if the column
     * it
     * not found.
     */
    private static int getColumnForHeader(final String[] line, final String header) throws MissingColumnException {
        final Integer column = columnForHeader(line, header);
        if (null == column) {
            throw new MissingColumnException("Unable to find header '" + header + "' in " + Arrays.asList(line));
        } else {
            return column;
        }
    }

    private static ColumnInformation parseHeader(final Collection<String> subjectiveHeaders, final String[] line) {
        final int numPerfRounds = countNumRounds(line);

        final int[] perfColumn = new int[numPerfRounds];
        final int[] perfTableColumn = new int[numPerfRounds];

        final int teamNumColumn = getColumnForHeader(line, TEAM_NUMBER_HEADER);
        final int organizationColumn = getColumnForHeader(line, ORGANIZATION_HEADER);
        final int teamNameColumn = getColumnForHeader(line, TEAM_NAME_HEADER);

        int judgeGroupColumn = -1;
        try {
            judgeGroupColumn = getColumnForHeader(line, JUDGE_GROUP_HEADER);
        } catch (final MissingColumnException e) {
            judgeGroupColumn = -1;
        }

        int divisionColumn = -1;
        try {
            divisionColumn = getColumnForHeader(line, DIVISION_HEADER);
        } catch (final MissingColumnException e) {
            divisionColumn = -1;
        }
        if (-1 == divisionColumn) {
            try {
                divisionColumn = getColumnForHeader(line, AWARD_GROUP_HEADER);
            } catch (final MissingColumnException e) {
                divisionColumn = -1;
            }
        }

        // Need one of judge group column or division column
        if (-1 == judgeGroupColumn && -1 == divisionColumn) {
            throw new MissingColumnException("Must have judging station column or award group column");
        } else if (-1 == judgeGroupColumn) {
            judgeGroupColumn = divisionColumn;
        } else if (-1 == divisionColumn) {
            divisionColumn = judgeGroupColumn;
        }

        for (int round = 0; round < numPerfRounds; ++round) {
            final String perfHeader = String.format(PERF_HEADER_FORMAT, (round + 1));
            final String perfTableHeader = String.format(TABLE_HEADER_FORMAT, (round + 1));
            perfColumn[round] = getColumnForHeader(line, perfHeader);
            perfTableColumn[round] = getColumnForHeader(line, perfTableHeader);
        }

        final Map<Integer, String> subjectiveColumns = new HashMap<Integer, String>();
        for (final String header : subjectiveHeaders) {
            final int column = getColumnForHeader(line, header);
            subjectiveColumns.put(column, header);
        }

        return new ColumnInformation(line, teamNumColumn, organizationColumn, teamNameColumn, divisionColumn,
                subjectiveColumns, judgeGroupColumn, perfColumn, perfTableColumn);
    }

    /**
     * Parse the data of the schedule.
     * 
     * @throws IOException
     * @throws ScheduleParseException if there is an error with the schedule
     */
    private void parseData(final CellFileReader reader, final ColumnInformation ci)
            throws IOException, ParseException, ScheduleParseException {
        TeamScheduleInfo ti;
        while (null != (ti = parseLine(reader, ci))) {
            cacheTeamScheduleInformation(ti);
        }
    }

    /**
     * Add a team to the schedule. Check that the team isn't already in the
     * schedule.
     */
    private void addToSchedule(final TeamScheduleInfo ti) {
        if (!_schedule.contains(ti)) {
            _schedule.add(ti);
        } else {
            LOGGER.warn("Attempting to add the same team to the schedule twice: " + ti.getTeamNumber());
        }
    }

    /**
     * Populate internal caches with the data from this newly created
     * {@link TeamScheduleInfo}.
     */
    private void cacheTeamScheduleInformation(final TeamScheduleInfo ti) {
        addToSchedule(ti);

        // keep track of some meta information
        for (int round = 0; round < getNumberOfRounds(); ++round) {
            _tableColors.add(ti.getPerfTableColor(round));
            addToMatches(ti, round);
        }
        _awardGroups.add(ti.getAwardGroup());
        _judgingGroups.add(ti.getJudgingGroup());
    }

    /**
     * Find the team that is competing earliest after the specified time on the
     * specified table and side in the specified round.
     * 
     * @param time the time after which to find a competition
     * @param table the table color
     * @param side the side
     * @param round the round - zero based index
     */
    private TeamScheduleInfo findNextTeam(final LocalTime time, final String table, final int side,
            final int round) {
        TeamScheduleInfo retval = null;
        for (final TeamScheduleInfo si : _schedule) {
            if (table.equals(si.getPerfTableColor(round)) && side == si.getPerfTableSide(round)
                    && si.getPerfTime(round).isAfter(time)) {
                if (retval == null) {
                    retval = si;
                } else if (null != si.getPerfTime(round)
                        && si.getPerfTime(round).isBefore(retval.getPerfTime(round))) {
                    retval = si;
                }
            }
        }
        return retval;
    }

    /**
     * Check if the specified team needs to stay around after their performance to
     * even up the table.
     * 
     * @param si the TeamScheduleInfo for the team
     * @param round the round the team is competing at (zero based index)
     * @return the team one needs to compete against in an extra round or null if
     *         the team does not need to stay
     */
    public TeamScheduleInfo checkIfTeamNeedsToStay(final TeamScheduleInfo si, final int round) {
        final int otherSide = 2 == si.getPerfTableSide(round) ? 1 : 2;
        final TeamScheduleInfo next = findNextTeam(si.getPerfTime(round), si.getPerfTableColor(round), otherSide,
                round);

        if (null != next) {
            final TeamScheduleInfo nextOpponent = findOpponent(next, round);
            if (null == nextOpponent) {
                return next;
            } else {
                return null;
            }
        } else {
            return null;
        }
    }

    /**
     * Output the detailed schedule.
     * 
     * @param directory the directory to put the files in
     * @param baseFilename the base filename
     * @throws DocumentException
     * @throws IOException
     * @throws IllegalArgumentExcption if directory doesn't exist and can't be
     *           created or exists and isn't a directory
     */
    public void outputDetailedSchedules(final SchedParams params, final File directory, final String baseFilename)
            throws DocumentException, IOException {
        if (!directory.exists()) {
            if (!directory.mkdirs()) {
                throw new IllegalArgumentException("Unable to create " + directory.getAbsolutePath());
            }
        } else if (!directory.isDirectory()) {
            throw new IllegalArgumentException(directory.getAbsolutePath() + " exists, but isn't a directory");
        }

        OutputStream pdfFos = null;
        try {
            final File byDivision = new File(directory, baseFilename + "-subjective-by-division.pdf");

            pdfFos = new FileOutputStream(byDivision);
            outputSubjectiveSchedulesByJudgingStation(pdfFos);
            IOUtils.closeQuietly(pdfFos);
            pdfFos = null;

            final File byTime = new File(directory, baseFilename + "-subjective-by-time.pdf");
            pdfFos = new FileOutputStream(byTime);
            outputSubjectiveSchedulesByTime(pdfFos);
            IOUtils.closeQuietly(pdfFos);
            pdfFos = null;

            final File performance = new File(directory, baseFilename + "-performance.pdf");
            pdfFos = new FileOutputStream(performance);
            outputPerformanceScheduleByTime(pdfFos);
            IOUtils.closeQuietly(pdfFos);
            pdfFos = null;

            final File teamSchedules = new File(directory, baseFilename + "-team-schedules.pdf");
            pdfFos = new FileOutputStream(teamSchedules);
            outputTeamSchedules(params, pdfFos);
            IOUtils.closeQuietly(pdfFos);
            pdfFos = null;

        } finally {
            IOUtils.closeQuietly(pdfFos);
        }
    }

    /**
     * Output the schedule for each team.
     * 
     * @param params schedule parameters
     * @param pdfFos where to write the schedule
     * @throws DocumentException
     */
    public void outputTeamSchedules(final SchedParams params, final OutputStream pdfFos) throws DocumentException {
        Collections.sort(_schedule, ComparatorByTeam.INSTANCE);
        final Document teamDoc = PdfUtils.createPortraitPdfDoc(pdfFos, new SimpleFooterHandler());
        for (final TeamScheduleInfo si : _schedule) {
            outputTeamSchedule(params, teamDoc, si);
        }
        teamDoc.close();
    }

    /**
     * Output the performance schedule, sorted by time.
     * 
     * @param pdfFos where to write the schedule
     * @throws DocumentException
     */
    public void outputPerformanceScheduleByTime(final OutputStream pdfFos) throws DocumentException {
        final Document performanceDoc = PdfUtils.createPortraitPdfDoc(pdfFos, new SimpleFooterHandler());
        outputPerformanceSchedule(performanceDoc);
        performanceDoc.close();
    }

    /**
     * Output the subjective schedules with a table for each category and sorted
     * by time.
     * 
     * @param pdfFos where to write the schedule
     * @throws DocumentException
     */
    public void outputSubjectiveSchedulesByTime(final OutputStream pdfFos) throws DocumentException {
        final Document detailedSchedulesByTime = PdfUtils.createPortraitPdfDoc(pdfFos, new SimpleFooterHandler());
        for (final String subjectiveStation : subjectiveStations) {
            outputSubjectiveScheduleByTime(detailedSchedulesByTime, subjectiveStation);
            detailedSchedulesByTime.add(Chunk.NEXTPAGE);
        }
        detailedSchedulesByTime.close();
    }

    /**
     * Output the subjective schedules with a table for each category and sorted
     * by judging station, then by time.
     * 
     * @param pdfFos where to output the schedule
     * @throws DocumentException
     */
    public void outputSubjectiveSchedulesByJudgingStation(final OutputStream pdfFos) throws DocumentException {
        final Document detailedSchedulesByDivision = PdfUtils.createPortraitPdfDoc(pdfFos,
                new SimpleFooterHandler());
        for (final String subjectiveStation : subjectiveStations) {
            outputSubjectiveScheduleByDivision(detailedSchedulesByDivision, subjectiveStation);
            detailedSchedulesByDivision.add(Chunk.NEXTPAGE);
        }
        detailedSchedulesByDivision.close();
    }

    /**
     * Output the schedule sorted by team number. This schedule looks much like
     * the input spreadsheet.
     * 
     * @param stream where to write the schedule
     * @throws DocumentException
     */
    public void outputScheduleByTeam(final OutputStream stream) throws DocumentException {
        final Document pdf = PdfUtils.createLandscapePdfDoc(stream, new SimpleFooterHandler());

        final int numColumns = 5 + subjectiveStations.size() + getNumberOfRounds() * 2;
        final PdfPTable table = PdfUtils.createTable(numColumns);
        final float[] columnWidths = new float[numColumns];
        int idx = 0;
        columnWidths[idx] = 2; // team number
        ++idx;
        columnWidths[idx] = 3; // team name
        ++idx;
        columnWidths[idx] = 3; // organization
        ++idx;
        columnWidths[idx] = 2; // judging group
        ++idx;
        columnWidths[idx] = 2; // division
        ++idx;
        for (int i = 0; i < subjectiveStations.size(); ++i) {
            columnWidths[idx] = 2; // time
            ++idx;
        }
        for (int i = 0; i < getNumberOfRounds(); ++i) {
            columnWidths[idx] = 2; // time
            ++idx;
            columnWidths[idx] = 2; // table
            ++idx;
        }
        table.setWidths(columnWidths);

        final PdfPCell tournamentCell = PdfUtils.createHeaderCell("Tournament: " + getName());
        tournamentCell.setColspan(numColumns);

        table.addCell(tournamentCell);

        table.addCell(PdfUtils.createHeaderCell(TEAM_NUMBER_HEADER));
        table.addCell(PdfUtils.createHeaderCell(TEAM_NAME_HEADER));
        table.addCell(PdfUtils.createHeaderCell(ORGANIZATION_HEADER));
        table.addCell(PdfUtils.createHeaderCell(JUDGE_GROUP_HEADER));
        table.addCell(PdfUtils.createHeaderCell(AWARD_GROUP_HEADER));
        for (final String subjectiveStation : subjectiveStations) {
            table.addCell(PdfUtils.createHeaderCell(subjectiveStation));
        }
        for (int round = 0; round < getNumberOfRounds(); ++round) {
            table.addCell(PdfUtils.createHeaderCell(String.format(PERF_HEADER_FORMAT, round + 1)));
            table.addCell(PdfUtils.createHeaderCell(String.format(TABLE_HEADER_FORMAT, round + 1)));
        }
        table.setHeaderRows(2);

        Collections.sort(_schedule, ComparatorByTeam.INSTANCE);
        for (final TeamScheduleInfo si : _schedule) {
            table.addCell(PdfUtils.createCell(String.valueOf(si.getTeamNumber())));
            table.addCell(PdfUtils.createCell(si.getTeamName()));
            table.addCell(PdfUtils.createCell(si.getOrganization()));
            table.addCell(PdfUtils.createCell(si.getJudgingGroup()));
            table.addCell(PdfUtils.createCell(si.getAwardGroup()));

            for (final String subjectiveStation : subjectiveStations) {
                table.addCell(
                        PdfUtils.createCell(formatTime(si.getSubjectiveTimeByName(subjectiveStation).getTime())));
            }

            for (int round = 0; round < getNumberOfRounds(); ++round) {
                final PerformanceTime perf = si.getPerf(round);

                table.addCell(PdfUtils.createCell(formatTime(perf.getTime())));

                table.addCell(PdfUtils.createCell(String.format("%s %s", perf.getTable(), perf.getSide())));
            }

        }

        pdf.add(table);

        pdf.close();
    }

    private static final Font TEAM_TITLE_FONT = FontFactory.getFont(FontFactory.TIMES, 12, Font.BOLD);

    private static final Font TEAM_HEADER_FONT = FontFactory.getFont(FontFactory.HELVETICA, 10, Font.BOLD);

    private static final Font TEAM_VALUE_FONT = FontFactory.getFont(FontFactory.HELVETICA, 10, Font.NORMAL);

    /**
     * Output the detailed schedule for a team for the day.
     * 
     * @throws DocumentException
     */
    private void outputTeamSchedule(final SchedParams params, final Document detailedSchedules,
            final TeamScheduleInfo si) throws DocumentException {
        final Paragraph para = new Paragraph();
        para.add(new Chunk(
                String.format("Detailed schedule for Team #%d - %s", si.getTeamNumber(), si.getTeamName()),
                TEAM_TITLE_FONT));
        para.add(Chunk.NEWLINE);

        para.add(new Chunk(String.format("Organization: %s", si.getOrganization()), TEAM_TITLE_FONT));
        para.add(Chunk.NEWLINE);

        para.add(new Chunk("Division: ", TEAM_HEADER_FONT));
        para.add(new Chunk(si.getAwardGroup(), TEAM_VALUE_FONT));
        para.add(Chunk.NEWLINE);

        for (final String subjectiveStation : subjectiveStations) {
            para.add(new Chunk(subjectiveStation + ": ", TEAM_HEADER_FONT));
            final LocalTime start = si.getSubjectiveTimeByName(subjectiveStation).getTime();
            final LocalTime end = start.plus(params.getStationByName(subjectiveStation).getDuration());
            para.add(new Chunk(String.format("%s - %s", formatTime(start), formatTime(end)), TEAM_VALUE_FONT));
            para.add(Chunk.NEWLINE);
        }

        for (int round = 0; round < getNumberOfRounds(); ++round) {
            para.add(new Chunk(String.format(PERF_HEADER_FORMAT, round + 1) + ": ", TEAM_HEADER_FONT));
            final LocalTime start = si.getPerfTime(round);
            final LocalTime end = start.plus(Duration.ofMinutes(params.getPerformanceMinutes()));
            para.add(new Chunk(String.format("%s - %s %s %d", formatTime(start), formatTime(end),
                    si.getPerfTableColor(round), si.getPerfTableSide(round)), TEAM_VALUE_FONT));
            para.add(Chunk.NEWLINE);
        }

        para.add(Chunk.NEWLINE);
        para.add(new Chunk(
                "Performance rounds must start on time, and will start without you. Please ensure your team arrives at least 5 minutes ahead of scheduled time, and checks in.",
                TEAM_HEADER_FONT));

        para.add(Chunk.NEWLINE);
        para.add(new Chunk(
                "Note that there may be more judging and a head to head round after this judging, please see the main tournament schedule for these details.",
                TEAM_HEADER_FONT));
        para.add(Chunk.NEWLINE);
        para.add(Chunk.NEWLINE);
        para.add(Chunk.NEWLINE);

        para.setKeepTogether(true);
        detailedSchedules.add(para);
    }

    /**
     * @param dir where to write the files
     * @param baseFileName the base name of the files
     * @param description the challenge description
     * @param categoryToSchedule mapping of ScoreCategories to schedule columns
     * @throws DocumentException
     * @throws MalformedURLException
     * @throws IOException
     */
    public void outputSubjectiveSheets(final String dir, final String baseFileName,
            final ChallengeDescription description, final Map<ScoreCategory, String> categoryToSchedule)
            throws DocumentException, MalformedURLException, IOException {

        // setup the sheets from the sucked in xml
        for (final ScoreCategory category : description.getSubjectiveCategories()) {
            final SheetElement sheetElement = createSubjectiveSheetElement(category);

            final String filename = dir + File.separator + baseFileName + "_SubjectiveSheets-" + category.getName()
                    + ".pdf";

            // sort the schedule by the category we're working with
            final String subjectiveStation = categoryToSchedule.get(category);
            if (null != subjectiveStation) {
                Collections.sort(_schedule, getComparatorForSubjectiveByDivision(subjectiveStation));
            }

            final ScoreCategory scoreCategory = sheetElement.getSheetData();
            final String schedulerColumn = categoryToSchedule.get(scoreCategory);

            SubjectivePdfWriter.createDocument(filename, description, sheetElement, schedulerColumn, _schedule);
        }
    }

    public static SheetElement createSubjectiveSheetElement(final ScoreCategory sc) {
        // Get the info from the .xml sheet for the specific subjective category
        // An sc == a subjective category
        final SheetElement sheet = new SheetElement(sc);
        return sheet;
    }

    public void outputPerformanceSheets(final OutputStream output, final ChallengeDescription description)
            throws DocumentException, SQLException, IOException {
        final ScoresheetGenerator scoresheets = new ScoresheetGenerator(getNumberOfRounds() * _schedule.size(),
                description);
        final SortedMap<PerformanceTime, TeamScheduleInfo> performanceTimes = new TreeMap<PerformanceTime, TeamScheduleInfo>();
        for (int round = 0; round < getNumberOfRounds(); ++round) {
            for (final TeamScheduleInfo si : _schedule) {
                performanceTimes.put(si.getPerf(round), si);
            }
        }

        int sheetIndex = 0;
        for (final Map.Entry<PerformanceTime, TeamScheduleInfo> entry : performanceTimes.entrySet()) {
            final PerformanceTime performance = entry.getKey();
            final TeamScheduleInfo si = entry.getValue();
            final int round = si.computeRound(performance);

            scoresheets.setTime(sheetIndex, performance.getTime());
            scoresheets.setTable(sheetIndex, String.format("%s %d", performance.getTable(), performance.getSide()));
            scoresheets.setRound(sheetIndex, String.valueOf(round + 1));
            scoresheets.setNumber(sheetIndex, si.getTeamNumber());
            scoresheets.setDivision(sheetIndex, ScoresheetGenerator.AWARD_GROUP_LABEL, si.getAwardGroup());
            scoresheets.setName(sheetIndex, si.getTeamName());

            ++sheetIndex;
        }

        final boolean orientationIsPortrait = ScoresheetGenerator.guessOrientation(description);
        scoresheets.writeFile(output, orientationIsPortrait);
    }

    private void outputPerformanceSchedule(final Document detailedSchedules) throws DocumentException {
        final SortedMap<PerformanceTime, TeamScheduleInfo> performanceTimes = new TreeMap<PerformanceTime, TeamScheduleInfo>();
        for (int round = 0; round < getNumberOfRounds(); ++round) {
            for (final TeamScheduleInfo si : _schedule) {
                performanceTimes.put(si.getPerf(round), si);
            }
        }

        // list of teams staying around to even up the teams
        final List<TeamScheduleInfo> teamsStaying = new LinkedList<TeamScheduleInfo>();

        final PdfPTable table = PdfUtils.createTable(7);
        table.setWidths(new float[] { 2, 1, 3, 3, 2, 2, 2 });

        final PdfPCell tournamentCell = PdfUtils.createHeaderCell("Tournament: " + getName() + " Performance");
        tournamentCell.setColspan(7);
        table.addCell(tournamentCell);

        table.addCell(PdfUtils.createHeaderCell(TEAM_NUMBER_HEADER));
        table.addCell(PdfUtils.createHeaderCell(DIVISION_HEADER));
        table.addCell(PdfUtils.createHeaderCell(ORGANIZATION_HEADER));
        table.addCell(PdfUtils.createHeaderCell(TEAM_NAME_HEADER));
        table.addCell(PdfUtils.createHeaderCell("Time"));
        table.addCell(PdfUtils.createHeaderCell("Table"));
        table.addCell(PdfUtils.createHeaderCell("Round"));
        table.setHeaderRows(1);

        for (final Map.Entry<PerformanceTime, TeamScheduleInfo> entry : performanceTimes.entrySet()) {
            final PerformanceTime performance = entry.getKey();
            final TeamScheduleInfo si = entry.getValue();
            final int round = si.computeRound(performance);

            // check if team needs to stay and color the cell magenta if they do
            final BaseColor backgroundColor;
            if (null != checkIfTeamNeedsToStay(si, round)) {
                teamsStaying.add(si);
                backgroundColor = BaseColor.MAGENTA;
            } else {
                backgroundColor = null;
            }

            table.addCell(PdfUtils.createCell(String.valueOf(si.getTeamNumber())));
            table.addCell(PdfUtils.createCell(si.getAwardGroup()));
            table.addCell(PdfUtils.createCell(si.getOrganization()));
            table.addCell(PdfUtils.createCell(si.getTeamName()));
            table.addCell(PdfUtils.createCell(formatTime(si.getPerfTime(round)), backgroundColor));
            table.addCell(PdfUtils.createCell(si.getPerfTableColor(round) + " " + si.getPerfTableSide(round),
                    backgroundColor));
            table.addCell(PdfUtils.createCell(String.valueOf(round + 1)));
        }

        detailedSchedules.add(table);

        // output teams staying
        if (!teamsStaying.isEmpty()) {
            final String formatString = "Team %d will please stay at the table and compete again - score will not count.";
            final PdfPTable stayingTable = PdfUtils.createTable(1);
            for (final TeamScheduleInfo si : teamsStaying) {
                stayingTable.addCell(PdfUtils.createCell(
                        new Formatter().format(formatString, si.getTeamNumber()).toString(), BaseColor.MAGENTA));
            }
            detailedSchedules.add(stayingTable);

        }

        detailedSchedules.add(Chunk.NEXTPAGE);
    }

    private void outputSubjectiveScheduleByDivision(final Document detailedSchedules,
            final String subjectiveStation) throws DocumentException {
        final PdfPTable table = PdfUtils.createTable(6);
        table.setWidths(new float[] { 2, 1, 3, 3, 2, 2 });

        final PdfPCell tournamentCell = PdfUtils
                .createHeaderCell("Tournament: " + getName() + " - " + subjectiveStation);
        tournamentCell.setColspan(6);
        table.addCell(tournamentCell);

        table.addCell(PdfUtils.createHeaderCell(TEAM_NUMBER_HEADER));
        table.addCell(PdfUtils.createHeaderCell(AWARD_GROUP_HEADER));
        table.addCell(PdfUtils.createHeaderCell(ORGANIZATION_HEADER));
        table.addCell(PdfUtils.createHeaderCell(TEAM_NAME_HEADER));
        table.addCell(PdfUtils.createHeaderCell(subjectiveStation));
        table.addCell(PdfUtils.createHeaderCell(JUDGE_GROUP_HEADER));
        table.setHeaderRows(2);

        Collections.sort(_schedule, getComparatorForSubjectiveByDivision(subjectiveStation));
        for (final TeamScheduleInfo si : _schedule) {
            table.addCell(PdfUtils.createCell(String.valueOf(si.getTeamNumber())));
            table.addCell(PdfUtils.createCell(si.getAwardGroup()));
            table.addCell(PdfUtils.createCell(si.getOrganization()));
            table.addCell(PdfUtils.createCell(si.getTeamName()));
            table.addCell(PdfUtils.createCell(formatTime(si.getSubjectiveTimeByName(subjectiveStation).getTime())));
            table.addCell(PdfUtils.createCell(si.getJudgingGroup()));
        }

        detailedSchedules.add(table);

    }

    private void outputSubjectiveScheduleByTime(final Document detailedSchedules, final String subjectiveStation)
            throws DocumentException {
        final PdfPTable table = PdfUtils.createTable(6);
        table.setWidths(new float[] { 2, 1, 3, 3, 2, 2 });

        final PdfPCell tournamentCell = PdfUtils
                .createHeaderCell("Tournament: " + getName() + " - " + subjectiveStation);
        tournamentCell.setColspan(6);
        table.addCell(tournamentCell);

        table.addCell(PdfUtils.createHeaderCell(TEAM_NUMBER_HEADER));
        table.addCell(PdfUtils.createHeaderCell(AWARD_GROUP_HEADER));
        table.addCell(PdfUtils.createHeaderCell(ORGANIZATION_HEADER));
        table.addCell(PdfUtils.createHeaderCell(TEAM_NAME_HEADER));
        table.addCell(PdfUtils.createHeaderCell(subjectiveStation));
        table.addCell(PdfUtils.createHeaderCell(JUDGE_GROUP_HEADER));
        table.setHeaderRows(2);

        Collections.sort(_schedule, getComparatorForSubjectiveByTime(subjectiveStation));
        for (final TeamScheduleInfo si : _schedule) {
            table.addCell(PdfUtils.createCell(String.valueOf(si.getTeamNumber())));
            table.addCell(PdfUtils.createCell(si.getAwardGroup()));
            table.addCell(PdfUtils.createCell(si.getOrganization()));
            table.addCell(PdfUtils.createCell(si.getTeamName()));
            table.addCell(PdfUtils.createCell(formatTime(si.getSubjectiveTimeByName(subjectiveStation).getTime())));
            table.addCell(PdfUtils.createCell(si.getJudgingGroup()));
        }

        detailedSchedules.add(table);

    }

    /**
     * Get the comparator for outputting the schedule for the specified subjective
     * station. Sort by division, then judge, then by time.
     */
    private Comparator<TeamScheduleInfo> getComparatorForSubjectiveByDivision(final String name) {
        return new SubjectiveComparatorByDivision(name);
    }

    /**
     * Get the comparator for outputting the schedule for the specified subjective
     * station. Sort by time, division, then judge.
     */
    private Comparator<TeamScheduleInfo> getComparatorForSubjectiveByTime(final String name) {
        return new SubjectiveComparatorByTime(name);
    }

    /**
     * Comparator for for sorting by the specified subjective station.
     */
    private static class SubjectiveComparatorByDivision implements Comparator<TeamScheduleInfo>, Serializable {
        private final String name;

        public SubjectiveComparatorByDivision(final String name) {
            this.name = name;
        }

        public int compare(final TeamScheduleInfo one, final TeamScheduleInfo two) {

            if (!one.getAwardGroup().equals(two.getAwardGroup())) {
                return one.getAwardGroup().compareTo(two.getAwardGroup());
            } else {
                final SubjectiveTime oneTime = one.getSubjectiveTimeByName(name);
                final SubjectiveTime twoTime = two.getSubjectiveTimeByName(name);

                final int timeCompare;
                if (oneTime == null) {
                    if (twoTime == null) {
                        timeCompare = 0;
                    } else {
                        timeCompare = -1;
                    }
                } else {
                    if (null == twoTime) {
                        timeCompare = 1;
                    } else {
                        timeCompare = oneTime.getTime().compareTo(twoTime.getTime());
                    }
                }
                if (timeCompare == 0) {
                    return one.getJudgingGroup().compareTo(two.getJudgingGroup());
                } else {
                    return timeCompare;
                }
            }
        }
    }

    /**
     * Comparator for for sorting by the specified subjective station.
     */
    private static class SubjectiveComparatorByTime implements Comparator<TeamScheduleInfo>, Serializable {
        private final String name;

        public SubjectiveComparatorByTime(final String name) {
            this.name = name;
        }

        public int compare(final TeamScheduleInfo one, final TeamScheduleInfo two) {

            final SubjectiveTime oneTime = one.getSubjectiveTimeByName(name);
            final SubjectiveTime twoTime = two.getSubjectiveTimeByName(name);

            final int timeCompare;
            if (oneTime == null) {
                if (twoTime == null) {
                    timeCompare = 0;
                } else {
                    timeCompare = -1;
                }
            } else {
                if (null == twoTime) {
                    timeCompare = 1;
                } else {
                    timeCompare = oneTime.getTime().compareTo(twoTime.getTime());
                }
            }
            if (timeCompare == 0) {
                if (!one.getAwardGroup().equals(two.getAwardGroup())) {
                    return one.getAwardGroup().compareTo(two.getAwardGroup());
                } else {
                    return one.getJudgingGroup().compareTo(two.getJudgingGroup());
                }
            } else {
                return timeCompare;
            }
        }
    }

    /**
     * Comparator for for sorting by team number.
     */
    private static class ComparatorByTeam implements Comparator<TeamScheduleInfo>, Serializable {

        public static final ComparatorByTeam INSTANCE = new ComparatorByTeam();

        private ComparatorByTeam() {
        }

        public int compare(final TeamScheduleInfo one, final TeamScheduleInfo two) {

            return Integer.compare(one.getTeamNumber(), two.getTeamNumber());
        }
    }

    /**
     * Compute the general schedule and return it as a string
     */
    public String computeGeneralSchedule() {
        LocalTime minPerf = null;
        LocalTime maxPerf = null;
        // division -> date
        final Map<String, LocalTime> minSubjectiveTimes = new HashMap<>();
        final Map<String, LocalTime> maxSubjectiveTimes = new HashMap<>();

        for (final TeamScheduleInfo si : _schedule) {
            final String judgingStation = si.getJudgingGroup();
            for (final SubjectiveTime stime : si.getSubjectiveTimes()) {
                final LocalTime currentMin = minSubjectiveTimes.get(judgingStation);
                if (null == currentMin) {
                    minSubjectiveTimes.put(judgingStation, stime.getTime());
                } else {
                    if (stime.getTime().isBefore(currentMin)) {
                        minSubjectiveTimes.put(judgingStation, stime.getTime());
                    }
                }
                final LocalTime currentMax = maxSubjectiveTimes.get(judgingStation);
                if (null == currentMax) {
                    maxSubjectiveTimes.put(judgingStation, stime.getTime());
                } else {
                    if (stime.getTime().isAfter(currentMax)) {
                        maxSubjectiveTimes.put(judgingStation, stime.getTime());
                    }
                }

            }

            for (int i = 0; i < getNumberOfRounds(); ++i) {
                if (null != si.getPerfTime(i)) {
                    if (null == minPerf || si.getPerfTime(i).isBefore(minPerf)) {
                        minPerf = si.getPerfTime(i);
                    }

                    if (null == maxPerf || si.getPerfTime(i).isAfter(maxPerf)) {
                        maxPerf = si.getPerfTime(i);
                    }
                }
            }

        }

        // print out the general schedule
        final Formatter output = new Formatter();
        final Set<String> stations = new HashSet<String>();
        stations.addAll(minSubjectiveTimes.keySet());
        stations.addAll(maxSubjectiveTimes.keySet());
        for (final String station : stations) {
            final LocalTime earliestStart = minSubjectiveTimes.get(station);
            final LocalTime latestStart = maxSubjectiveTimes.get(station);
            final Duration subjectiveDuration = Duration.ofMinutes(SolverParams.DEFAULT_SUBJECTIVE_MINUTES);
            final LocalTime latestEnd = latestStart.plus(subjectiveDuration);

            output.format(
                    "Subjective times for judging station %s: %s - %s (assumes default subjective time of %d minutes)%n",
                    station, formatTime(earliestStart), formatTime(latestEnd),
                    SolverParams.DEFAULT_SUBJECTIVE_MINUTES);
        }
        if (null != minPerf && null != maxPerf) {
            final Duration performanceDuration = Duration.ofMinutes(SolverParams.DEFAULT_PERFORMANCE_MINUTES);
            final LocalTime performanceEnd = maxPerf.plus(performanceDuration);

            output.format("Performance times: %s - %s (assumes default performance time of %d minutes)%n",
                    formatTime(minPerf), formatTime(performanceEnd), SolverParams.DEFAULT_PERFORMANCE_MINUTES);
        }
        return output.toString();
    }

    /**
     * Add the data from the specified round of the specified TeamScheduleInfo to
     * matches.
     * 
     * @param ti the schedule info
     * @param round the round we care about
     */
    private void addToMatches(final TeamScheduleInfo ti, final int round) {
        final Map<String, List<TeamScheduleInfo>> timeMatches;
        if (_matches.containsKey(ti.getPerfTime(round))) {
            timeMatches = _matches.get(ti.getPerfTime(round));
        } else {
            timeMatches = new HashMap<String, List<TeamScheduleInfo>>();
            _matches.put(ti.getPerfTime(round), timeMatches);
        }

        final List<TeamScheduleInfo> tableMatches;
        if (timeMatches.containsKey(ti.getPerfTableColor(round))) {
            tableMatches = timeMatches.get(ti.getPerfTableColor(round));
        } else {
            tableMatches = new LinkedList<TeamScheduleInfo>();
            timeMatches.put(ti.getPerfTableColor(round), tableMatches);
        }

        tableMatches.add(ti);
    }

    private void removeFromMatches(final TeamScheduleInfo ti, final int round) {
        if (!_matches.containsKey(ti.getPerfTime(round))) {
            throw new IllegalArgumentException("Cannot find time info for " + round + " in matches");
        }
        final Map<String, List<TeamScheduleInfo>> timeMatches = _matches.get(ti.getPerfTime(round));
        if (!timeMatches.containsKey(ti.getPerfTableColor(round))) {
            throw new IllegalArgumentException("Cannot find table info for " + round + " in matches");
        }
        final List<TeamScheduleInfo> tableMatches = timeMatches.get(ti.getPerfTableColor(round));
        if (!tableMatches.remove(ti)) {
            throw new IllegalArgumentException(
                    "Cannot find team " + ti.getTeamNumber() + " in the matches for round " + round);
        }
    }

    /**
     * Find the round of the opponent for a given team in a given round.
     * 
     * @param ti
     * @param round
     * @return the round number or -1 if no opponent
     */
    public int findOpponentRound(final TeamScheduleInfo ti, final int round) {
        final List<TeamScheduleInfo> tableMatches = _matches.get(ti.getPerfTime(round))
                .get(ti.getPerfTableColor(round));
        if (tableMatches.size() > 1) {
            if (tableMatches.get(0).equals(ti)) {
                return tableMatches.get(1).findRoundFortime(ti.getPerfTime(round));
            } else {
                return tableMatches.get(0).findRoundFortime(ti.getPerfTime(round));
            }
        } else {
            return -1;
        }
    }

    /**
     * Find the opponent for a given team in a given round.
     * 
     * @param ti
     * @param round
     * @return the team number or null if no opponent
     */
    public TeamScheduleInfo findOpponent(final TeamScheduleInfo ti, final int round) {
        final List<TeamScheduleInfo> tableMatches = _matches.get(ti.getPerfTime(round))
                .get(ti.getPerfTableColor(round));
        if (tableMatches.size() > 1) {
            if (tableMatches.get(0).equals(tableMatches.get(1))) {
                throw new FLLRuntimeException(
                        "Internal error, _matches is inconsistent. Has team competing against itself");
            }
            if (tableMatches.get(0).equals(ti)) {
                return tableMatches.get(1);
            } else {
                return tableMatches.get(0);
            }
        } else {
            return null;
        }
    }

    /**
     * @return the schedule info or null if the last line is read
     * @throws ScheduleParseException if there is an error with the schedule being
     *           read
     */
    private TeamScheduleInfo parseLine(final CellFileReader reader, final ColumnInformation ci)
            throws IOException, ParseException, ScheduleParseException {
        final String[] line = reader.readNext();
        if (null == line) {
            return null;
        }

        try {

            if (ci.getTeamNumColumn() >= line.length) {
                return null;
            }
            final String teamNumberStr = line[ci.getTeamNumColumn()];
            if (null == teamNumberStr || teamNumberStr.length() < 1) {
                // hit empty row
                return null;
            }

            final int teamNumber = Utilities.NUMBER_FORMAT_INSTANCE.parse(teamNumberStr).intValue();
            final TeamScheduleInfo ti = new TeamScheduleInfo(getNumberOfRounds(), teamNumber);
            ti.setTeamName(line[ci.getTeamNameColumn()]);
            ti.setOrganization(line[ci.getOrganizationColumn()]);
            ti.setDivision(line[ci.getDivisionColumn()]);

            for (final Map.Entry<Integer, String> entry : ci.getSubjectiveColumnInfo().entrySet()) {
                final String station = entry.getValue();
                final int column = entry.getKey();
                final String str = line[column];
                if (str.isEmpty()) {
                    // If we got an empty string, then we must have hit the end
                    return null;
                }
                final LocalTime time = parseTime(str);
                ti.addSubjectiveTime(new SubjectiveTime(station, time));
            }

            ti.setJudgingGroup(line[ci.getJudgeGroupColumn()]);

            for (int perfNum = 0; perfNum < getNumberOfRounds(); ++perfNum) {
                final String perf1Str = line[ci.getPerfColumn(perfNum)];
                if (perf1Str.isEmpty()) {
                    // If we got an empty string, then we must have hit the end
                    return null;
                }
                String table = line[ci.getPerfTableColumn(perfNum)];
                String[] tablePieces = table.split(" ");
                if (tablePieces.length != 2) {
                    throw new RuntimeException("Error parsing table information from: " + table);
                }
                final LocalTime perf1Time = parseTime(perf1Str);
                final PerformanceTime performance = new PerformanceTime(perf1Time, tablePieces[0],
                        Utilities.NUMBER_FORMAT_INSTANCE.parse(tablePieces[1]).intValue());
                ti.setPerf(perfNum, performance);
                if (ti.getPerfTableSide(perfNum) > 2 || ti.getPerfTableSide(perfNum) < 1) {
                    final String message = "There are only two sides to the table, number must be 1 or 2 team: "
                            + ti.getTeamNumber() + " round " + (perfNum + 1);
                    LOGGER.error(message);
                    throw new ScheduleParseException(message);
                }
            }

            return ti;
        } catch (final ParseException pe) {
            LOGGER.error("Error parsing line: " + Arrays.toString(line), pe);
            throw pe;
        }
    }

    /**
     * Check if the schedule exists in the database.
     * 
     * @param connection database connection
     * @param tournamentID ID of the tournament to look for
     * @return if a schedule exists in the database for the specified tournament
     * @throws SQLException
     */
    public static boolean scheduleExistsInDatabase(final Connection connection, final int tournamentID)
            throws SQLException {
        ResultSet rs = null;
        PreparedStatement prep = null;
        try {
            prep = connection.prepareStatement("SELECT COUNT(team_number) FROM schedule where tournament = ?");
            prep.setInt(1, tournamentID);
            rs = prep.executeQuery();
            if (rs.next()) {
                return rs.getInt(1) > 0;
            } else {
                return false;
            }
        } finally {
            SQLFunctions.close(rs);
            rs = null;
            SQLFunctions.close(prep);
            prep = null;
        }
    }

    /**
     * Store a tournament schedule in the database. This will delete any previous
     * schedule for the same tournament.
     * 
     * @param tournamentID the ID of the tournament
     */
    public void storeSchedule(final Connection connection, final int tournamentID) throws SQLException {
        PreparedStatement deletePerfRounds = null;
        PreparedStatement deleteSchedule = null;
        PreparedStatement deleteSubjective = null;
        PreparedStatement insertSchedule = null;
        PreparedStatement insertPerfRounds = null;
        PreparedStatement insertSubjective = null;
        try {
            // delete previous tournament schedule
            deletePerfRounds = connection.prepareStatement("DELETE FROM sched_perf_rounds WHERE tournament = ?");
            deletePerfRounds.setInt(1, tournamentID);
            deletePerfRounds.executeUpdate();

            deleteSubjective = connection.prepareStatement("DELETE FROM sched_subjective WHERE tournament = ?");
            deleteSubjective.setInt(1, tournamentID);
            deleteSubjective.executeUpdate();

            deleteSchedule = connection.prepareStatement("DELETE FROM schedule WHERE tournament = ?");
            deleteSchedule.setInt(1, tournamentID);
            deleteSchedule.executeUpdate();

            // insert new tournament schedule
            insertSchedule = connection.prepareStatement("INSERT INTO schedule"//
                    + " (tournament, team_number, judging_station)"//
                    + " VALUES(?, ?, ?)");
            insertSchedule.setInt(1, tournamentID);

            insertPerfRounds = connection.prepareStatement("INSERT INTO sched_perf_rounds"//
                    + " (tournament, team_number, round, perf_time, table_color, table_side)"//
                    + " VALUES(?, ?, ?, ?, ?, ?)");
            insertPerfRounds.setInt(1, tournamentID);

            insertSubjective = connection.prepareStatement("INSERT INTO sched_subjective" //
                    + " (tournament, team_number, name, subj_time)" //
                    + " VALUES(?, ?, ?, ?)");
            insertSubjective.setInt(1, tournamentID);

            for (final TeamScheduleInfo si : getSchedule()) {
                insertSchedule.setInt(2, si.getTeamNumber());
                insertSchedule.setString(3, si.getJudgingGroup());
                insertSchedule.executeUpdate();

                insertPerfRounds.setInt(2, si.getTeamNumber());
                for (int round = 0; round < si.getNumberOfRounds(); ++round) {
                    insertPerfRounds.setInt(3, round);
                    insertPerfRounds.setTime(4, Time.valueOf(si.getPerfTime(round)));
                    insertPerfRounds.setString(5, si.getPerfTableColor(round));
                    insertPerfRounds.setInt(6, si.getPerfTableSide(round));
                    insertPerfRounds.executeUpdate();
                }

                for (final SubjectiveTime subjectiveTime : si.getSubjectiveTimes()) {
                    insertSubjective.setInt(2, si.getTeamNumber());
                    insertSubjective.setString(3, subjectiveTime.getName());
                    insertSubjective.setTime(4, Time.valueOf(subjectiveTime.getTime()));
                    insertSubjective.executeUpdate();
                }
            }

        } finally {
            SQLFunctions.close(deletePerfRounds);
            deletePerfRounds = null;
            SQLFunctions.close(deleteSchedule);
            deleteSchedule = null;
            SQLFunctions.close(deleteSubjective);
            deleteSubjective = null;
            SQLFunctions.close(insertSchedule);
            insertSchedule = null;
            SQLFunctions.close(insertPerfRounds);
            insertPerfRounds = null;
            SQLFunctions.close(insertSubjective);
            insertSubjective = null;
        }
    }

    /**
     * Check if the current schedule is consistent with the specified tournament
     * in the database.
     * 
     * @param connection the database connection
     * @param tournamentID the tournament to check
     * @return the constraint violations, empty if no violations
     * @throws SQLException
     */
    public Collection<ConstraintViolation> compareWithDatabase(final Connection connection, final int tournamentID)
            throws SQLException {
        final Collection<ConstraintViolation> violations = new LinkedList<ConstraintViolation>();
        final Map<Integer, TournamentTeam> dbTeams = Queries.getTournamentTeams(connection, tournamentID);
        final Set<Integer> scheduleTeamNumbers = new HashSet<Integer>();
        for (final TeamScheduleInfo si : _schedule) {
            scheduleTeamNumbers.add(si.getTeamNumber());
            if (!dbTeams.containsKey(si.getTeamNumber())) {
                // TODO: could support adding teams to the database based upon the
                // schedule
                violations.add(new ConstraintViolation(true, si.getTeamNumber(), null, null, null,
                        "Team " + si.getTeamNumber() + " is in schedule, but not in database"));
            }

            if (si.getJudgingGroup().isEmpty()) {
                violations.add(new ConstraintViolation(true, si.getTeamNumber(), null, null, null,
                        "Team " + si.getTeamNumber() + " has no judging station specified"));
            }
        }
        for (final Integer dbNum : dbTeams.keySet()) {
            if (!scheduleTeamNumbers.contains(dbNum)) {
                violations.add(new ConstraintViolation(true, dbNum, null, null, null,
                        "Team " + dbNum + " is in database, but not in schedule"));
            }
        }

        return violations;
    }

    /**
     * Keep track of column information from a spreadsheet.
     */
    public static final class ColumnInformation {

        private final List<String> headerLine;

        /**
         * The columns that were parsed into the headers in the same order that they
         * appear in the schedule.
         */
        public List<String> getHeaderLine() {
            return headerLine;
        }

        private final int teamNumColumn;

        public int getTeamNumColumn() {
            return teamNumColumn;
        }

        private final int organizationColumn;

        public int getOrganizationColumn() {
            return organizationColumn;
        }

        private final int teamNameColumn;

        public int getTeamNameColumn() {
            return teamNameColumn;
        }

        private final int divisionColumn;

        public int getDivisionColumn() {
            return divisionColumn;
        }

        private final Map<Integer, String> subjectiveColumns;

        public Map<Integer, String> getSubjectiveColumnInfo() {
            return subjectiveColumns;
        }

        private final int judgeGroupColumn;

        public int getJudgeGroupColumn() {
            return judgeGroupColumn;
        }

        private final int[] perfColumn;

        public int getNumPerfs() {
            return perfColumn.length;
        }

        public int getPerfColumn(final int round) {
            return perfColumn[round];
        }

        private final int[] perfTableColumn;

        public int getPerfTableColumn(final int round) {
            return perfTableColumn[round];
        }

        public ColumnInformation(final String[] headerLine, final int teamNumColumn, final int organizationColumn,
                final int teamNameColumn, final int divisionColumn, final Map<Integer, String> subjectiveColumns,
                final int judgeGroupColumn, final int[] perfColumn, final int[] perfTableColumn) {
            this.headerLine = Collections.unmodifiableList(Arrays.asList(headerLine));
            this.teamNumColumn = teamNumColumn;
            this.organizationColumn = organizationColumn;
            this.teamNameColumn = teamNameColumn;
            this.divisionColumn = divisionColumn;
            this.subjectiveColumns = subjectiveColumns;
            this.judgeGroupColumn = judgeGroupColumn;
            this.perfColumn = new int[perfColumn.length];
            System.arraycopy(perfColumn, 0, this.perfColumn, 0, perfColumn.length);
            this.perfTableColumn = new int[perfTableColumn.length];
            System.arraycopy(perfTableColumn, 0, this.perfTableColumn, 0, perfTableColumn.length);

            // determine which columns aren't used
            final List<String> unused = new LinkedList<String>();
            for (int column = 0; column < this.headerLine.size(); ++column) {
                boolean match = false;
                if (column == this.teamNumColumn //
                        || column == this.organizationColumn //
                        || column == this.teamNameColumn //
                        || column == this.divisionColumn //
                        || column == this.judgeGroupColumn //
                ) {
                    match = true;
                }
                for (final int pc : this.perfColumn) {
                    if (pc == column) {
                        match = true;
                    }
                }
                for (final int ptc : this.perfTableColumn) {
                    if (ptc == column) {
                        match = true;
                    }
                }
                for (final int sc : this.subjectiveColumns.keySet()) {
                    if (sc == column) {
                        match = true;
                    }
                }
                if (!match) {
                    unused.add(this.headerLine.get(column));
                }
            }
            this.unusedColumns = Collections.unmodifiableList(unused);
        }

        private final List<String> unusedColumns;

        /**
         * @return the column names that were in the header line, but not used.
         */
        public List<String> getUnusedColumns() {
            return unusedColumns;
        }
    }

    /**
     * Find the {@link TeamScheduleInfo} object for the specified team number.
     * 
     * @return the schedule info or null if not found
     */
    public TeamScheduleInfo findScheduleInfo(final int team) {
        for (final TeamScheduleInfo si : _schedule) {
            if (si.getTeamNumber() == team) {
                return si;
            }
        }
        return null;
    }

    /**
     * @param team the team to rescheduled
     * @param oldTime the old time that the team was scheduled at
     * @param perfTime the new performance information
     */
    public void reassignTable(final int team, final LocalTime oldTime, final PerformanceTime perfTime) {
        final TeamScheduleInfo si = findScheduleInfo(team);
        if (null == si) {
            throw new IllegalArgumentException("Cannot find team " + team + " in the schedule");
        }
        final int round = si.findRoundFortime(oldTime);
        if (-1 == round) {
            throw new IllegalArgumentException("Team " + team + " does not have a performance at " + oldTime);
        }

        removeFromMatches(si, round);
        si.setPerf(round, perfTime);
        addToMatches(si, round);
    }

    /**
     * Convert the schedule to an XML document that conforms to
     * fll/resources/schedule.xsd. The document that is returned has already been
     * run through {@link #validateXML(org.w3c.dom.Document)}.
     */
    public org.w3c.dom.Document createXML() {
        final org.w3c.dom.Document document = XMLUtils.DOCUMENT_BUILDER.newDocument();

        final Element top = document.createElementNS(null, "schedule");
        document.appendChild(top);

        for (final TeamScheduleInfo si : getSchedule()) {
            final Element team = document.createElementNS(null, "team");
            top.appendChild(team);
            team.setAttributeNS(null, "number", String.valueOf(si.getTeamNumber()));
            team.setAttributeNS(null, "judging_station", si.getJudgingGroup());

            for (final String subjName : si.getKnownSubjectiveStations()) {
                final LocalTime time = si.getSubjectiveTimeByName(subjName).getTime();
                final Element subjective = document.createElementNS(null, "subjective");
                team.appendChild(subjective);
                subjective.setAttributeNS(null, "name", subjName);
                subjective.setAttributeNS(null, "time", XML_TIME_FORMAT.format(time));
            }

            for (int round = 0; round < si.getNumberOfRounds(); ++round) {
                final PerformanceTime perfTime = si.getPerf(round);
                final Element perf = document.createElementNS(null, "performance");
                team.appendChild(perf);
                perf.setAttributeNS(null, "round", String.valueOf(round + 1));
                perf.setAttributeNS(null, "table_color", perfTime.getTable());
                perf.setAttributeNS(null, "table_side", String.valueOf(perfTime.getSide()));
                perf.setAttributeNS(null, "time", XML_TIME_FORMAT.format(perfTime.getTime()));
            }
        }

        try {
            validateXML(document);
        } catch (final SAXException e) {
            throw new FLLInternalException("Schedule XML document is invalid", e);
        }

        return document;
    }

    /**
     * Validate the schedule XML document.
     * 
     * @throws SAXException on an error
     */
    public static void validateXML(final org.w3c.dom.Document document) throws SAXException {
        try {
            final ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

            final SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
            final Source schemaFile = new StreamSource(
                    classLoader.getResourceAsStream("fll/resources/schedule.xsd"));
            final Schema schema = factory.newSchema(schemaFile);

            final Validator validator = schema.newValidator();
            validator.validate(new DOMSource(document));
        } catch (final IOException e) {
            throw new RuntimeException("Internal error, should never get IOException here", e);
        }

    }

    /**
     * Write out to the specified writer.
     * 
     * @param outputWriter
     * @throws IOException
     */
    public void writeToCSV(final Writer outputWriter) throws IOException {
        try (final CSVWriter csv = new CSVWriter(outputWriter)) {

            final List<String> line = new ArrayList<String>();
            line.add(TournamentSchedule.TEAM_NUMBER_HEADER);
            line.add(TournamentSchedule.AWARD_GROUP_HEADER);
            line.add(TournamentSchedule.TEAM_NAME_HEADER);
            line.add(TournamentSchedule.ORGANIZATION_HEADER);
            line.add(TournamentSchedule.JUDGE_GROUP_HEADER);
            final List<String> categories = Collections
                    .unmodifiableList(new LinkedList<String>(getSubjectiveStations()));
            for (final String category : categories) {
                line.add(category);
            }
            for (int round = 0; round < getNumberOfRounds(); ++round) {
                line.add(String.format(TournamentSchedule.PERF_HEADER_FORMAT, round + 1));
                line.add(String.format(TournamentSchedule.TABLE_HEADER_FORMAT, round + 1));
            }
            csv.writeNext(line.toArray(new String[line.size()]));
            line.clear();

            for (final TeamScheduleInfo si : getSchedule()) {
                line.add(String.valueOf(si.getTeamNumber()));
                line.add(si.getAwardGroup());
                line.add(si.getTeamName());
                line.add(si.getOrganization());
                line.add(si.getJudgingGroup());
                for (final String category : categories) {
                    final LocalTime d = si.getSubjectiveTimeByName(category).getTime();
                    line.add(TournamentSchedule.formatTime(d));
                }
                for (int round = 0; round < getNumberOfRounds(); ++round) {
                    final PerformanceTime p = si.getPerf(round);
                    line.add(TournamentSchedule.formatTime(p.getTime()));
                    line.add(p.getTable() + " " + p.getSide());
                }
                csv.writeNext(line.toArray(new String[line.size()]));
                line.clear();
            }
        }
    }

    /**
     * Write out the current schedule to a CSV file.
     * 
     * @param outputFile where to write
     * @throws IOException
     */
    public void writeToCSV(final File outputFile) throws IOException {
        try (final Writer writer = new OutputStreamWriter(new FileOutputStream(outputFile),
                Utilities.DEFAULT_CHARSET)) {
            writeToCSV(writer);
        }
    }

    public static class MissingColumnException extends FLLRuntimeException {
        public MissingColumnException(final String message) {
            super(message);
        }
    }

}