import itertools
import logging
import os.path
import sys
import time

import click
import pychromecast
from pychromecast.error import PyChromecastError
import pylast
import toml


logger = logging.getLogger(__name__)

# Default set of apps to scrobble from.
APP_WHITELIST = ['Spotify', 'Google Play Music', 'SoundCloud', 'Plex', 'YouTube Music']

SCROBBLE_THRESHOLD_PCT = 0.50
SCROBBLE_THRESHOLD_SECS = 120
UNSUPPORTED_MODES = {'MultizoneLeader'}
POLL_INTERVAL = 5.0
RECONNECT_INTERVAL = 15


class ChromecastNotFoundException(Exception):
    pass


class ScrobbleListener(object):
    '''
    Tracks state of media playing on a single Chromecast device and
    logs to {last,libre}.fm when scrobble threshholds are hit
    '''

    def __init__(self, config, cast_name, available_devices=None):
        self.cast_name = cast_name
        self.cast_config = config.get('chromecast', {})
        self.app_whitelist = self.cast_config.get('app_whitelist', APP_WHITELIST)

        self.last_scrobbled = {}
        self.current_track = {}
        self.current_time = 0
        self.last_poll = time.time()

        self._connect_chromecast(available_devices)

        if not self.cast:
            raise ChromecastNotFoundException()

        self.scrobblers = []

        if 'lastfm' in config:
            self.scrobblers.append(pylast.LastFMNetwork(
                api_key=config['lastfm']['api_key'],
                api_secret=config['lastfm']['api_secret'],
                username=config['lastfm']['user_name'],
                password_hash=pylast.md5(config['lastfm']['password'])))

        if 'librefm' in config:
            self.scrobblers.append(pylast.LibreFMNetwork(
                session_key=config['librefm']['session_key'],
                username=config['librefm']['user_name'],
                password_hash=pylast.md5(config['librefm']['password'])
            ))

        self.estimate_spotify_timestamp = self.cast_config.get(
            'estimate_spotify_timestamp', True)

    def poll(self):
        ''' Helper for `_poll` to handle errors and reconnects. '''
        try:
            if not self.cast:
                click.echo('Reconnecting to cast device `%s`...' % self.cast_name)
                self._connect_chromecast()
            else:
                self._poll()

        # This could happen due to network hiccups, Chromecast
        # restarting, race conditions, etc...
        except (PyChromecastError, pylast.NetworkError):
            logger.info('poll(%s) failed', self.cast_name, exc_info=True)
            self.cast = None

    def _poll(self):
        ''' Updates internal media state, can trigger scrobble. '''

        current_app = self.cast.app_display_name

        # Only certain applications make sense to scrobble.
        if current_app not in self.app_whitelist:
            return

        # Certain operating modes do not support the
        # `media_controller.update_status()` call. Placing a device into a cast
        # group is one such case. When running in this config, the cast.app_id
        # is reported as 'MultizoneLeader'. Ensure we skip.
        if self.cast.app_id in UNSUPPORTED_MODES:
            return

        # This can raise an exception when device is part of a multizone group
        # (apparently `app_id` isn't sufficient)
        try:
            self.cast.media_controller.update_status()
        except pychromecast.error.UnsupportedNamespace:
            return

        status = self.cast.media_controller.status

        # Ignore when the player is paused.
        if not status.player_is_playing:
            return

        # Triggered when we poll in between songs (see issue #6)
        if status.current_time is None or status.duration is None or \
           status.duration <= 0:
            return

        # Triggered when song is repeated and starting again after the first
        # time
        if status.duration < self.current_time:
            self.last_scrobbled = {}
            self.current_track = {}
            self.current_time = 0

        # Spotify doesn't reliably report timestamps (see #20, #27),
        # so we estimate the current time as best we can
        last_poll, self.last_poll = (self.last_poll, time.time())

        if self.estimate_spotify_timestamp and current_app == 'Spotify':
            self.current_time += time.time() - last_poll

        else:
            self.current_time = status.current_time

        # We know music is playing, want to check what to update
        self._on_media_controller_status(current_app, status)

    def _connect_chromecast(self, available_devices=None):
        ''' Attempt to (re)connect to cast device named in `__init__`. '''

        self.cast = None

        if not available_devices:
            available_devices, browser = pychromecast.get_chromecasts(tries=1)

        matching_devices = [
            c for c in available_devices
            if c.cast_info.friendly_name == self.cast_name
        ]

        if not matching_devices:
            click.echo('Could not connect to device "%s"' % self.cast_name)
            return

        if len(matching_devices) > 1:
            click.echo('WARNING: Multiple devices available. Choosing first.')

        self.cast = matching_devices[0]

        # Wait for the device to be available
        self.cast.wait()
        click.echo('Using chromecast: %s' % self.cast.cast_info.friendly_name)

    def _on_media_controller_status(self, app, status):
        ''' Handle a status object returned from MediaController '''
        meta = {
            'artist': status.artist if status.artist else status.album_artist,
            'album': status.album_name,
            'title': status.title,
        }

        # Filter out ads from free-tier Spotify (see #49)
        if app == 'Spotify':
            # First party ads have 'Spotify' as the artist
            is_spotify_ad = meta['artist'] == 'Spotify'

            # Third party ads have title 'Advertisement' and an empty
            # album.
            is_3p_ad = meta['title'] == 'Advertisement' and not meta['album']

            if is_spotify_ad or is_3p_ad:
                return

        # Only need to update the now playing once for each track
        if meta != self.current_track:
            self._log_now_playing(meta)
            self.current_track = meta

        # Only scrobble if track has played 50% through (or 120 seconds,
        # whichever comes first).
        #
        # Don't scrobble the same thing over and over
        hit_threshold = self.current_time > SCROBBLE_THRESHOLD_SECS or \
            (self.current_time / status.duration) >= SCROBBLE_THRESHOLD_PCT

        if meta != self.last_scrobbled and hit_threshold:
            self._log_scrobble(meta)

    def _log_now_playing(self, track_meta):
        ''' Update the "now playing" track on user's profile. '''

        for scrobbler in self.scrobblers:
            try:
                scrobbler.update_now_playing(**track_meta)
            except (pylast.NetworkError, pylast.MalformedResponseError) as exc:
                click.echo('Failed to update now playing for {}: {}'.format(
                    scrobbler.name, str(exc)))

                logger.info('_log_now_playing(%s) failed', scrobbler.name,
                            exc_info=True)

        # First time this track has been seen, so reset the estimated
        # current time if we're using the spotify hack
        if self.cast.app_display_name == 'Spotify' and \
           self.estimate_spotify_timestamp:
            # Assume the track did not start in sync with the poll interval
            self.current_time = POLL_INTERVAL / 2

    def _log_scrobble(self, track_meta):
        ''' Scrobble current track to user's profile. '''

        click.echo('Scrobbling: {artist} - {title} [{album}]'.format(**track_meta))

        for scrobbler in self.scrobblers:
            try:
                scrobbler.scrobble(timestamp=int(time.time()), **track_meta)
            except (pylast.NetworkError, pylast.MalformedResponseError) as exc:
                click.echo('Failed to scrobble to {}: {}'.format(
                    scrobbler.name, str(exc)))

                logger.info('_log_scrobble(%s) failed', scrobbler.name,
                            exc_info=True)

        self.last_scrobbled = track_meta


