#!/usr/bin/env python2.7

from __future__ import absolute_import, division, print_function
import argparse
from collections import defaultdict
import subprocess
import sys

import nfldb

import nflgame
import nflgame.live


_batch_size = 150
"""
The number of games to batch before sending data to the database
server.
"""


def log(*args, **kwargs):
    kwargs['file'] = sys.stderr
    print(*args, **kwargs)
    sys.stderr.flush()


def game_from_id(cursor, gsis_id):
    """
    Returns an `nfldb.Game` object given its GSIS identifier.
    Namely, it looks for a completed or in progress game in nflgame's
    schedule, otherwise it creates a dummy `nfldb.Game` object with
    data from the schedule.
    """
    schedule = nflgame.schedule.games_byid[gsis_id]
    start_time = nfldb.types._nflgame_start_time(schedule)
    if (start_time - nfldb.now()).total_seconds() >= 900:
        # Bail quickly if the game isn't close to starting yet.
        return game_from_schedule(cursor, gsis_id)

    g = nflgame.game.Game(gsis_id)
    if g is None:  # Whoops. I guess the pregame hasn't started yet?
        return game_from_schedule(cursor, gsis_id)
    return nfldb.Game._from_nflgame(cursor.connection, g)


def game_from_schedule(cursor, gsis_id):
    """
    Returns an `nfldb.Game` object from schedule data in
    `nflgame.schedule`.

    This is useful when you want to avoid initializing a
    `nflgame.game.Game` object.
    """
    s = nflgame.schedule.games_byid[gsis_id]
    return nfldb.Game._from_schedule(cursor.connection, s)


def update_season_state(cursor):
    phase_map = nfldb.types.Enums._nflgame_season_phase

    nflgame.live._update_week_number()
    typ = phase_map[nflgame.live._cur_season_phase]
    cursor.execute('''
        UPDATE meta SET season_type = %s, season_year = %s, week = %s
    ''', (typ, nflgame.live._cur_year, nflgame.live._cur_week))


def update_players(cursor, interval):
    db = cursor.connection
    cursor.execute('SELECT last_roster_download FROM meta')
    last = cursor.fetchone()['last_roster_download']
    update_due = (nfldb.now() - last).total_seconds() >= interval
    num_existing = nfldb.db._num_rows(cursor, 'player')

    # The interval only applies if the player table has data in it.
    # If it's empty, we always want to try and update regardless of interval.
    if not update_due and num_existing > 0:
        return

    log('Updating player JSON database... (last update was %s)' % last)
    cmd = ['nflgame-update-players', '--no-block']
    try:
        subprocess.check_call(cmd, stdout=sys.stdout, stderr=sys.stderr)
    except subprocess.CalledProcessError as e:
        log('`%s` failed (exit status %d)' % (' '.join(e.cmd), e.returncode))
        return
    except OSError as e:
        log('`%s` failed [Errno %d]: %s'
            % (' '.join(cmd), e.errno, e.strerror))
        return
    log('done.')

    # Reset the player JSON database.
    nflgame.players = nflgame.player._create_players()

    log('Locking player table...')
    cursor.execute('''
        LOCK TABLE player IN SHARE ROW EXCLUSIVE MODE
    ''')

    log('Updating %d players... ' % len(nflgame.players), end='')
    if num_existing == 0:  # Fast path the initial insert.
        insert = []
        for p in nflgame.players.itervalues():
            insert.append(nfldb.Player._from_nflgame_player(db, p)._row)
        nfldb.db._big_insert(cursor, 'player', insert)
    else:
        for p in nflgame.players.itervalues():
            vals = nfldb.Player._from_nflgame_player(db, p)._row
            nfldb.db._upsert(cursor, 'player', vals, [vals[0]])
    log('done.')

    # If the player table is empty at this point, then something is very
    # wrong. The user MUST fix things before going forward.
    if nfldb.db._num_rows(cursor, 'player') == 0:
        log('Something is very wrong. The player table is empty even after\n'
            'trying to update it. Please seek help. Include the output of\n'
            'this program when asking for help.')
        log('The likely cause here is that the `nflgame-update-players`\n'
            'program is failing somehow. Try running it separately to see\n'
            'if it succeeds on its own.')
        sys.exit(1)

    # Finally, report that we've just update the rosters.
    cursor.execute('UPDATE meta SET last_roster_download = NOW()')


