# -*- coding: utf-8 -*-

from __future__ import print_function, division, absolute_import, unicode_literals
from future.utils import PY3
from past.builtins import basestring
from builtins import *  # noqa

import os
from socket import gethostname
import time
from uuid import getnode as getmac
import webbrowser

if PY3:
    from urllib.parse import unquote
else:
    from urllib import unquote

import httplib2  # included with oauth2client
from oauth2client.client import OAuth2WebServerFlow, TokenRevokeError
import oauth2client.file

import gmusicapi
from gmusicapi.clients.shared import _Base
from gmusicapi.appdirs import my_appdirs
from gmusicapi.exceptions import CallFailure, NotLoggedIn
from gmusicapi.protocol import musicmanager, upload_pb2, locker_pb2
from gmusicapi.utils import utils
from gmusicapi import session

OAUTH_FILEPATH = os.path.join(my_appdirs.user_data_dir, 'oauth.cred')


class Musicmanager(_Base):
    """Allows uploading by posing as Google's Music Manager.

    Musicmanager uses OAuth, so a plaintext email and password are not required
    when logging in.

    For most authors and users of gmusicapi scripts,
    :func:`perform_oauth` should be run once per machine to
    store credentials to disk.
    Future calls to :func:`login` can use
    use the stored credentials by default.

    Some authors may want more control over the OAuth flow.
    In this case, credentials can be directly provided to :func:`login`.
    """

    _session_class = session.Musicmanager

    @staticmethod
    def perform_oauth(storage_filepath=OAUTH_FILEPATH, open_browser=False):
        """Provides a series of prompts for a user to follow to authenticate.
        Returns ``oauth2client.client.OAuth2Credentials`` when successful.

        In most cases, this should only be run once per machine to store
        credentials to disk, then never be needed again.

        If the user refuses to give access,
        ``oauth2client.client.FlowExchangeError`` is raised.

        :param storage_filepath: a filepath to write the credentials to,
          or ``None``
          to not write the credentials to disk (which is not recommended).

          `Appdirs <https://pypi.python.org/pypi/appdirs>`__
          ``user_data_dir`` is used by default. Users can run::

              import gmusicapi.clients
              print gmusicapi.clients.OAUTH_FILEPATH

          to see the exact location on their system.

        :param open_browser: if True, attempt to open the auth url
          in the system default web browser. The url will be printed
          regardless of this param's setting.

        This flow is intentionally very simple.
        For complete control over the OAuth flow, pass an
        ``oauth2client.client.OAuth2Credentials``
        to :func:`login` instead.
        """

        flow = OAuth2WebServerFlow(*musicmanager.oauth)

        auth_uri = flow.step1_get_authorize_url()
        print()
        print("Visit the following url:\n %s" % auth_uri)

        if open_browser:
            print()
            print('Opening your browser to it now...', end=' ')
            webbrowser.open(auth_uri)
            print('done.')
            print("If you don't see your browser, you can just copy and paste the url.")
            print()

        code = input("Follow the prompts, then paste the auth code here and hit enter: ")

        credentials = flow.step2_exchange(code)

        if storage_filepath is not None:
            if storage_filepath == OAUTH_FILEPATH:
                utils.make_sure_path_exists(os.path.dirname(OAUTH_FILEPATH), 0o700)
            storage = oauth2client.file.Storage(storage_filepath)
            storage.put(credentials)

        return credentials

    def __init__(self, debug_logging=True, validate=True, verify_ssl=True):
        super(Musicmanager, self).__init__(self.__class__.__name__,
                                           debug_logging,
                                           validate,
                                           verify_ssl)

    def login(self, oauth_credentials=OAUTH_FILEPATH,
              uploader_id=None, uploader_name=None):
        """Authenticates the Music Manager using OAuth.
        Returns ``True`` on success, ``False`` on failure.

        Unlike the :class:`Webclient`, OAuth allows authentication without
        providing plaintext credentials to the application.

        In most cases, the default parameters should be acceptable. Users on
        virtual machines will want to provide `uploader_id`.

        :param oauth_credentials: ``oauth2client.client.OAuth2Credentials`` or the path to a
          ``oauth2client.file.Storage`` file. By default, the same default path used by
          :func:`perform_oauth` is used.

          Endusers will likely call :func:`perform_oauth` once to write
          credentials to disk and then ignore this parameter.

          This param
          is mostly intended to allow flexibility for developers of a
          3rd party service who intend to perform their own OAuth flow
          (eg on their website).

        :param uploader_id: a unique id as a MAC address, eg ``'00:11:22:33:AA:BB'``.
          This should only be provided in cases where the default
          (host MAC address incremented by 1) will not work.

          Upload behavior is undefined if a Music Manager uses the same id, especially when
          reporting bad matches.

          ``ValueError`` will be raised if this is provided but not in the proper form.

          ``OSError`` will be raised if this is not provided and a real MAC could not be
          determined (most common when running on a VPS).

          If provided, use the same id on all future runs for this machine,
          because of the upload device limit explained below.

        :param uploader_name: human-readable non-unique id; default is
          ``"<hostname> (gmusicapi-{version})"``.

          This doesn't appear to be a part of authentication at all.
          Registering with (id, name = X, Y) and logging in with
          (id, name = X, Z) works, and does not change the server-stored
          uploader_name.

        There are hard limits on how many upload devices can be registered; refer to `Google's
        docs <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=1230356>`__. There
        have been limits on deauthorizing devices in the past, so it's smart not to register
        more devices than necessary.
        """

        return (self._oauth_login(oauth_credentials) and
                self._perform_upauth(uploader_id, uploader_name))

    def _oauth_login(self, oauth_credentials):
        """Auth ourselves to the MM oauth endpoint.

        Return True on success; see :py:func:`login` for params.
        """

        if isinstance(oauth_credentials, basestring):
            oauth_file = oauth_credentials
            if oauth_file == OAUTH_FILEPATH:
                utils.make_sure_path_exists(os.path.dirname(OAUTH_FILEPATH), 0o700)
            storage = oauth2client.file.Storage(oauth_file)

            oauth_credentials = storage.get()
            if oauth_credentials is None:
                self.logger.warning("could not retrieve oauth credentials from '%r'", oauth_file)
                return False

        if not self.session.login(oauth_credentials):
            self.logger.warning("failed to authenticate")
            return False

        self.logger.info("oauth successful")

        return True

    def _perform_upauth(self, uploader_id, uploader_name):
        """Auth or register ourselves as an upload client.

        Return True on success; see :py:func:`login` for params.
        """

        if uploader_id is None:
            mac_int = getmac()
            if (mac_int >> 40) % 2:
                self.session.logout()
                raise OSError('a valid MAC could not be determined.'
                              ' Provide uploader_id (and be'
                              ' sure to provide the same one on future runs).')

            else:
                # distinguish us from a Music Manager on this machine
                mac_int = (mac_int + 1) % (1 << 48)

            uploader_id = utils.create_mac_string(mac_int)

        if not utils.is_valid_mac(uploader_id):
            self.session.logout()
            raise ValueError('uploader_id is not in a valid form.'
                             '\nProvide 6 pairs of hex digits'
                             ' with capital letters',
                             ' (eg "00:11:22:33:AA:BB")')

        if uploader_name is None:
            uploader_name = gethostname() + u" (gmusicapi-%s)" % gmusicapi.__version__

        try:
            # this is a MM-specific step that might register a new device.
            self._make_call(musicmanager.AuthenticateUploader,
                            uploader_id,
                            uploader_name)
            self.logger.info("successful upauth")
            self.uploader_id = uploader_id
            self.uploader_name = uploader_name

        except CallFailure:
            self.logger.exception("upauth failure")
            self.session.logout()
            return False

        return True

    def logout(self, revoke_oauth=False):
        """Forgets local authentication in this Client instance.

        :param revoke_oauth: if True, oauth credentials will be permanently
          revoked. If credentials came from a file, it will be deleted.

        Returns ``True`` on success."""

        # TODO the login/logout stuff is all over the place

        success = True

        if revoke_oauth:
            try:
                # this automatically deletes a Storage file, if present
                self.session._oauth_creds.revoke(httplib2.Http())
            except TokenRevokeError:
                self.logger.exception("could not revoke oauth credentials")
                success = False

        self.uploader_id = None
        self.uploader_name = None

        return success and super(Musicmanager, self).logout()

    # mostly copy-paste from Webclient.get_all_songs.
    # not worried about overlap in this case; the logic of either could change.
    def get_uploaded_songs(self, incremental=False):
        """Returns a list of dictionaries, each with the following keys:
        ``('id', 'title', 'album', 'album_artist', 'artist', 'track_number',
        'track_size', 'disc_number', 'total_disc_count')``.

        All Access tracks that were added to the library will not be included,
        only tracks uploaded/matched by the user.

        :param incremental: if True, return a generator that yields lists
          of at most 1000 dictionaries
          as they are retrieved from the server. This can be useful for
          presenting a loading bar to a user.
        """

        to_return = self._get_all_songs()

        if not incremental:
            to_return = [song for chunk in to_return for song in chunk]

        return to_return

    # mostly copy-paste from Webclient.get_all_songs.
    # not worried about overlap in this case; the logic of either could change.
    def get_purchased_songs(self, incremental=False):
        """Returns a list of dictionaries, each with the following keys:
        ``('id', 'title', 'album', 'album_artist', 'artist', 'track_number',
        'track_size', 'disc_number', 'total_disc_count')``.

        :param incremental: if True, return a generator that yields lists
          of at most 1000 dictionaries
          as they are retrieved from the server. This can be useful for
          presenting a loading bar to a user.
        """

        to_return = self._get_all_songs(export_type=2)

        if not incremental:
            to_return = [song for chunk in to_return for song in chunk]

        return to_return

    @staticmethod
    def _track_info_to_dict(track_info):
        """Given a download_pb2.DownloadTrackInfo, return a dictionary."""
        # figure it's better to hardcode keys here than use introspection
        # and risk returning a new field all of a sudden.

        return dict((field, getattr(track_info, field)) for field in
                    ('id', 'title', 'album', 'album_artist', 'artist',
                     'track_number', 'track_size', 'disc_number',
                     'total_disc_count'))

    def _get_all_songs(self, export_type=1):
        """Return a generator of song chunks."""

        get_next_chunk = True

        # need to spoof .continuation_token access, and
        # can't add attrs to object(). Can with functions.

        lib_chunk = lambda: 0  # noqa
        lib_chunk.continuation_token = None

        while get_next_chunk:
            lib_chunk = self._make_call(musicmanager.ListTracks,
                                        self.uploader_id,
                                        lib_chunk.continuation_token,
                                        export_type)

            yield [self._track_info_to_dict(info)
                   for info in lib_chunk.download_track_info]

            get_next_chunk = lib_chunk.HasField('continuation_token')

    @utils.enforce_id_param
    def download_song(self, song_id):
        """Download an uploaded or purchased song from your library.

        Subscription tracks can't be downloaded with this method.

        Returns a tuple ``(u'suggested_filename', 'audio_bytestring')``.
        The filename
        will be what the Music Manager would save the file as,
        presented as a unicode string with the proper file extension.
        You don't have to use it if you don't want.


        :param song_id: a single uploaded or purchased song id.

        To write the song to disk, use something like::

            filename, audio = mm.download_song(an_id)

            # if open() throws a UnicodeEncodeError, either use
            #   filename.encode('utf-8')
            # or change your default encoding to something sane =)
            with open(filename, 'wb') as f:
                f.write(audio)

        Unlike with :py:func:`Webclient.get_song_download_info
        <gmusicapi.clients.Webclient.get_song_download_info>`,
        there is no download limit when using this interface.

        Also unlike the Webclient, downloading a track requires authentication.
        Returning a url does not suffice, since retrieving a track without auth
        will produce an http 500.
        """

        url = self._make_call(musicmanager.GetDownloadLink,
                              song_id,
                              self.uploader_id)['url']

        response = self._make_call(musicmanager.DownloadTrack, url)

        cd_header = response.headers['content-disposition']

        filename = unquote(cd_header.split("filename*=UTF-8''")[-1])

        return (filename, response.content)

    def get_quota(self):
        """Returns a tuple of (number of uploaded tracks, allowed number of uploaded tracks)."""

        if self.uploader_id is None:
            raise NotLoggedIn("Not authenticated as an upload device;"
                              " run Musicmanager.login(...perform_upload_auth=True...)"
                              " first.")

        client_state = self._make_call(
            musicmanager.GetClientState, self.uploader_id).clientstate_response

        return (client_state.total_track_count, client_state.locker_track_limit)

    @utils.accept_singleton(basestring)
    @utils.empty_arg_shortcircuit(return_code='{}')
    def upload(self, filepaths, enable_matching=False,
               enable_transcoding=True, transcode_quality='320k'):
        """Uploads the given filepaths.

        All non-mp3 files will be transcoded before being uploaded.
        This is a limitation of Google's backend.

        An available installation of ffmpeg or avconv is required in most cases:
        see `the installation page
        <https://unofficial-google-music-api.readthedocs.io/en
        /latest/usage.html?#installation>`__ for details.

        Returns a 3-tuple ``(uploaded, matched, not_uploaded)`` of dictionaries, eg::

            (
                {'<filepath>': '<new server id>'},               # uploaded
                {'<filepath>': '<new server id>'},               # matched
                {'<filepath>': '<reason, eg ALREADY_EXISTS>'}    # not uploaded
            )

        :param filepaths: a list of filepaths, or a single filepath.

        :param enable_matching: if ``True``, attempt to use `scan and match
          <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=2920799&topic=2450455>`__
          to avoid uploading every song.
          This requires ffmpeg or avconv.
          **WARNING**: currently, mismatched songs can *not* be fixed with the 'Fix Incorrect Match'
          button nor :py:func:`report_incorrect_match
          <gmusicapi.clients.Webclient.report_incorrect_match>`.
          They would have to be deleted and reuploaded with matching disabled
          (or with the Music Manager).
          Fixing matches from gmusicapi may be supported in a future release; see issue `#89
          <https://github.com/simon-weber/gmusicapi/issues/89>`__.

        :param enable_transcoding:
          if ``False``, non-MP3 files that aren't matched using `scan and match
          <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=2920799&topic=2450455>`__
          will not be uploaded.

        :param transcode_quality: if int, pass to ffmpeg/avconv ``-q:a`` for libmp3lame
          (`lower-better int,
          <http://trac.ffmpeg.org/wiki/Encoding%20VBR%20(Variable%20Bit%20Rate)%20mp3%20audio>`__).
          If string, pass to ffmpeg/avconv ``-b:a`` (eg ``'128k'`` for an average bitrate of 128k).
          The default is 320kbps cbr (the highest possible quality).

        All Google-supported filetypes are supported; see `Google's documentation
        <http://support.google.com/googleplay/bin/answer.py?hl=en&answer=1100462>`__.

        If ``PERMANENT_ERROR`` is given as a not_uploaded reason, attempts to reupload will never
        succeed. The file will need to be changed before the server will reconsider it; the easiest
        way is to change metadata tags (it's not important that the tag be uploaded, just that the
        contents of the file change somehow).
        """

        if self.uploader_id is None or self.uploader_name is None:
            raise NotLoggedIn("Not authenticated as an upload device;"
                              " run Api.login(...perform_upload_auth=True...)"
                              " first.")

        # TODO there is way too much code in this function.

        # To return.
        uploaded = {}
        matched = {}
        not_uploaded = {}

        # Gather local information on the files.
        local_info = {}  # {clientid: (path, Track)}
        for path in filepaths:
            try:
                track = musicmanager.UploadMetadata.fill_track_info(path)
            except BaseException as e:
                self.logger.exception("problem gathering local info of '%r'", path)

                user_err_msg = str(e)

                if 'Non-ASCII strings must be converted to unicode' in str(e):
                    # This is a protobuf-specific error; they require either ascii or unicode.
                    # To keep behavior consistent, make no effort to guess - require users
                    # to decode first.
                    user_err_msg = ("nonascii bytestrings must be decoded to unicode"
                                    " (error: '%s')" % user_err_msg)

                not_uploaded[path] = user_err_msg
            else:
                local_info[track.client_id] = (path, track)

        if not local_info:
            return uploaded, matched, not_uploaded

        # TODO allow metadata faking

        # Upload metadata; the server tells us what to do next.
        res = self._make_call(musicmanager.UploadMetadata,
                              [t for (path, t) in local_info.values()],
                              self.uploader_id)

        # TODO checking for proper contents should be handled in verification
        md_res = res.metadata_response

        responses = [r for r in md_res.track_sample_response]
        sample_requests = [req for req in md_res.signed_challenge_info]

        # Send scan and match samples if requested.
        for sample_request in sample_requests:
            path, track = local_info[sample_request.challenge_info.client_track_id]

            bogus_sample = None
            if not enable_matching:
                bogus_sample = b''  # just send empty bytes

            try:
                res = self._make_call(musicmanager.ProvideSample,
                                      path, sample_request, track,
                                      self.uploader_id, bogus_sample)

            except (IOError, ValueError) as e:
                self.logger.warning("couldn't create scan and match sample for '%r': %s",
                                    path, str(e))
                not_uploaded[path] = str(e)
            else:
                responses.extend(res.sample_response.track_sample_response)

        # Read sample responses and prep upload requests.
        to_upload = {}  # {serverid: (path, Track, do_not_rematch?)}
        for sample_res in responses:
            path, track = local_info[sample_res.client_track_id]

            if sample_res.response_code == upload_pb2.TrackSampleResponse.MATCHED:
                self.logger.info("matched '%r' to sid %s", path, sample_res.server_track_id)

                matched[path] = sample_res.server_track_id

                if not enable_matching:
                    self.logger.error("'%r' was matched without matching enabled", path)

            elif sample_res.response_code == upload_pb2.TrackSampleResponse.UPLOAD_REQUESTED:
                to_upload[sample_res.server_track_id] = (path, track, False)

            else:
                # there was a problem
                # report the symbolic name of the response code enum for debugging
                enum_desc = upload_pb2._TRACKSAMPLERESPONSE.enum_types[0]
                res_name = enum_desc.values_by_number[sample_res.response_code].name

                err_msg = "TrackSampleResponse code %s: %s" % (sample_res.response_code, res_name)

                if res_name == 'ALREADY_EXISTS':
                    # include the sid, too
                    # this shouldn't be relied on externally, but I use it in
                    # tests - being surrounded by parens is how it's matched
                    err_msg += "(%s)" % sample_res.server_track_id

                self.logger.warning("upload of '%r' rejected: %s", path, err_msg)
                not_uploaded[path] = err_msg

        # Send upload requests.
        if to_upload:
            # TODO reordering requests could avoid wasting time waiting for reup sync
            self._make_call(musicmanager.UpdateUploadState, 'start', self.uploader_id)

            for server_id, (path, track, do_not_rematch) in to_upload.items():
                # It can take a few tries to get an session.
                should_retry = True
                attempts = 0

                while should_retry and attempts < 10:
                    session = self._make_call(musicmanager.GetUploadSession,
                                              self.uploader_id, len(uploaded),
                                              track, path, server_id, do_not_rematch)
                    attempts += 1

                    got_session, error_details = \
                        musicmanager.GetUploadSession.process_session(session)

                    if got_session:
                        self.logger.info("got an upload session for '%r'", path)
                        break

                    should_retry, reason, error_code = error_details
                    self.logger.debug("problem getting upload session: %s\ncode=%s retrying=%s",
                                      reason, error_code, should_retry)

                    if error_code == 200 and do_not_rematch:
                        # reupload requests need to wait on a server sync
                        # 200 == already uploaded, so force a retry in this case
                        should_retry = True

                    time.sleep(6)  # wait before retrying
                else:
                    err_msg = "GetUploadSession error %s: %s" % (error_code, reason)

                    self.logger.warning("giving up on upload session for '%r': %s", path, err_msg)
                    not_uploaded[path] = err_msg

                    continue  # to next upload

                # got a session, do the upload
                # this terribly inconsistent naming isn't my fault: Google--
                session = session['sessionStatus']
                external = session['externalFieldTransfers'][0]

                session_url = external['putInfo']['url']
                content_type = external.get('content_type', 'audio/mpeg')

                if track.original_content_type != locker_pb2.Track.MP3:
                    if enable_transcoding:
                        try:
                            self.logger.info("transcoding '%r' to mp3", path)
                            contents = utils.transcode_to_mp3(path, quality=transcode_quality)
                        except (IOError, ValueError) as e:
                            self.logger.warning("error transcoding %r: %s", path, e)
                            not_uploaded[path] = "transcoding error: %s" % e
                            continue
                    else:
                        not_uploaded[path] = "transcoding disabled"
                        continue
                else:
                    with open(path, 'rb') as f:
                        contents = f.read()

                upload_response = self._make_call(musicmanager.UploadFile,
                                                  session_url, content_type, contents)

                success = upload_response.get('sessionStatus', {}).get('state')
                if success:
                    uploaded[path] = server_id
                else:
                    # 404 == already uploaded? serverside check on clientid?
                    self.logger.debug("could not finalize upload of '%r'. response: %s",
                                      path, upload_response)
                    not_uploaded[path] = 'could not finalize upload; details in log'

            self._make_call(musicmanager.UpdateUploadState, 'stopped', self.uploader_id)

        return uploaded, matched, not_uploaded
