import os
from os.path import join
try:
    from hashlib import md5
except ImportError:
    from md5 import md5
import zipfile
from StringIO import StringIO
from utils import urljoin, subrect, pdf2img, havedir, uniq, replace_ampersands_ands, replace_dashes_underscores

from PIL import Image as PILImage
from rlextra.pageCatcher.pdfexplorer import PdfExplorer

from django import forms
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.db import connection, models, transaction
from django.db.models import Q
from django.db.models.fields.files import ImageField, ImageFieldFile
from django.template.defaultfilters import slugify

#for this to return anything useful, UserFindingMiddleware needs installing
from rldjango.common.middleware import getCurrentUser

THUMB_DIR = join(settings.IMADJ_ROOT, 'thumbs')
TMP_DIR = join(settings.IMADJ_ROOT, 'tmp')

THUMB_URL = urljoin(settings.IMADJ_URL, 'thumbs')
TMP_URL = urljoin(settings.IMADJ_URL, 'tmp')

THUMBSIZE = 128, 128
PREVIEWSIZE = 640, 480

class ImageException(Exception):
    """Used when we want to raise image related exceptions
    (incorrect format, could not parse, wrong size etc)
    """
    pass

class Size(models.Model):
    "An allowed size for a bitmap image"
    id = models.CharField(max_length=20, primary_key=True, verbose_name='name', help_text="e.g. banner, thumbnail, standard etc")
    width = models.PositiveIntegerField(help_text="If width2 is not specified, the image width must match this value, otherwise this is the lower bound of the image width. If 0 there is no lower bound.")
    height = models.PositiveIntegerField(help_text="Same as for width.")
    format = models.CharField(max_length=20, null=True) # a comma separated list of allowed formats e.g 'JPEG,GIF,PNG'. Normalize to uppercase of known formqats.
    width2 = models.PositiveIntegerField(null=True, blank=True, help_text="If not specified, this is the upper bound of the image width. If 0, there is no upper bound.")
    height2 = models.PositiveIntegerField(null=True, blank=True, help_text="Same as for width2.")
    
    known_formats = ['GIF', 'JPEG', 'PNG', 'PDF', 'SVG', 'TIFF']

    def __unicode__(self):
        return self.id

    def describe(self):
        return "%s: %dx%d, %s" % (self.id, self.width, self.height, self.format)
    
    @staticmethod
    def normalize_format(f):
        f = f.upper()
        if f == 'JPG':
            return 'JPEG'
        if not f in Size.known_formats:
            raise ValueError('Unkown format %s' % f)
        return f

    def normalize(self):
        if not self.format:
            return
        self.format = ','.join(self.formats())
        
    def save(self, *args, **kwargs):
        self.normalize()
        super(Size, self).save(*args, **kwargs)

    def formats(self):
        '''
        Return a set of the normalized formats.

        >>> s1 = Size(id="logo", width=182, height=113, format="gif,jpg,png")
        >>> s1.formats()
        ['GIF', 'JPEG', 'PNG']

        '''
        if not self.format:
            return []
        return uniq([ Size.normalize_format(f) for f in self.format.split(',') ])
    
    def assert_match(self, width, height, format):
        '''
        Tries to assert that the size matches the given width, height and format.
        If not, it will throw an ImageException specifying the reason for the failure.
        
        >>> s1 = Size(format='GIF', width=1, height=1, width2=5, height2=5)
        >>> s2 = Size(width=2, height=2, format='GIF')
        >>> s3 = Size(width=1, height=1, format='GIF', width2=0, height2=0)
        >>> s4 = Size(width=0, height=0, format='GIF', width2=0, height2=19)
        >>> s1.assert_match(width=2, height=2, format='GIF')
        >>> s1.assert_match(width=6, height=2, format='GIF')
        Traceback (most recent call last):
        ImageException: Incorrect width for this image.  Expected 1-5 but got 6
        >>> s1.assert_match(width=2, height=6, format='GIF')
        Traceback (most recent call last):
        ImageException: Incorrect height for this image.  Expected 1-5 but got 6
        >>> s2.assert_match(2, 2, 'GIF')
        >>> s2.assert_match(2, 3, 'GIF')
        Traceback (most recent call last):
        ImageException: Incorrect height for this image.  Expected 2 but got 3
        >>> s2.assert_match(2, 0, 'GIF')
        Traceback (most recent call last):
        ImageException: Incorrect height for this image.  Expected 2 but got 0
        >>> s3.assert_match(2000, 2000, 'GIF')
        >>> s3.assert_match(2000, 0, 'GIF')
        Traceback (most recent call last):
        ImageException: Incorrect height for this image.  Expected 1-? but got 0
        >>> s4.assert_match(345, 18, 'GIF')
        >>> s4.assert_match(345, 20, 'GIF')
        Traceback (most recent call last):
        ImageException: Incorrect height for this image.  Expected ?-19 but got 20
        >>> s1.assert_match(3, 3, 'JPEG')
        Traceback (most recent call last):
        ImageException: Incorrect format for this image.  Expected one of ['GIF'] but got JPEG
        '''

        if self.width2 == None:
            if self.width != width:
                raise ImageException('Incorrect width for this image.  Expected %s but got %s'
                    % (self.width, width))
        else:
            if not ((self.width <= width) and (not self.width2 or self.width2 >= width)):
                raise ImageException('Incorrect width for this image.  Expected %s-%s but got %s'
                    % (self.width or '?', self.width2 or '?', width))

        if self.height2 == None:
            if self.height != height:
                raise ImageException('Incorrect height for this image.  Expected %s but got %s'
                    % (self.height, height))
        else:
            if not ((self.height <= height) and (not self.height2 or self.height2 >= height)):
                raise ImageException('Incorrect height for this image.  Expected %s-%s but got %s'
                    % (self.height or '?', self.height2 or '?', height))
                
        if not format in self.formats():
            raise ImageException('Incorrect format for this image.  Expected one of %s but got %s'
                    % (self.formats(), format))
        
    @staticmethod
    def matching(width, height, format):
        ''''
        Returns a queryset with Size objects matching the given width, height and format.
        
        >>> Size(id='s1', width=1, height=1, format='gif', width2=5, height2=5).save()
        >>> Size(id='s2', width=2, height=2, format='gif').save()
        >>> Size(id='s3', width=1, height=1, format='gif', width2=0, height2=0).save()
        >>> Size(id='s4', width=0, height=0, format='gif', width2=0, height2=19).save()
        >>> Size.matching(2, 2, 'gif')
        [<Size: s1>, <Size: s2>, <Size: s3>, <Size: s4>]
        >>> Size.matching(1, 1, 'gif')
        [<Size: s1>, <Size: s3>, <Size: s4>]
        >>> Size.matching(200000, 19, 'gif')
        [<Size: s3>, <Size: s4>]
        >>> Size(id="logo", width=182, height=113, format="gif,jpg,png").save()
        >>> Size(id="logo2", width=182, height=113, format="jpg,png").save()
        >>> Size(id="logo3", width=182, height=114, format="gif,jpg,png").save()
        >>> sorted(Size.matching(width=182, height=113, format='GIF'))
        [<Size: logo>, <Size: s3>]
        >>> [ s.delete() for s in Size.objects.filter(id__in=('s1', 's2', 's3', 's4')) ]
        [None, None, None, None]
        '''
        format = format.upper()
        
        widthq = Q(width=width) \
                | Q(Q(width2__isnull=False), \
                        Q(width__lte=width), \
                        Q(width2__gte=width) | Q(width2=0))
        heightq = Q(height=height) \
                | Q(Q(height2__isnull=False), \
                        Q(height__lte=height), \
                        Q(height2__gte=height) | Q(height2=0))
        
        matches = Size.objects.filter(Q(format__icontains=format), widthq, heightq)
        excludes = []
        for m in matches:
           if not format in m.formats():
               excludes.append(m.name)
        matches = matches.exclude(id__in=excludes)
        return matches
    