def load_config(path):
    ''' Parse config at given absolute path and check for required keys. '''

    config = toml.load(path)

    if 'lastfm' in config:
        for k in ['api_key', 'api_secret', 'user_name', 'password']:
            assert k in config['lastfm'], 'Missing required lastfm option: %s' % k

    if 'librefm' in config:
        for k in ['password', 'user_name', 'session_key']:
            assert k in config['librefm'], 'Missing required librefm option: %s' % k

    return config


def config_wizard():
    ''' Text User Interface to generate initial lastcast.toml config. '''

    config = {'chromecast': {}}

    if click.confirm('Set up last.fm account?', default=True):
        click.echo('''
You'll need to create a last.fm API application first. Do so here:

    http://www.last.fm/api/account/create

What you fill in doesn't matter at all, just make sure to save the API
Key and Shared Secret.
''')

        config['lastfm'] = {
            key: click.prompt(key, type=str, hide_input=hidden)
            for (key, hidden) in [('user_name', False),
                                  ('password', True),
                                  ('api_key', False),
                                  ('api_secret', True)]
        }

    if click.confirm('Set up Libre.fm account?'):
        libre_conf = {
            key: click.prompt(key, type=str, hide_input=hidden)
            for (key, hidden) in [('user_name', False),
                                  ('password', True)]
        }

        libre = pylast.LibreFMNetwork(
            username=libre_conf['user_name'],
            password_hash=pylast.md5(libre_conf['password']))

        skg = pylast.SessionKeyGenerator(libre)
        url = skg.get_web_auth_url()

        click.echo('''Please grant lastcast access to your Libre.fm account:

        %s
''' % url)

        click.echo('Hit enter when ready')
        click.getchar()

        libre_conf['session_key'] = skg.get_web_auth_session_key(url)
        config['librefm'] = libre_conf

    devices, _browser = pychromecast.get_chromecasts()
    available = [
        cc.cast_info.friendly_name for cc in
        devices
    ]

    if len(available) == 1:
        config['chromecast']['devices'] = [available[0]]

    if len(available) > 1 or click.confirm('Manually specify cast device?', default=True):
        click.echo('\n\nAvailable cast devices: %s' % ', '.join(available))
        device_names = click.prompt('Which device(s) should be used? (comma separated)')
        device_names = [d.strip() for d in device_names.split(',') if d.strip != '']

        config['chromecast']['devices'] = device_names

    click.echo('\n\nDefault chromecast apps to scrobble from: %s' %
               ', '.join(APP_WHITELIST))

    apps = click.prompt('Comma separated apps [blank for default]',
                        default='', show_default=False)
    apps = [app.strip() for app in apps.split(',') if app.strip() != '']

    if apps:
        config['chromecast']['app_whitelist'] = apps

    generated = toml.dumps(config)
    click.echo('Generated config:\n\n%s' % generated)

    if click.confirm('Write to ~/.lastcast.toml?', default=True):
        with open(os.path.expanduser('~/.lastcast.toml'), 'w') as fp:
            fp.write(generated)


