# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2007-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.
#
# Author: Olivier Tilloy <olivier@fluendo.com>

"""
Provide access to resources served by Flickr over HTTP.
"""

from elisa.core.components.resource_provider import ResourceProvider
from elisa.core.resource_manager import NoMatchingResourceProvider
from elisa.core.media_uri import MediaUri
from elisa.core import common
from elisa.core.utils import defer

from elisa.plugins.http_client.http_client import ElisaHttpClient, \
                                                  ElisaAdvancedHttpClient
from elisa.plugins.http_client.extern.client_http import ClientRequest
from twisted.web2 import responsecode
from twisted.web2.stream import BufferedStream, MemoryStream, FileStream

from twisted.internet import threads
from elisa.core.utils.cancellable_defer import CancellableDeferred, \
                                               CancelledError

from elisa.plugins.base.models.media import RawDataModel
from elisa.plugins.base.models.image import ImageModel

from elisa.plugins.flickr import flickr_api as flickr
from elisa.plugins.flickr.models import FlickrResponseModel, \
                                        FlickrPhotoModel, FlickrTagModel, \
                                        LoginModel, FlickrPhotoSetModel, \
                                        FlickrContactModel

import re
import mimetools
import os.path
from xml.dom import minidom

from twill import commands as twill


class FlickrResourceProvider(ResourceProvider):

    """
    A resource provider that implements the GET and POST methods for use on the
    Flickr API (see http://www.flickr.com/services/api/ for details).
    The GET method is also able to retrieve image files from Flickr directly.
    """

    # Queries to the Flickr API
    api_uri = \
        'http://%s/services/(rest/?.*|upload|replace)' % flickr.API_SERVER
    api_re = re.compile(api_uri)
    # Queries to the static image servers
    img_uri = 'http://farm(\d+).' + flickr.IMG_SERVER + '/.*'
    img_re = re.compile(img_uri)

    supported_uri = api_uri + '|' + img_uri

    def initialize(self):
        dfr = super(FlickrResourceProvider, self).initialize()

        def parent_initialized(result):
            self._api_client = \
                ElisaHttpClient(host=flickr.API_SERVER, pipeline=False)

            # Dirty hack: Flickr servers close the connection after each
            # request, so we do not even try to pipeline requests because it
            # seems to be very unstable.
            # FIXME: obviously, this problem needs to be adressed in a much
            # cleaner way...
            self._api_client.pipeline_queue = [] # Emulate pipelining
            # One client for each farm
            self._img_clients = {}

            self.auth_token = None
            self.login_dfr = None
            # Is the user already logged in?
            try:
                token = flickr.get_cached_token()
            except IOError:
                pass
            else:
                # Check the token validity
                def valid_token(response, token):
                    self.auth_token = token

                def invalid_token(failure):
                    msg = 'Invalid authentication token. Please log in again.'
                    self.warning(msg)
                    # The user will have to log in again from the UI
                    try:
                        os.unlink(flickr.get_token_file())
                    except OSError:
                        pass

                uri = flickr.generate_call_uri(method='flickr.auth.checkToken',
                                               arguments={'auth_token': token},
                                               sign=True)
                model, dfr = self.get(uri)
                dfr.addCallbacks(valid_token, invalid_token,
                                 callbackArgs=(token,))
                return dfr

        dfr.addCallback(parent_initialized)
        dfr.addCallback(lambda result: self)
        return dfr

    def clean(self):
        """
        Close all the open HTTP connections.
        """
        close_dfrs = []
        # Close the API connection
        close_dfrs.append(self._api_client.close())

        if self.login_dfr is not None:
            close_dfrs.append(self.login_dfr)

        # Close all the image farm connections
        close_dfrs += [client.close() for client in self._img_clients.values()]
        dfr = defer.DeferredList(close_dfrs, consumeErrors=True)

        def parent_clean(result):
            return super(FlickrResourceProvider, self).clean()

        dfr.addCallback(parent_clean)
        return dfr

    def login(self, username, password):
        """
        Login to the Flickr webservice using the given credentials.

        @param username: username to request the login with
        @type username:  C{str}
        @param password: password to request the login with
        @type password:  C{str}

        @return: a deferred fired when successfully logged in
        @rtype:  L{elisa.core.utils.defer.Deferred}
        """
        login_model = LoginModel()
        login_model.username = username
        login_model.password = password

        def connected_cb(auth_token, model):
            self.info("Authentication succeeded: %r", auth_token)
            model.success = True
            model.auth_token = auth_token

            self.auth_token = auth_token

            return model

        def parse_login_web_form(login_url, username, password):
            # Clear previously cached Flickr credentials
            twill.clear_cookies()

            # This will take us to the login page
            twill.go(login_url)

            # We are now on the login page
            # Fill in credentials and submit
            twill.formvalue(1, 'login', username)
            twill.formvalue(1, 'passwd', password)
            twill.submit()

            # We are now on the "genuine request" confirmation page
            # Click the NEXT button of the correct form (the last one)
            twill.formvalue(4, 1, 'NEXT')
            twill.submit()

            # We are now on the authorization confirmation page
            twill.formvalue(3, 1, "OK, I'LL AUTHORIZE IT")
            twill.submit()

        def web_form_parsed(result, frob, model):
            dfr = flickr.authenticate_2(self, frob)
            dfr.addCallback(connected_cb, model)
            return dfr

        def auth_open_url(state, model):
            self.debug("state = %r", state)
            if 'token' in state:
                return connected_cb(state['token'], model)
            else:
                dfr = threads.deferToThread(parse_login_web_form, state['url'],
                                            username, password)
                dfr.addCallback(web_form_parsed, state['frob'], model)
                return dfr

        # remove the previous auth_token (from a previous login)
        token_file = flickr.get_token_file()
        if self.auth_token and os.path.exists(token_file):
            os.unlink(token_file)

        dfr = flickr.authenticate_1(self)
        dfr.addCallback(auth_open_url, login_model)
        return dfr

    def logout(self):
        """
        Remove any reference to the current Flickr authentication
        token and remove the file storing the Flickr frob.
        """
        self.debug("Logging out. Auth token was: %s", self.auth_token)
        self.auth_token = None
        token_file = flickr.get_token_file()
        if os.path.exists(token_file):
            os.unlink(token_file)
            self.debug("Removed cached token file: %s", token_file)

    def is_logged_in(self):
        return self.auth_token != None

    def get(self, uri, context_model=None):
        """
        GET request to the Flickr servers.

        The request URI can be of one of the following forms::

            http://api.flickr.com/services/rest/?.*
            http://farm{farm_id}.static.flickr.com/.*\.jpg

        @param uri:           URI pointing to the resource
        @type uri:            L{elisa.core.media_uri.MediaUri}
        @param context_model: [not used]
        @type context_model:  C{None}

        @return:              a new model and a deferred fired when the model
                              is filled with the requested resource's data
        @rtype:               tuple of L{elisa.core.components.model.Model}
                              L{elisa.core.utils.defer.Deferred}
        """
        url = str(uri)
        self.debug("GET %s", url)

        # Select the correct HTTP client to target
        match = self.api_re.match(url)
        if match is not None:
            if not match.groups()[0].startswith('rest'):
                # upload and replace are not supported by the GET method
                return (None,
                        defer.fail(NoMatchingResourceProvider('GET: ' + url)))
            else:
                http_client = self._api_client
                result_model = FlickrResponseModel()
        else:
            match = self.img_re.match(url)
            if match is not None:
                farm = match.groups()[0]
                try:
                    # Re-use an existing client
                    http_client = self._img_clients[farm]
                except KeyError:
                    # Instantiate a new client for this farm
                    server = 'farm%s.%s' % (farm, flickr.IMG_SERVER)
                    http_client = ElisaHttpClient(host=server, pipeline=False)
                    http_client.pipeline_queue = [] # Emulate pipelining
                    self._img_clients[farm] = http_client
                result_model = RawDataModel()

        def response_read(response, model):
            # Parse the response and populate the model accordingly
            if isinstance(model, RawDataModel):
                model.data = response
                model.size = len(response)
            elif isinstance(model, FlickrResponseModel):
                dom = minidom.parseString(response)
                rsp = dom.firstChild

                if rsp.nodeName != 'rsp':
                    # Malformed response
                    raise ValueError('Malformed response')
                if rsp.attributes['stat'].nodeValue == 'fail':
                    # Failure
                    err = rsp.getElementsByTagName('err')[0]
                    err_code = err.attributes['code'].nodeValue
                    err_msg = err.attributes['msg'].nodeValue
                    raise ValueError('%s: %s' %(err_code, err_msg))

                if rsp.getElementsByTagName('photos'):
                    # The response contains a list of photos
                    model.photos = []
                    photolist = rsp.getElementsByTagName('photos')[0].getElementsByTagName('photo')
                    for photo in photolist:
                        photo_model = FlickrPhotoModel()
                        photo_model.flickr_id = photo.attributes['id'].nodeValue
                        photo_model.owner = photo.attributes['owner'].nodeValue
                        photo_model.secret = photo.attributes['secret'].nodeValue
                        photo_model.farm = int(photo.attributes['farm'].nodeValue)
                        photo_model.server = int(photo.attributes['server'].nodeValue)
                        photo_model.title = photo.attributes['title'].nodeValue
                        photo_model.ispublic = bool(int(photo.attributes['ispublic'].nodeValue))
                        photo_model.isfriend = bool(int(photo.attributes['isfriend'].nodeValue))
                        photo_model.isfamily = bool(int(photo.attributes['isfamily'].nodeValue))
                        model.photos.append(photo_model)
                elif rsp.getElementsByTagName('photosets'):
                    # The response contains a list of photosets
                    model.photosets = []
                    sets = rsp.getElementsByTagName('photosets')[0].getElementsByTagName('photoset')
                    for photoset in sets:
                        set_model = FlickrPhotoSetModel()
                        set_model.flickr_id = photoset.attributes['id'].nodeValue
                        set_model.primary = int(photoset.attributes['primary'].nodeValue)
                        set_model.secret = photoset.attributes['secret'].nodeValue
                        set_model.farm = int(photoset.attributes['farm'].nodeValue)
                        set_model.server = int(photoset.attributes['server'].nodeValue)
                        set_model.photosnb = int(photoset.attributes['photos'].nodeValue)

                        title = photoset.getElementsByTagName('title')[0]
                        set_model.title = title.childNodes[0].nodeValue

                        description = photoset.getElementsByTagName('description')[0]
                        if description.childNodes:
                            set_model.description = description.childNodes[0].nodeValue
                        else:
                            set_model.description = ""

                        model.photosets.append(set_model)
                elif rsp.getElementsByTagName('photoset'):
                    # The response contains a list of photos
                    model.photos = []
                    photolist = rsp.getElementsByTagName('photoset')[0].getElementsByTagName('photo')
                    owner = "Foo"
                    for photo in photolist:
                        photo_model = FlickrPhotoModel()
                        photo_model.flickr_id = photo.attributes['id'].nodeValue
                        photo_model.owner = owner
                        photo_model.secret = photo.attributes['secret'].nodeValue
                        photo_model.farm = int(photo.attributes['farm'].nodeValue)
                        photo_model.server = int(photo.attributes['server'].nodeValue)
                        photo_model.title = photo.attributes['title'].nodeValue
                        model.photos.append(photo_model)
                elif rsp.getElementsByTagName('contacts'):
                    # The response contains a list of contacts
                    model.contacts = []
                    contacts = rsp.getElementsByTagName('contacts')[0].getElementsByTagName('contact')
                    for contact in contacts:
                        contact_model = FlickrContactModel()
                        contact_model.nsid = contact.attributes['nsid'].nodeValue
                        contact_model.username = contact.attributes['username'].nodeValue
                        contact_model.iconserver = int(contact.attributes['iconserver'].nodeValue)
                        contact_model.realname = contact.attributes['realname'].nodeValue
                        contact_model.is_friend = int(contact.attributes['friend'].nodeValue)
                        contact_model.is_family = int(contact.attributes['family'].nodeValue)
                        contact_model.is_ignored = int(contact.attributes['ignored'].nodeValue)
                        model.contacts.append(contact_model)
                elif rsp.getElementsByTagName('who'):
                    # The response contains a list of user tags
                    model.tags = []
                    tags = rsp.getElementsByTagName('who')[0].getElementsByTagName('tag')
                    for tag in tags:
                        tag_model = FlickrTagModel()
                        tag_model.label = tag.firstChild.nodeValue
                        # the private attribute is specific to our models
                        # we set it to True here because the response results from a call
                        # of the API flickr.tags.getListUser (request the user's tags)
                        tag_model.private = True
                        model.tags.append(tag_model)

                elif rsp.getElementsByTagName('photo'):
                    # The response contains details about one photo
                    model.photo = FlickrPhotoModel()
                    photo = rsp.getElementsByTagName('photo')[0]
                    model.photo.flickr_id = photo.attributes['id'].nodeValue
                    model.photo.secret = photo.attributes['secret'].nodeValue
                    model.photo.server = int(photo.attributes['server'].nodeValue)
                    owner = photo.getElementsByTagName('owner')[0]
                    model.photo.owner = owner.attributes['nsid'].nodeValue
                    model.photo.title = photo.getElementsByTagName('title')[0].firstChild.nodeValue
                    model.photo.description = photo.getElementsByTagName('description')[0].firstChild.nodeValue
                    visibility = photo.getElementsByTagName('visibility')[0]
                    model.photo.ispublic = bool(int(visibility.attributes['ispublic'].nodeValue))
                    model.photo.isfriend = bool(int(visibility.attributes['isfriend'].nodeValue))
                    model.photo.isfamily = bool(int(visibility.attributes['isfamily'].nodeValue))
                elif rsp.getElementsByTagName('hottags'):
                    # The response contains a list of hot tags
                    model.tags = []
                    taglist = rsp.getElementsByTagName('hottags')[0].getElementsByTagName('tag')
                    for tag in taglist:
                        tag_model = FlickrTagModel()
                        tag_model.label = tag.firstChild.nodeValue
                        tag_model.score = int(tag.attributes['score'].nodeValue)
                        model.tags.append(tag_model)
                elif rsp.getElementsByTagName('sizes'):
                    # The response contains a list of photo sizes with their
                    # URIs
                    # Hack: we always build a list of three sizes for the
                    # result to be predictible and usable by the Flickr
                    # controller and the slideshow player. That means we might
                    # discard or make up some information depending on the
                    # available sizes...
                    sizelist = rsp.getElementsByTagName('sizes')[0].getElementsByTagName('size')
                    _small = None
                    _medium = None
                    _large = None
                    for size in sizelist:
                        if size.attributes['label'].nodeValue == 'Thumbnail' \
                            and _small is None:
                            _small = size.attributes['source'].nodeValue
                        if size.attributes['label'].nodeValue == 'Small':
                            _small = size.attributes['source'].nodeValue
                        if size.attributes['label'].nodeValue == 'Medium':
                            _medium = size.attributes['source'].nodeValue
                        if size.attributes['label'].nodeValue == 'Large':
                            # The original image is too big, take the large
                            # image instead
                            _large = size.attributes['source'].nodeValue
                        if size.attributes['label'].nodeValue == 'Original' \
                            and _large is None:
                            _large = size.attributes['source'].nodeValue
                    if _medium is None:
                        _medium = _small
                    if _large is None:
                        _large = _medium
                    model.sizes = ImageModel()
                    # TODO: implement image rotation
                    model.sizes.can_rotate = False
                    model.sizes.references = [MediaUri(_small),
                                              MediaUri(_medium),
                                              MediaUri(_large)]
                else:
                    # Another type of response, default to filling model.data
                    # with the raw XML response
                    model.data = response

            return model

        def request_done(response, model):
            if response.code == responsecode.OK:
                # Read the response stream
                read_dfr = BufferedStream(response.stream).readExactly()
                read_dfr.addCallback(response_read, model)
                return read_dfr
            elif response.code == responsecode.NOT_FOUND:
                # 404 error code: resource not found
                return defer.fail(IOError('Resource not found at %s' % url))
            else:
                # Other HTTP response code
                return defer.fail(Exception('Received an %d HTTP response code' % response.code))

        # Here we emulate pipelining by filling an internal request queue,
        # because the image servers do not support pipelining
        def _img_client_request_queued(response, client):
            client.pipeline_queue.pop(0)
            if client.pipeline_queue:
                return _img_client_queue_next_request(response, client)
            else:
                return defer.succeed(None)

        def _img_client_queue_next_request(response, client):
            try:
                url, deferred = client.pipeline_queue[0]
            except IndexError:
                # No requests to queue
                return defer.succeed(None)
            if deferred.called:
                # The deferred has been cancelled, ignore the request and go on
                # with the next one in the queue
                client.pipeline_queue.pop(0)
                return _img_client_queue_next_request(response, client)
            request_dfr = client.request(url)
            request_dfr.addCallback(request_done, result_model)
            request_dfr.chainDeferred(deferred)
            # We want to queue a request even if the previous one fails
            request_dfr.addCallback(_img_client_request_queued, client)
            request_dfr.addErrback(_img_client_request_queued, client)
            return request_dfr

        def _cancel_request(deferred):
            deferred.errback(CancelledError('Cancelled request'))

        if hasattr(http_client, 'pipeline_queue'):
            # img server client, fake pipelining
            request_dfr = CancellableDeferred(canceller=_cancel_request)
            http_client.pipeline_queue.append((url, request_dfr))
            if len(http_client.pipeline_queue) == 1:
                _img_client_queue_next_request(None, http_client)
        else:
            # Normal case, the server supports pipelining
            request_dfr = http_client.request(url)
            request_dfr.addCallback(request_done, result_model)
        return (result_model, request_dfr)

    def __encodeForm(self, arguments, filename):
        """
        Encode arguments and a file's contents in a multipart/form-data string.

        This code is largely inspired by Ross Burton's flickrpc
        (see http://burtonini.com/bzr/flickrpc/).
        """
        # FIXME: this method should definitely go in the http_client plugin!
        boundary = mimetools.choose_boundary()
        lines = []
        for key, val in arguments.items():
            lines.append('--' + boundary.encode('utf-8'))
            lines.append('Content-Disposition: form-data; name="%s";' % key)
            lines.append('')
            lines.append(val.encode('utf-8'))
        # Add the binary data from the file to post
        lines.append('--' + boundary.encode('utf-8'))
        header = 'Content-Disposition: form-data; name="photo";'
        header += 'filename="%s";' % os.path.basename(filename)
        lines.append(header)
        lines.append('Content-Type: application/octet-stream')
        lines.append('')

        def file_read(data):
            lines.append(data)
            # Add final boundary
            lines.append('--' + boundary.encode('utf-8'))
            return (boundary, '\r\n'.join(lines))

        stream = BufferedStream(FileStream(file(filename)))
        read_defer = stream.readExactly()
        read_defer.addCallback(file_read)
        return read_defer

    def post(self, uri, arguments, filename):
        """
        POST request to the Flickr servers.

        The request URI can be of one of the following forms::

            http://api.flickr.com/services/upload/
            http://api.flickr.com/services/replace/

        @param uri:       URI pointing to the resource
        @type uri:        L{elisa.core.media_uri.MediaUri}
        @param arguments: the arguments (including authentication and signing)
        @type arguments:  C{dict} of C{str}
        @param filename:  the name of a local photo file
        @type filename:   C{str}

        @return:          a deferred fired with the id of the photo
                          uploaded/replaced when the operation is complete
        @rtype:           L{elisa.core.utils.defer.Deferred}
        """
        url = str(uri)
        # Check that the uri is accepted by the client
        match = self.api_re.match(url)
        if match is None:
            # only the API server can be targetted with a POST request
            return defer.fail(NoMatchingResourceProvider('POST: ' + url))
        if not match.groups()[0].startswith('upload') and \
            not match.groups()[0].startswith('replace'):
            # only upload and replace are supported by the POST method
            return defer.fail(NoMatchingResourceProvider('POST: ' + url))

        def response_read(response):
            dom = minidom.parseString(response)
            rsp_stat = dom.firstChild.attributes.getNamedItem('stat').nodeValue
            if rsp_stat != 'ok':
                # Upload failed
                err_attributes = dom.getElementsByTagName('err')[0].attributes
                err_code = err_attributes.getNamedItem('code').nodeValue
                err_msg = err_attributes.getNamedItem('msg').nodeValue
                return defer.fail(Exception(err_code + ': ' + err_msg))
            else:
                # Get the id of the photo that has been posted
                photo_id = dom.getElementsByTagName('photoid')[0].firstChild.nodeValue.strip()
                return defer.succeed(photo_id)

        def post_done(response):
            if response.code == responsecode.OK:
                # Read the response stream
                read_dfr = BufferedStream(response.stream).readExactly()
                read_dfr.addCallback(response_read)
                return read_dfr
            else:
                # Other HTTP response code
                return defer.fail(Exception('Received an %d HTTP response code' % response.code))

        def form_encoded((boundary, formdata)):
            request = ClientRequest('POST', uri, None, MemoryStream(formdata))
            request.headers.setRawHeaders('Content-Type',
                ['multipart/form-data; boundary=%s' % boundary])
            request_dfr = self._api_client.request_full(request)
            request_dfr.addCallback(post_done)
            return request_dfr

        encode_defer = self.__encodeForm(arguments, filename)
        encode_defer.addCallback(form_encoded)
        return encode_defer
