"""
Module: DMS Core main Document object.

Project: Adlibre DMS
Copyright: Adlibre Pty Ltd 2013
License: See LICENSE for license information
Author: Iurii Garmash
"""


import os
import re
import ntpath
import magic
import mimetypes
import time
import logging

from django.conf import settings
from django.db import models
from django.db.models import ForeignKey, CharField
from core.errors import DmsException

from django.core.cache import get_cache
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType

from taggit.managers import TaggableManager

import dms_plugins

log = logging.getLogger('core')

__all__ = ['DocumentTypeRule', 'DocumentTypeRuleManager', 'DocumentTypeRulePermission', 'Document', 'DocTags']


def get_doctypes():
    """returns a list of tuple for possible document types"""
    # Add method here to add ability to validate and write a validator for it.
    return [
        ('1', 'Default'),
        ('2', 'Credit Card'),
        ('3', 'Book'),
    ]


class DocumentTypeRule(models.Model):
    """
    Document Type Rules definition

    Sets up storage at the filesystem rules and several hooks for farther DMS interaction
    """
    doccode_type = models.CharField(choices=get_doctypes(), max_length=64, default='1')
    sequence_last = models.IntegerField(
        "Number of Documents", default=0,
        help_text="Last document stored. (Don't change unless you understand the consequences.)"
    )
    title = models.CharField("Document Type Rule Name", max_length=60)
    description = models.TextField("Description", blank=True)
    regex = models.CharField(
        "Filename Regex",
        max_length=100,
        blank=True,
        help_text="""
        Regex To validate Filaname against. <br />
        E.g.: <br />
        [a-z]{5}[0-9]{3}<br />
        Will validate documents with name like 'abcde222'<br />
        """
    )
    split_string = models.CharField(
        "Spliting Method",
        max_length=100,
        blank=True,
        help_text="""
        WARNING! Please assign a Split method for 'Default' doc type rule!. <br />
        It is responsible for 'Folder hierarchy' of files stored.<br />
        E.g.:<br />
        Document name: 'abcde222'<br />
        Spliting method:  ['abcde', '222', 'abcde222']<br />
        Folder Files Stored: /{{ doccode_path }}/abcde/222/abcde222/<br />
        Split string: <br />
        """
    )
    barcode_format = models.CharField(
        "Barcode Format",
        max_length=100,
        blank=False,
        default='%s',
        help_text="""
        Barcode format. Eg: <br />
        'ADL-%s'
        """
    )
    barcode_zfill = models.IntegerField(
        "Barcode Integer Length",
        max_length=2,
        blank=True,
        default=0,
        help_text="""
        Used to zero fill the integer portion of the autogenerated barcode.
        """
    )

    active = models.BooleanField(default=True)

    def __unicode__(self):
        return unicode(self.get_title())

    def save(self, *args, **kwargs):
        """Overriding save method to add permissions into admin

        @param args: arguments
        @param kwargs arguments
        """
        content_type, created = ContentType.objects.get_or_create(
            app_label='rule',
            model='',
            name='document type'
        )  # filtered upon usage with this 'name'!
        Permission.objects.get_or_create(
            codename=self.title,
            name='Can interact '+unicode(self.title),
            content_type=content_type
        )
        super(DocumentTypeRule, self).save(*args, **kwargs)

    def validate(self, document_name):
        """Validates DocumentTypeRule against available "document_name" string.

        @param document_name: is a name of a document code
        @return True if OK and False if not passed.
        """

        # TODO: expansion to validate document_name against "is_luhn_valid(self, cc)" for document_type:2 (credit Card)
        #print '%s Uncategorized: %s' % (self, self.uncategorized)
        regex = '^' + str(self.regex) + '$'
        if self.regex == '' and re.match(regex, document_name) and self.uncategorized:
            return True
        if not self.uncategorized and re.match(regex, document_name):
            return True
        return False

    def split(self, document_name=''):
        """Method to generate folder hierarchy to search for document depending on name.

        @param document_name: is a nplitting dcoderelevant

        Returns spliting method List:
        Usage e.g.:
        File name:  abcde222.pdf (document_name = 'abcde222')
        Spliting method:  ['abcde', '222', 'abcde222']
        In case of document_type_rule == no_rule returns current DATE
        """
        split_method = False
        if self.doccode_type == '1':
            # Default Storing Documents based on self.split_string
            if self.split_string:
                split_list = self.split_string.split(',')
                split_method = []
                for pair in split_list:
                    s, e = pair.split(':')
                    split_method.append(document_name[int(s):int(e)])
                split_method.append(document_name)

        if self.doccode_type == '2':
            split_method = [
                document_name[0:4],
                document_name[5:9],
                document_name[10:13],
                document_name[14:18],
                document_name
            ]
        if self.doccode_type == '3':
            # Split document_name as per Project Gutenberg method for 'eBook number' not, eText
            # http://www.gutenberg.org/dirs/00README.TXT
            split_method = []
            for i in range(len(document_name)):
                split_method.append(document_name[i-1:i])
            split_method.append(document_name)
        if not split_method:
            split_method = ['Split_errors', ]  # folder name for improper doccdes!!!!!
            log.error('doc_codes.models.DocumentTypeRule.split Splitting Errors exist for code: %s' % document_name)
        #print "Spliting method: ", split_method
        return split_method

    def is_luhn_valid(self, cc):
        """Method to validate Luhn algorithm based on:
        # Credit: http://en.wikipedia.org/wiki/Luhn_algorithm

        @param cc: Credit Card number
        """
        num = [int(x) for x in str(cc)]
        return sum(num[::-2] + [sum(divmod(d * 2, 10)) for d in num[-2::-2]]) % 10 == 0

    def get_id(self):
        """Returns instance PK"""
        return self.pk

    def get_title(self):
        """Returns instance title"""
        title = getattr(self, 'title', '')
        return title

    def get_directory_name(self):
        """Returns instance directory number"""
        return str(self.get_id())

    def get_last_document_number(self):
        """GET last document number for this instance"""
        return self.sequence_last

    def set_last_document_number(self, number):
        """SET last document number for this instance.

        @param number: number to be set in format int()
        """
        self.sequence_last = int(number)
        self.save()
        return self

    def allocate_barcode(self):
        """Function increments last document number for this Document Type Model by int(1)"""
        log.debug('doc_codes.models allocate_barcode. sequence_last: %s', self.sequence_last)
        self.sequence_last += 1
        self.save()
        return self._generate_document_barcode(self.sequence_last)

    def show_last_allocated_barcode(self):
        """Function shows last available Document Code used for this Document Type Rule"""
        self.sequence_last += 1
        return self._generate_document_barcode(self.sequence_last)

    def _generate_document_barcode(self, sequence):
        """Function generates next barcode in sequence.
        As soon as it is generated, it must be assumed to be used to avoid race.
        """
        log.debug("doc_codes.models Generate document barcode called. sequence: %s", sequence)
        # HACK: Ideally we shouldn't need a 'barcode_format' field, as that could be inferred from the regex. (maybe?)
        # along with the integer padding required? eg assume a fixed length integer portion of a regex is padded
        # TODO: Validate the generated code to ensure it's valid, and not invalid according to the regex.

        if len(self.barcode_format) > 0:
            n = str(sequence)
            n = n.zfill(self.barcode_zfill)
            log.debug('barcode: %s' % self.barcode_format % n)
            return self.barcode_format % n
        else:
            log.debug("False barcode")
            return False

    def get_docrule_plugin_mappings(self):
        """Returns DocumentTypeRule Mapping for this instance"""
        log.info('get_docrule_mapping for DocumentTypeRule : %s.' % self)
        mapping = dms_plugins.models.DoccodePluginMapping.objects.filter(
            doccode=str(self.pk),
            active=True)
        if mapping.count():
            mapping = mapping[0]
        else:
            raise DmsException('Rule not found', 404)
        return mapping

    @property
    def uncategorized(self):
        """Boolean function to know if a model is set as uncategorised in DMS"""
        configs = CoreConfiguration.objects.filter(uncategorized__pk__exact=self.pk)
        if configs.count():
            # this config is uncategorized
            return True
        return False


