"""
Image file manipulation functions related to profile images.
"""
from collections import namedtuple
from contextlib import closing
from cStringIO import StringIO

import piexif
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.translation import ugettext as _
from PIL import Image

from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage

from .exceptions import ImageValidationError

ImageType = namedtuple('ImageType', ('extensions', 'mimetypes', 'magic'))

IMAGE_TYPES = {
    'jpeg': ImageType(
        extensions=['.jpeg', '.jpg'],
        mimetypes=['image/jpeg', 'image/pjpeg'],
        magic=['ffd8'],
    ),
    'png': ImageType(
        extensions=[".png"],
        mimetypes=['image/png'],
        magic=["89504e470d0a1a0a"],
    ),
    'gif': ImageType(
        extensions=[".gif"],
        mimetypes=['image/gif'],
        magic=["474946383961", "474946383761"],
    ),
}


def create_profile_images(image_file, profile_image_names):
    """
    Generates a set of image files based on image_file and stores them
    according to the sizes and filenames specified in `profile_image_names`.

    Arguments:

        image_file (file):
            The uploaded image file to be cropped and scaled to use as a
            profile image.  The image is cropped to the largest possible square,
            and centered on this image.

        profile_image_names (dict):
            A dictionary that maps image sizes to file names.  The image size
            is an integer representing one side of the equilateral image to be
            created.

    Returns:

        None
    """
    storage = get_profile_image_storage()

    original = Image.open(image_file)
    image = _set_color_mode_to_rgb(original)
    image = _crop_image_to_square(image)

    for size, name in profile_image_names.items():
        scaled = _scale_image(image, size)
        exif = _get_corrected_exif(scaled, original)
        with closing(_create_image_file(scaled, exif)) as scaled_image_file:
            storage.save(name, scaled_image_file)


def remove_profile_images(profile_image_names):
    """
    Physically remove the image files specified in `profile_image_names`
    """
    storage = get_profile_image_storage()
    for name in profile_image_names.values():
        storage.delete(name)


def validate_uploaded_image(uploaded_file):
    """
    Raises ImageValidationError if the server should refuse to use this
    uploaded file as the source image for a user's profile image.  Otherwise,
    returns nothing.
    """

    # validation code by @pmitros,
    # adapted from https://github.com/pmitros/ProfileXBlock
    # see also: http://en.wikipedia.org/wiki/Magic_number_%28programming%29

    if uploaded_file.size > settings.PROFILE_IMAGE_MAX_BYTES:
        file_upload_too_large = _(
            u'The file must be smaller than {image_max_size} in size.'
        ).format(
            image_max_size=_user_friendly_size(settings.PROFILE_IMAGE_MAX_BYTES)
        )
        raise ImageValidationError(file_upload_too_large)
    elif uploaded_file.size < settings.PROFILE_IMAGE_MIN_BYTES:
        file_upload_too_small = _(
            u'The file must be at least {image_min_size} in size.'
        ).format(
            image_min_size=_user_friendly_size(settings.PROFILE_IMAGE_MIN_BYTES)
        )
        raise ImageValidationError(file_upload_too_small)

    # check the file extension looks acceptable
    filename = unicode(uploaded_file.name).lower()
    filetype = [ft for ft in IMAGE_TYPES if any(filename.endswith(ext) for ext in IMAGE_TYPES[ft].extensions)]
    if not filetype:
        file_upload_bad_type = _(
            u'The file must be one of the following types: {valid_file_types}.'
        ).format(valid_file_types=_get_valid_file_types())
        raise ImageValidationError(file_upload_bad_type)
    filetype = filetype[0]

    # check mimetype matches expected file type
    if uploaded_file.content_type not in IMAGE_TYPES[filetype].mimetypes:
        file_upload_bad_mimetype = _(
            u'The Content-Type header for this file does not match '
            u'the file data. The file may be corrupted.'
        )
        raise ImageValidationError(file_upload_bad_mimetype)

    # check magic number matches expected file type
    headers = IMAGE_TYPES[filetype].magic
    if uploaded_file.read(len(headers[0]) / 2).encode('hex') not in headers:
        file_upload_bad_ext = _(
            u'The file name extension for this file does not match '
            u'the file data. The file may be corrupted.'
        )
        raise ImageValidationError(file_upload_bad_ext)
    # avoid unexpected errors from subsequent modules expecting the fp to be at 0
    uploaded_file.seek(0)


def _crop_image_to_square(image):
    """
    Given a PIL.Image object, return a copy cropped to a square around the
    center point with each side set to the size of the smaller dimension.
    """
    width, height = image.size
    if width != height:
        side = width if width < height else height
        left = (width - side) // 2
        top = (height - side) // 2
        right = (width + side) // 2
        bottom = (height + side) // 2
        image = image.crop((left, top, right, bottom))
    return image


def _set_color_mode_to_rgb(image):
    """
    Given a PIL.Image object, return a copy with the color mode set to RGB.
    """
    return image.convert('RGB')


def _scale_image(image, side_length):
    """
    Given a PIL.Image object, get a resized copy with each side being
    `side_length` pixels long.  The scaled image will always be square.
    """
    return image.resize((side_length, side_length), Image.ANTIALIAS)


def _create_image_file(image, exif):
    """
    Given a PIL.Image object, create and return a file-like object containing
    the data saved as a JPEG.

    Note that the file object returned is a django ContentFile which holds data
    in memory (not on disk).
    """
    string_io = StringIO()

    # The if/else dance below is required, because PIL raises an exception if
    # you pass None as the value of the exif kwarg.
    if exif is None:
        image.save(string_io, format='JPEG')
    else:
        image.save(string_io, format='JPEG', exif=exif)

    image_file = ContentFile(string_io.getvalue())
    return image_file


def _get_corrected_exif(image, original):
    """
    If the original image contains exif data, use that data to
    preserve image orientation in the new image.
    """
    if 'exif' in original.info:
        image_exif = image.info.get('exif', piexif.dump({}))
        original_exif = original.info['exif']
        image_exif = _update_exif_orientation(image_exif, _get_exif_orientation(original_exif))
        return image_exif


def _update_exif_orientation(exif, orientation):
    """
    Given an exif value and an integer value 1-8, reflecting a valid value for
    the exif orientation, return a new exif with the orientation set.
    """
    exif_dict = piexif.load(exif)
    if orientation:
        exif_dict['0th'][piexif.ImageIFD.Orientation] = orientation
    return piexif.dump(exif_dict)


def _get_exif_orientation(exif):
    """
    Return the orientation value for the given Image object, or None if the
    value is not set.
    """
    exif_dict = piexif.load(exif)
    return exif_dict['0th'].get(piexif.ImageIFD.Orientation)


def _get_valid_file_types():
    """
    Return comma separated string of valid file types.
    """
    return ', '.join([', '.join(IMAGE_TYPES[ft].extensions) for ft in IMAGE_TYPES.keys()])


def _user_friendly_size(size):
    """
    Convert size in bytes to user friendly size.

    Arguments:
        size (int): size in bytes

    Returns:
        user friendly size
    """
    units = [_('bytes'), _('KB'), _('MB')]
    i = 0
    while size >= 1024 and i < len(units):
        size /= 1024
        i += 1
    return u'{} {}'.format(size, units[i])