def bulk_insert_game_data(cursor, scheduled):
    """
    Given a list of GSIS identifiers of games that have **only**
    schedule data in the database, perform a bulk insert of all drives
    and plays in the game.
    """
    def do():
        log('\tSending batch of data to database...')
        for table in ('drive', 'play', 'play_player'):  # order matters
            if len(bulk[table]) > 0:
                nfldb.db._big_insert(cursor, table, bulk[table])
                bulk[table] = []

    bulk = defaultdict(list)
    queued = 0
    for gsis_id in scheduled:
        if queued >= _batch_size:
            do()
            queued = 0
        g = game_from_id(cursor, gsis_id)
        vals = g._row

        # This updates the schedule data to include all game meta data.
        # We don't use _save here, as that would recursively upsert all
        # drive/play data in the game.
        nfldb.db._upsert(cursor, 'game', vals, [vals[0]])

        queued += 1
        for drive in g.drives:
            bulk['drive'].append(drive._row)
            for play in drive.plays:
                bulk['play'].append(play._row)
                for pp in play.play_players:
                    # Whoops. Shouldn't happen often...
                    # Only inserts into the DB if the player wasn't found
                    # in the JSON database. A few weird corner cases...
                    pp.player._save(cursor)
                    bulk['play_player'].append(pp._row)

    # Bulk insert leftovers.
    do()


def games_in_progress(cursor):
    """
    Returns a list of GSIS identifiers corresponding to games that
    are in progress. Namely, they are not finished but have at least
    one drive in the database.

    The list is sorted in the order in which the games will be played.
    """
    playing = []
    cursor.execute('''
        SELECT DISTINCT game.gsis_id, game.finished
        FROM drive
        LEFT JOIN game
        ON drive.gsis_id = game.gsis_id
        WHERE game.finished = False AND drive.drive_id IS NOT NULL
    ''')
    for row in cursor.fetchall():
        playing.append(row['gsis_id'])
    return sorted(playing, key=int)


def games_scheduled(cursor):
    """
    Returns a list of GSIS identifiers corresponding to games that
    have schedule data in the database but don't have any drives or
    plays in the database. In the typical case, this corresponds to
    games that haven't started yet.

    The list is sorted in the order in which the games will be played.
    """
    scheduled = []
    cursor.execute('''
        SELECT DISTINCT game.gsis_id, game.start_time
        FROM game
        LEFT JOIN drive
        ON game.gsis_id = drive.gsis_id
        WHERE drive.drive_id IS NULL
    ''')
    for row in cursor.fetchall():
        # This condition guards against unnecessarily processing games
        # that have only schedule data but aren't even close to starting yet.
        # Namely, if a game doesn't have any drives, then there's nothing to
        # bulk insert.
        #
        # We start looking at games when it's 15 minutes before game time.
        # Eventually, the game will start, and the first bits of drive/play
        # data will be bulk inserted. On the next database update, the game
        # will move to the `games_in_progress` list and updated incrementally.
        #
        # So what's the point of bulk inserting? It's useful when updates are
        # done infrequently (like the initial load of the database or say,
        # once a week).
        if (row['start_time'] - nfldb.now()).total_seconds() < 900:
            scheduled.append(row['gsis_id'])
    return sorted(scheduled, key=int)


def games_missing(cursor):
    """
    Returns a list of GSIS identifiers corresponding to games that
    don't have any data in the database.

    The list is sorted in the order in which the games will be played.
    """
    allids = set()
    cursor.execute('SELECT gsis_id FROM game')
    for row in cursor.fetchall():
        allids.add(row['gsis_id'])
    nada = (gid for gid in nflgame.schedule.games_byid if gid not in allids)
    return sorted(nada, key=int)