def connect_to_devices(config, device_names, available):
    '''
    Attempt to connect to each named device, returning
    ScrobbleListners and names of devices that couldn't be found.
    '''

    listeners = []
    missing_devices = []

    for name in device_names:
        try:
            listeners.append(ScrobbleListener(config, name, available_devices=available))
        except ChromecastNotFoundException:
            missing_devices.append(name)

    return listeners, missing_devices


@click.command()
@click.option('--config', required=False, help='Config file location')
@click.option('--wizard', is_flag=True, help='Generate a lastcast config.')
@click.option('--verbose', is_flag=True, help='Enable debug logging.')
def main(config, wizard, verbose):
    if verbose:
        logger.setLevel('DEBUG')
    else:
        # pychromecast is by default pretty noisy about caught exceptions
        logging.getLogger('pychromecast').setLevel('CRITICAL')

    if wizard:
        return config_wizard()

    paths = [config] if config else ['./lastcast.toml', '~/.lastcast.toml']

    for path in paths:
        path = os.path.expanduser(path)
        if os.path.exists(path):
            config = load_config(path)
            break
    else:
        click.echo('Config file not found!\n\nUse --wizard to create a config')
        sys.exit(1)

    cast_config = config.get('chromecast', {})
    device_names = cast_config.get('devices', [])

    # `name` is the legacy parameter name, supporting it for now.
    if not device_names and 'name' in cast_config:
        device_names = [cast_config['name']]

    if not device_names:
        click.echo('Need to specify either `devices` or `name` in '
                   '`[chromecast]` config block!')
        sys.exit(1)

    available, browser = pychromecast.get_chromecasts()
    listeners, missing = connect_to_devices(config, device_names, available)

    retry_missing = cast_config.get('retry_missing', False)
    if cast_config.get('ignore_missing', False) and missing:
        click.echo('Continuing without missing devices: %s' % ', '.join(missing))
        missing = []

    if missing and not retry_missing:
        click.echo('Failed to connect to %s. Exiting' % ', '.join(missing))
        click.echo('Available devices: %s' % ', '.join([
            d.cast_info.friendly_name for d in available
        ]))
        sys.exit(1)

    for i in itertools.count():
        for listener in listeners:
            listener.poll()

        # If we have any devices missing, periodically try to connect to them
        if retry_missing and missing and i % RECONNECT_INTERVAL == 0:
            click.echo('Retrying missing devices: %s' % ', '.join(missing))
            available, browser = pychromecast.get_chromecasts(tries=1)

            new_devices, missing = connect_to_devices(config, missing, available)
            listeners.extend(new_devices)

        time.sleep(POLL_INTERVAL)