class DocumentTypeRuleManager(object):
    """Helper to handle document type rule searches and operations"""

    def __init__(self):
        """Every manager Instance has it's own document type rules set in memory (cache), not touching the DB often."""
        # Using cache to store DB objects.
        cache_docrules_for = 300  # 5 minutes (new updated docrules will be stored)
        cache = get_cache('core')
        cache_key = 'docrules_objects'
        cached_docrules = cache.get(cache_key, None)
        if not cached_docrules:
            self.docrules = DocumentTypeRule.objects.all()
            cache.set(cache_key, self.docrules, cache_docrules_for)
        else:
            self.docrules = cached_docrules

    def get_uncategorized(self):
        """Returns Uncategorized document type rule (In case it is set in system) or None (In case it is not)"""
        dtr = None
        configs = CoreConfiguration.objects.filter()
        if configs.count() > 0:
            # Taking last config as a most proper one
            config = configs.reverse()[0]
            dtr = config.uncategorized
        return dtr

    def find_for_string(self, string):
        """Find a DocumentType that corresponds to certain string

        @param string: a string to check"""
        res = None
        for docrule in self.docrules:
            if docrule.validate(string):
                res = docrule
                break
        if res is None:
            # Assigning Uncategorized doc type
            res = self.get_uncategorized()
        return res

    def get_docrules(self):
        """Get cached document types"""
        return self.docrules

    def get_docrule_by_name(self, name):
        """Returns a document type rule based on it's name

        @param name: is a name of this DocumentTypeRule"""
        docrules = self.get_docrules()
        docrule_set = docrules.filter(title=name)
        if docrule_set:
            return docrule_set[0]
        else:
            return None

    def get_docrule_by_id(self, id_):
        """
        Works without making additional requests to DB.

        (when used with instance variable)
        @param id_: Model PK
        """
        docrules = self.docrules
        docrule_instance = docrules.get(pk=id_)
        return docrule_instance