def update(db, player_interval=(60 * 60 * 12)):
    """
    Does a single monolithic update of players, games, drives and
    plays.  If `update` terminates, then the database will be
    completely up to date with all current NFL data known by `nflgame`.

    Note that while `update` is executing, all writes to the following
    tables will be blocked: player, game, drive, play, play_player.
    The huge lock is used so that there aren't any races introduced
    when updating the database. Other clients will still be able to
    read from the database.
    """
    # The complexity of this function has one obvious culprit:
    # performance reasons. On the one hand, we want to make infrequent
    # updates quick by bulk-inserting game, drive and play data. On the
    # other hand, we need to be able to support incremental updates
    # as games are played.
    #
    # Therefore, games and their data are split up into three chunks.
    #
    # The first chunk are games that don't exist in the database at all.
    # The games have their *schedule* data bulk-inserted as a place holder
    # in the `game` table. This results in all of the `home_*` and `away_*`
    # fields being set to 0. The schedule data is bulk inserted without
    # ever initializing a `nflgame.game.Game` object, which can be costly.
    #
    # The second chunk are games that have schedule data in the database
    # but have nothing else. In the real world, this corresponds to games
    # in the current season that haven't started yet. Or more usefully,
    # every game when the database is empty. This chunk of games has its
    # drives and play bulk-inserted.
    #
    # The third and final chunk are games that are being played. These games
    # have the slowest update procedure since each drive and play need to be
    # "upserted." That is, inserted if it doesn't exist or updated if it
    # does. On the scale of a few games, performance should be reasonable.
    # (Data needs to be updated because mistakes can be made on the fly and
    # corrected by the NFL. Blech.)
    #
    # Comparatively, updating players is pretty simple. Player meta data
    # changes infrequently, which means we can update it on a larger interval
    # and we can be less careful about performance.
    log('-' * 79)
    log('STARTING NFLDB UPDATE AT %s' % nfldb.now())

    with nfldb.Tx(db) as cursor:
        # Try to update players first to avoid upserting them.
        update_players(cursor, player_interval)

    with nfldb.Tx(db) as cursor:
        log('Locking write access to tables... ', end='')
        cursor.execute('''
            LOCK TABLE player IN SHARE ROW EXCLUSIVE MODE;
            LOCK TABLE game IN SHARE ROW EXCLUSIVE MODE;
            LOCK TABLE drive IN SHARE ROW EXCLUSIVE MODE;
            LOCK TABLE play IN SHARE ROW EXCLUSIVE MODE;
            LOCK TABLE play_player IN SHARE ROW EXCLUSIVE MODE
        ''')
        log('done.')

        log('Updating season phase, year and week... ', end='')
        update_season_state(cursor)
        log('done.')

        nada = games_missing(cursor)
        if len(nada) > 0:
            log('Adding schedule data for %d games... ' % len(nada), end='')
            insert = []
            for gid in nada:
                g = game_from_schedule(cursor, gid)
                insert.append(g._row)
            nfldb.db._big_insert(cursor, 'game', insert)
            log('done.')

        scheduled = games_scheduled(cursor)
        if len(scheduled) > 0:
            log('Bulk inserting data for %d games...' % len(scheduled))
            bulk_insert_game_data(cursor, scheduled)
            log('done.')

        playing = games_in_progress(cursor)
        if len(playing) > 0:
            log('Updating %d games in progress...' % len(playing))
            for gid in playing:
                g = game_from_id(cursor, gid)
                log('\t%s' % g)
                g._save(cursor)
            log('done.')

        log('FINISHED NFLDB UPDATE AT %s' % nfldb.now())
        log('-' * 79)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Updates the nfldb database. It may be run at any '
                    'frequency, or it may be run in the background with '
                    '--background.',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    aa = parser.add_argument
    aa('--player-interval', type=int, default=(60 * 60 * 12),
       help='The number of seconds between player meta data updates. A longer '
            'interval is needed since meta data does not change frequently '
            'and because each update requires a few dozen HTTP requests to '
            'NFL.com.')
    args = parser.parse_args()

    log('Connecting to nfldb... ', end='')
    db = nfldb.connect()
    log('done.')

    # We always insert dates and times as UTC.
    log('Setting timezone to UTC... ', end='')
    nfldb.set_timezone(db, 'UTC')
    log('done.')

    update(db, player_interval=args.player_interval)