class Tag(models.Model):
    """General collection of tags to apply to images.
    Generally any themes in the hotel model will have a corresponding
    tag, but extra tags can be created e.g. "happy_porter", "exterior".
    """
    
    id = models.CharField(max_length=200, primary_key=True, verbose_name='tag')
    description = models.CharField(max_length=100, blank=True, default='', help_text="Put help here for when to use this tag")

    def __unicode__(self):
        return self.id

    def save(self, *args, **kwargs):
        self.id = self.id.lower()
        if self.id.startswith('the '):
            self.id = self.id[4:]
        super(Tag, self).save(*args, **kwargs)
        
class ThumbnailImageFieldFile(ImageFieldFile):
    def _get_thumb_path(self):
        return self.path
    thumb_path = property(_get_thumb_path)
    
    def _get_thumb_url(self):
        return self.url
    thumb_url = property(_get_thumb_url)

    def save(self, name, content, save=True):
        super(ThumbnailImageFieldFile, self).save(name, content, save)
        img = Image.open(self.path)
        img.thumbnail(
            (self.field.thumb_width, self.field.thumb_height),
            Image.ANTIALIAS
        )
        img.save(self.thumb_path, 'JPEG')

    def delete(self, save=True):
        if os.path.exists(self.thumb_path):
            os.remove(self.thumb_path)
        super(ThumbnailImageFieldFile, self).delete(save)
        