class DocumentTypeRulePermission(models.Model):
    """Proxy model for a proper admin placement. All the magic in admin.py"""
    pass


class CoreConfiguration(models.Model):
    """Basic DMS settings and main functionality"""
    uncategorized = ForeignKey(
        DocumentTypeRule,
        help_text="""
        This is a setting for DMS to treat documents that do not belong to any other type.<br />
        DMS discards uncategorized files, in case this option is not set.<br />
        AUI usage requires DMS to have this option set.<br />
        <br />
        # TODO: describe this<br />
        Uncategorized option must be a Document Type Rule without regexp<br />""")
    aui_url = CharField(
        max_length=150,
        blank=True,
        help_text="""Admin UI (AUI) interface url, like: http://www.mysite.com:3000/""")

    class Meta:
        # Add new fields here (upon migrations)
        # to ensure it's hard to mess things up with creation of several similar configs
        unique_together = ('uncategorized', 'aui_url')


class Document(object):
    """
    DMS core Document Object

    Basic internal building block.
    Represents an instance of unique NAME.
    All other DMS processing use it as a main building block.
    However main interaction method of this document is DocumentManager()

    """
    # TODO: make it DMS_OBject (or similar) instead of document, because we would need it to store another file types
    def __init__(self):
        """List of options DMS document object may have during lifetime"""
        self.options = {}
        self.user = None
        self.docrule = None
        self.old_docrule = None
        self.old_name_code = None
        self.file_name = None
        self.full_filename = None
        self.code = None
        self.thumbnail = None
        self.revision = None
        self.hashcode = None
        self.file_revision_data = None
        self.file_revisions = {}
        self.fullpath = None
        self.file_obj = None
        self.current_file_revision_data = {}
        self.mimetype = None
        self.tags = []
        self.tag_string = ''
        self.remove_tag_string = ''
        self.requested_extension = None
        self.db_info = {}
        self.new_indexes = {}
        self.index_revisions = {}
        self.marked_deleted = False

    def get_name(self):
        name = self.get_filename()
        if not name:
            name = self.get_docrule().get_name()
        name = "<Document> %s" % name
        return name

    def __unicode__(self):
        return unicode(self.get_name())

    def __str__(self):
        return self.get_name()

    def __repr__(self):
        return self.get_name()

    def get_docrule(self):
        log.debug('get_docrule for %s.' % self.docrule)
        if self.docrule is None and self.get_filename():
            dman = DocumentTypeRuleManager()
            self.docrule = dman.find_for_string(self.get_code())
            if self.docrule is None:
                log.error('get_docrule. docrule is None!')
                # Using self.get_full_filename() method here can cause race condition in some cases.
                filename = self.full_filename
                if not filename:
                    filename = self.file_name
                raise DmsException("No document type rule found for file " + filename, 404)
        log.debug('get_docrule finished for %s.' % self.docrule)
        return self.docrule

    def set_change_type(self, new_docrule):
        """Method to change document docrule for plugin interactions

        @param new_docrule: is an instance of DocumentTypeRule or a unicode pk of it e.g.: u'8'
        populates hooks for plugin to know we are renaming/moving document
        self.old_docrule
        self.old_name

        preserves new_indexes
        """
        preserved_indexes = False
        if new_docrule.__class__.__name__ == 'unicode':
            # Call from API. We should produce a model instance instead of id
            new_docrule = DocumentTypeRuleManager().get_docrule_by_id(new_docrule)
        if self.new_indexes:
            preserved_indexes = self.new_indexes
        self.old_docrule = self.docrule
        self.old_name_code = self.get_filename()
        self.set_filename(new_docrule.allocate_barcode())
        self.docrule = new_docrule
        if preserved_indexes:
            self.new_indexes = preserved_indexes

    def get_mimetype(self):
        if not self.mimetype and self.get_current_file_revision_data():
            self.mimetype = self.get_current_file_revision_data().get('mimetype', None)
        if not self.mimetype and self.get_file_obj():
            mime = magic.Magic(mime=True)
            self.mimetype = mime.from_buffer(self.get_file_obj().read())
            log.debug('get_mimetype guessed mimetype: %s.' % self.mimetype)
        return self.mimetype

    def set_mimetype(self, mimetype):
        self.mimetype = mimetype
        self.update_current_file_revision_data({'mimetype': mimetype})

    def set_file_obj(self, file_obj):
        self.file_obj = file_obj

    def get_file_obj(self):
        if self.get_fullpath() and not self.file_obj:
            self.file_obj = open(self.get_fullpath(), 'rb')
            self.file_obj.seek(0)
        return self.file_obj

    def get_fullpath(self):
        return self.fullpath

    def set_fullpath(self, fullpath):
        self.fullpath = fullpath

    def set_filename(self, filename):
        self.file_name = filename
        # Need to renew docrule on document receives name
        self.docrule = self.get_docrule()

    def get_filename(self):
        try:
            name = self.file_name or self.file_obj.name
        except AttributeError:
            name = ''
        if not name and self.get_revision():
            name = self.get_current_file_revision_data()['name']
        return name

    def get_code(self):
        code = os.path.splitext(self.get_filename())[0]
        return code

    def get_full_filename(self):
        if not self.full_filename:
            name = self.get_filename()
            if self.get_docrule().uncategorized:
                if self.get_requested_extension():
                    name = "%s.%s" % (name, self.get_requested_extension())
            elif not os.path.splitext(name)[1][1:]:
                ext = self.get_extension_by_mimetype()
                # Fixes extension format 2 dots in API output filename (Bug #588)
                try:
                    if '.' in ext:
                        dot, ext = ext.split(".", 1)
                except Exception, e:  #FIXME: Except WHAT?
                    log.error('get_full_filename Exception %s' % e)
                    pass # file type conversion is in progress failing gracefully
                if ext:
                    name = "%s.%s" % (name, ext)
            self.full_filename = name
        return self.full_filename

    def set_full_filename(self, filename):
        self.full_filename = filename

    def get_extension_by_mimetype(self):
        mimetype = self.get_mimetype()
        ext = mimetypes.guess_extension(mimetype)
        return ext

    def get_extension(self):
        return os.path.splitext(self.get_full_filename())[1][1:]

    def set_requested_extension(self, extension):
        self.requested_extension = extension

    def get_requested_extension(self):
        return self.requested_extension

    def get_revision(self):
        r = self.revision
        if r:
            try:
                r = int(r)
            except ValueError:
                raise  # or r = None, I'm not sure which is more correct behaviour
        return r

    def set_revision(self, revision):
        self.revision = revision

    def get_filename_with_revision(self):
        revision = self.get_revision()
        if revision:
            name = "%s_r%s.%s" % (self.get_code(), self.get_revision(), self.get_extension())
        else:
            name = self.get_full_filename()
        return name

    def set_hashcode(self, hashcode):
        self.hashcode = hashcode

    def save_hashcode(self, hashcode):
        self.update_current_file_revision_data({'hashcode': hashcode})

    def get_hashcode(self):
        return self.hashcode

    ####################################### FILE REVISIONS HELPERS #####################################################
    def get_file_revisions_data(self):
        return self.file_revision_data

    def set_file_revisions_data(self, file_revision_data):
        self.file_revision_data = file_revision_data

    def update_current_file_revision_data(self, file_revision_data):
        self.get_current_file_revision_data().update(file_revision_data)

    def get_current_file_revision_data(self):
        if not self.current_file_revision_data and self.get_file_revisions_data() and self.get_revision():
            self.current_file_revision_data = self.get_file_revisions_data()[str(self.get_revision())]
        return self.current_file_revision_data

    ########################################### OPTIONS HELPERS ########################################################
    def get_options(self):
        return self.options

    def get_option(self, option):
        return self.options.get(option, None)

    def update_options(self, options):
        self.options.update(options)

    def set_option(self, key, value):
        self.options[key] = value

    def get_creation_time(self):
        file_revision_data = self.get_current_file_revision_data()
        if file_revision_data:
            creation_time = file_revision_data.get('creation_time', None)
        else:
            creation_time = time.strftime(settings.DATETIME_FORMAT,
                                          time.localtime(os.stat(self.get_fullpath()).st_ctime))
        return creation_time

    ################################################ SQL TAGS ##########################################################
    def get_tags(self):
        return self.tags

    def set_tags(self, tags):
        self.tags = tags

    def get_tag_string(self):
        return self.tag_string

    def set_tag_string(self, tag_string):
        if tag_string:
            self.tag_string = tag_string

    def get_remove_tag_string(self):
        return self.remove_tag_string

    def set_remove_tag_string(self, tag_string):
        if tag_string:
            self.remove_tag_string = tag_string

    def get_db_info(self):
        return self.db_info

    def set_db_info(self, db_info):
        self.db_info = db_info

    def update_db_info(self, new_indexes):
        self.new_indexes = new_indexes

    def set_index_revisions(self, revisions_dict):
        """Forces document to have specified index revisions"""
        if revisions_dict:
            self.index_revisions = revisions_dict

    def set_user(self, user):
        self.user = user

    def allocate_next_uncategorized(self):
        """Allocate next uncategorized document name and rename self into it

        This function only should be called upon storing a file and being certain in it.
        It allocates a next free code for setting it into filename, also adding self.options variable
        'uncategorized_filename' with original stored filename (e.g.: for indexes storage)"""
        if self.get_docrule().uncategorized:
            # Forcing filesystem name for file
            document_file = self.get_file_obj()
            original_name = document_file.name
            code = self.get_docrule().allocate_barcode()
            # In case we have a path instead of a filename with extension
            if os.path.sep in original_name:
                head, tail = ntpath.split(original_name)
                original_name = tail or ntpath.basename(head)
            original_code, extension = os.path.splitext(original_name)
            new_name = code + extension
            if document_file:
                self.update_options({'uncategorized_filename': original_name})
            if not self.mimetype:
                self.get_mimetype()
            if 'barcode' in self.options:
                self.set_option('barcode', code)
            self.set_filename(new_name)
            self.set_full_filename(new_name)
        return self

    def uncategorized(self):
        """Function proxy to resolve if current document is of uncategorized type"""
        return self.get_docrule().uncategorized


class DocTags(models.Model):
    """A model that represents Document for maintaining Tag relations."""
    name = models.CharField(max_length=128)
    doccode = models.ForeignKey(DocumentTypeRule)
    tags = TaggableManager()

    class Meta:
        verbose_name = "Document > Tags mapping"
        verbose_name_plural = "Document > Tags mappings"

    def get_tag_list(self):
        """Returns a list of current tags"""
        return map(lambda x: x.name, self.tags.all())

    def __unicode__(self):
        return unicode(self.name)