class ThumbnailImageField(ImageField):
    """
    Behaves like a regular ImageField, but stores an extra (JPEG) thumbnail
    image, providing FIELD.thumb_url and FIELD.thumb_path.
    
    Accepts two additional, optional arguments: thumb_width and thumb_height,
    both defaulting to 128 (pixels). Resizing will preserve aspect ratio while
    staying inside the requested dimensions; see PIL's Image.thumbnail()
    method documentation for details.
    """
    attr_class = ThumbnailImageFieldFile

    def __init__(self, thumb_width=128, thumb_height=128, *args, **kwargs):
        self.thumb_width = thumb_width
        self.thumb_height = thumb_height
        super(ThumbnailImageField, self).__init__(*args, **kwargs)        
        

class Image(models.Model):
    id = models.CharField(max_length=50, primary_key=True)
    description = models.CharField(max_length=250, blank=True, help_text="what is it a picture of?")
    caption = models.CharField(max_length=100, blank=True)
    original_image_date = models.DateField(blank=True, null=True)  #when photo taken or map drawn...
    size = models.ForeignKey(Size)  #also defines file type e.g. GIF, JPG - should validate upload against both the file type and the size
    tags = models.ManyToManyField(Tag, blank=True)
    original_url = models.CharField(max_length=500, help_text="original url", blank=True, editable=False)
    source = models.CharField(max_length=500, default="(unknown)", help_text="note on how this got into our system", blank=True, editable=False)
    date_created = models.DateTimeField(auto_now_add=True, editable=False)  #complete when saved
    date_modified = models.DateTimeField(auto_now=True, editable=False)  #complete when saved
    last_edited_by = models.CharField(max_length=50, null=True, blank=True, editable=False)  #should be user...
    format = models.CharField(max_length=5, help_text="JPEG, GIF, PNG, PDF, SVG, ...")
    width = models.PositiveIntegerField()
    height = models.PositiveIntegerField()
    info = models.CharField(max_length=5000, help_text="repr of python dictionary containing miscelaneous info about the image", blank=True)
    ordering = models.PositiveIntegerField(default=0)  #Used for specifying the transition of images
    custom1 = models.CharField(max_length=50, null=True, blank=True)
    custom2 = models.CharField(max_length=50, null=True, blank=True)
    custom3 = models.CharField(max_length=50, null=True, blank=True)
    custom4 = models.CharField(max_length=50, null=True, blank=True)
    custom5 = models.CharField(max_length=50, null=True, blank=True)
    custom6 = models.CharField(max_length=50, null=True, blank=True)

    def __unicode__(self):
        "This is the default text representation; tends to appear in dropdowns"
        if self.description:
            return u"RLIMG: " + self.description
        else:
            return u'%s'%self.id
    
    def __getattr__(self, val):
        try:
            key = [k for k, v in settings.IMADJ_CUSTOM_ATTIBUTES if v == val][0]
            return getattr(self, key)
        except:
            raise AttributeError('no such attribute %r' % val)

    @staticmethod
    def findreferences(databases=[]):
        '''
        Scans all text-like columns in the database used by django plus the ones in the databases parameter
        and returns all id's of images of which references have been found.
        '''

        # FIXME: Escape names which go into queries

        ignore_tables = ['imadj_image', 'imadj_image_tags', 'django_admin_log']
        possible_refs = [ img.id for img in Image.objects.all() ]
        refs = []

        def istextcol(col):
            coltypes = ['varchar', 'longtext', 'text', 'tinytext']
            for c in coltypes:
                if col.startswith(c):
                    return True
            return False

        # Contains tuples of 2 elements: a table name and its text column names
        tcolumns = []

        cursor = connection.cursor()
        cursor.execute('SHOW TABLES')
        tables = [str(r[0]) for r in cursor.fetchall()]
        for database in databases:
            cursor.execute('SHOW TABLES FROM %s' % (database,))
            tables.extend(['%s.%s' % (database, r[0]) for r in cursor.fetchall()])
        for table in tables:
            if table in ignore_tables:
                continue
            cursor.execute('SHOW COLUMNS FROM %s' % (table,))
            columns = [r[0] for r in cursor.fetchall() if istextcol(r[1])]
            tcolumns.append((table, columns))

        for table, columns in tcolumns:
            for column in columns:
                cmd = 'SELECT %s FROM %s WHERE %s like "%%%%RLIMG_%%%%"' % (column, table, column,)
                cursor.execute(cmd)
                for match in [ r[0] for r in cursor.fetchall() ]:
                    idx = match.index('RLIMG_')
                    ref = match[idx:idx+42]
                    if ref in possible_refs:
                       refs.append(ref)
        return refs

    @staticmethod
    def newtmp(imgfile):
        '''
        Stores a temporary file with a valid filename and returns a dictionary with information about this file.
        '''
        bytes = imgfile.read()
        info = Image.getinfo(bytes)
        if info['already_present']:
            return info
        
        havedir(TMP_DIR)
        tmpf = open(join(TMP_DIR, info['id']), 'wb')
        tmpf.write(bytes)
        tmpf.close()
        return info

    @staticmethod
    def new_zip(fileName, content):
        '''Store a zip file, delivered in memory.
        
        Scheme is to make a directory with the same name as the zip.
        
        save IMAGEDATA/TMP/foo.zip
        explode to IMAGEDATA/TMP/foo/
        then remove the former
        
        Should we hash it?
        '''
        fileName = fileName.lower()
        root = os.path.splitext(fileName)[0]
        # FIXME: zipFileName and zipDirName are not unique and can easily create conflicts!
        zipFileName = os.path.join(TMP_DIR, fileName)
        zipDirName = os.path.join(TMP_DIR, root)
        
        havedir(TMP_DIR)
        havedir(zipDirName)

        msg = []
        open(zipFileName, 'wb').write(content)   #saves TMP/foo.zip
        z = zipfile.ZipFile(zipFileName)
        for name in z.namelist():
            z.extract(name, zipDirName)
            msg.append(name)
            
        
        raise ValueError("created directory %s with %d files" % (zipDirName, len(msg)))


    @staticmethod
    def getinfo(bytes):
        '''
        Returns a dictionary containing information about the image formed of the given bytes.
        If not found, raise an IOError.
        
        This will also contain a value already_present.
        '''
        pdf = img = None
        hash = md5(bytes).hexdigest()
        try:
            # Need to check for all the common problems over the years...
            # valid .jpg ending in .gif etc  (if we can detect format, we should fix the extension)
            # limit to common web formats (GIF, JPEG, PNG to start?)
            # extreme case - make a PDF containing the image and rasterize it - may be excessive
            # For now, load the image for a poor form of validation
            if "%PDF" in bytes[0:20]:
                pdf = PdfExplorer(bytes)
            else:
                img = PILImage.open(StringIO(bytes))
                img.load()
        except (ValueError, IOError), e:
            msg = "unable to verify image - " + str(e)
            raise ImageException(msg)
        info = {}
        if pdf:
            for btype in 'Media', 'Art', 'Bleed', 'Trim', 'Crop':
                try:
                    info['box_%s' % btype.lower()] = getattr(pdf, 'get%sBox' % btype)(0)
                except KeyError:
                    continue
            info['format'] = 'PDF'
            info['width'], info['height'] = 0, 0
        elif img:
            info['width'], info['height'] = img.size
            info['format'] = Size.normalize_format(img.format)

        info['filesize'] = len(bytes)

        info['id'] = 'RLIMG_%s.%s' % (hash, info['format'])
        
        matches = Size.matching(info['width'], info['height'], info['format'])
        info['possible_sizes'] = [x.id for x in matches]
        info['already_present'] = (Image.objects.filter(id=info['id']).count() == 1)
        
        return info

    @staticmethod
    def new(path=None, url=None, size=None):
        '''
        Creates, saves and returns an Image instance.
        If path is specified it loads the image from this path.
        If url is specified it loads it from the specified url.
        If size is given, image must match it or an exception will be raised.
        '''
        
        if path:
            f = open(path, 'rb')
            try:
                info = Image.newtmp(f)
            finally:
                f.close()
        elif url:
            import urllib2
            f = urllib2.urlopen(url)  #raises 404 if not found
            content = f.read()
            info = Image.newtmp(StringIO(content))
        
        if size is None:
            if len(info['possible_sizes']) == 1:
                sizeName = info['possible_sizes'][0]
                size = Size.objects.get(id=sizeName)
                
        if info['already_present']:
            obj = Image.objects.get(id=info['id'])
        #is it there already?  If so, return the one we have.
        else:
            obj = Image(id=info['id'])
            if size:
                obj.size=size
            #always populate source; it's helpful in exceptions
            if url:
                #this is a good identifier
                obj.original_url = obj.source = url
            if path:
                #directory it came from is probably meaningless,
                #just take last part of filename e.g. "logo.gif"
                obj.source = os.path.split(path)[-1]
            obj.save(info)
        return obj

    def get_file_name(self):
        "Return the full filesystem path to the image on disk"
        destfname = join(settings.IMADJ_ROOT, self.id)
        if os.path.isfile(destfname):
            return destfname
        tmpfname = join(TMP_DIR, self.id)
        if os.path.isfile(tmpfname):
            return tmpfname
        return ''

    def clean(self):
        'django 1.4+'
        self.last_edited_by = getCurrentUser()
        fname = self.get_file_name()
        if not fname:
            raise ValidationError('No associated file found for this image. Use new or newtmp to create a new Image object.')
        info = Image.getinfo(open(fname,'rb').read())
        if not self.size_id:  #means it was not set
            matches = Size.matching(info['width'], info['height'], info['format'])
            if len(matches) == 0:
                raise ValidationError("Image does not match any known sizes: " + self.source)
            elif len(matches) == 1:
                self.size = matches[0]
            else:
                raise ValidationError("Image matches multiple sizes, please specify which.")
        try:
            self.size.assert_match(info['width'], info['height'], info['format'])
        except ImageException, e:
            raise ValidationError('Image %s: %s' % (self.source, e.args[0]))

    def save(self, info=None):
        '''
        Save or update the image, or raise a very helpful exception
        
        If image already there, just save new properties.
        If tmpimage there and info in self, move to dest and validate with info.
        If tmpimage there but no info, get info from tmp and do the above.
        If no size given but there is one possible size, use that size silently
        If no tmpimage raise exception.
        
        Exceptions
        '''
        
        self.last_edited_by = getCurrentUser()
        fname = self.get_file_name()
        if not fname:
            raise ImageException('No associated file found for this image. Use new or newtmp to create a new Image object.')
        if not info:
            info = Image.getinfo(open(fname,'rb').read())

        for k in 'width height format'.split():
            setattr(self, k, info[k])

        if not self.size_id:  #means it was not set
            matches = Size.matching(info['width'], info['height'], info['format'])
            if len(matches) == 0:
                raise ImageException("Image does not match any known sizes: " + self.source)
            elif len(matches) == 1:
                self.size = matches[0]
            else:
                raise ImageException("Image matches multiple sizes, please specify which.")
        try:
            self.size.assert_match(info['width'], info['height'], info['format'])
        except ImageException, e:
            raise ImageException('Image %s: %s' % (self.source, e.args[0]))
        destfname = join(settings.IMADJ_ROOT, self.id)
        if fname != destfname:
            os.rename(fname, destfname)

        img_info = eval(self.info) if self.info else {}
        img_info.update(dict([ (k, v) for k, v in info.iteritems() if k.startswith('box_')]))
        self.info = repr(img_info)
        
        return super(Image, self).save()

    def delete(self):
        """End users will not have access to this.  Delete the disk file too."""
        filename = self.get_file_name()
        if os.path.isfile(filename):
            os.remove(filename)
        return super(Image, self).delete()

    def name(self):
        return self.id

    def get_absolute_url(self):
        for dir, url in ((settings.IMADJ_ROOT, settings.IMADJ_URL), (TMP_DIR, TMP_URL)):
            fname = join(dir, self.id)
            if os.path.isfile(fname):
                return urljoin(url, self.id)
        raise ImageException('Object in inconsistent state. Image on disk not found.')

    def get_preview_url(self):
        if self.id.endswith('.PDF'):
            url = urljoin(THUMB_URL, 'preview_%s.jpg' % self.id)
            fname = join(THUMB_DIR, 'preview_%s.jpg' % self.id)
            if os.path.isfile(fname):
                return url

            img = pdf2img(join(settings.IMADJ_ROOT, self.id), PREVIEWSIZE, self.id)
            img_info = eval(self.info) if self.info else {}
            
            if img_info.has_key('box'):
                r = subrect(img_info['box_media'], img_info['box_%s' % img_info['box']], img.size)
                img = img.crop(r)
            img.save(fname)
            return url
        return self.get_absolute_url()
    
    def get_thumbnail_url(self):
        url = urljoin(THUMB_URL, '%s.jpg' % self.id)
        fname = join(THUMB_DIR, '%s.jpg' % self.id)
        if os.path.isfile(fname):
            return url
        havedir(THUMB_DIR)
        if self.id.endswith('.PDF'):
            pdf_fname = join(settings.IMADJ_ROOT, self.id)
            img = pdf2img(pdf_fname, THUMBSIZE, self.id)
            img_info = eval(self.info) if self.info else {}
            
            if img_info.has_key('box'):
                r = subrect(img_info['box_media'], img_info['box_%s' % img_info['box']], img.size)
                img = img.crop(r)
        else:
            img = PILImage.open(join(settings.IMADJ_ROOT, self.id))
            img.thumbnail(THUMBSIZE, PILImage.ANTIALIAS)
        # Remove any indexed palette (e.g. if original was a GIF) as it creates ugly artifacts when resizing
        img2 = img.convert()
        img2.save(fname)
        return url

    def get_thumbnail_tag(self):
        "Returns an <img src=...> tag, used by the admin and other 'reports'"
        return '<img alt="%s" src="%s"/> ' % (self.description, self.get_thumbnail_url())
    get_thumbnail_tag.allow_tags = True

    def get_tag_text(self):
        "Returns a string of tags on this image"
        return u','.join(unicode(t) for t in self.tags.all())
    get_tag_text.allow_tags = True


    def set_tag_text(self, tagText):
        """Allow easy setting of tags from comma- or space-delimited strings.
        
        Setting to an empty string clears all tags.  If commas present, assume
        they are the delimiter.  Otherwise assume spaces.  Lowercase them all.
        """
        
        tagNames = []
        if tagText:
            if ',' in tagText:
                #might let spaces in
                tagNames = filter(None,[t.strip() for t in tagText.split(',')])
            else:
                tagNames = tagText.strip().split()
            tagNames = map(replace_ampersands_ands,tagNames)
            tagNames = map(slugify,tagNames)
            tagNames = map(replace_dashes_underscores,tagNames)
        #first, do they all exist?
        self.tags.clear()
        for tagName in tagNames:
            #does it exist?
            try:
                tag = Tag.objects.get(id=tagName)
            except Tag.DoesNotExist:
                tag = Tag(id=tagName)
                tag.save()
            self.tags.add(tag)

    tag_text = property(get_tag_text, set_tag_text, None, "Text representation of the related tags")
        
    def get_size_description(self):
        "Useful in admin and reports"
        return '%s (%dx%d,%s)' % (self.id, self.width, self.height, self.format)
    
    @staticmethod
    def bad(fname):
        baddir = os.path.join(settings.IMADJ_ROOT, 'bad')
        havedir(baddir)
        os.rename(fname, os.path.join(baddir, os.path.basename(fname)))


class SizeChoiceField(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        return '%s (%dx%d) (%s)' % (obj.id, obj.width, obj.height, obj.format)




