__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"


from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Avg, F
from django.utils import timezone
from django.urls import reverse

from .behaviors import doi_journal_validator, doi_volume_validator,\
                       doi_issue_validator, doi_publication_validator
from .constants import SCIPOST_JOURNALS, SCIPOST_JOURNALS_DOMAINS,\
                       STATUS_DRAFT, STATUS_PUBLISHED, ISSUE_STATUSES, PUBLICATION_PUBLISHED,\
                       CCBY4, CC_LICENSES, CC_LICENSES_URI, PUBLICATION_STATUSES,\
                       JOURNAL_STRUCTURE, ISSUES_AND_VOLUMES, ISSUES_ONLY
from .helpers import paper_nr_string, journal_name_abbrev_citation
from .managers import IssueQuerySet, PublicationQuerySet, JournalQuerySet

from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS
from scipost.fields import ChoiceArrayField


################
# Journals etc #
################

class UnregisteredAuthor(models.Model):
    first_name = models.CharField(max_length=100)
    last_name = models.CharField(max_length=100)

    def __str__(self):
        return self.last_name + ', ' + self.first_name


class PublicationAuthorsTable(models.Model):
    publication = models.ForeignKey('journals.Publication', related_name='authors')
    unregistered_author = models.ForeignKey('journals.UnregisteredAuthor', null=True, blank=True,
                                            related_name='+')
    contributor = models.ForeignKey('scipost.Contributor', null=True, blank=True, related_name='+')
    order = models.PositiveSmallIntegerField()

    class Meta:
        ordering = ('order',)

    def __str__(self):
        if self.contributor:
            return str(self.contributor)
        elif self.unregistered_author:
            return str(self.unregistered_author)

    def save(self, *args, **kwargs):
        if not self.order:
            self.order = self.publication.authors.count() + 1
        return super().save(*args, **kwargs)

    @property
    def is_registered(self):
        return self.contributor is not None

    @property
    def first_name(self):
        if self.contributor:
            return self.contributor.user.first_name
        if self.unregistered_author:
            return self.unregistered_author.first_name

    @property
    def last_name(self):
        if self.contributor:
            return self.contributor.user.last_name
        if self.unregistered_author:
            return self.unregistered_author.last_name


class Journal(models.Model):
    """
    Journal is a container of Publications with a unique issn and doi_label.
    Publications may be categorized into issues or issues and volumes.
    """
    name = models.CharField(max_length=100, choices=SCIPOST_JOURNALS, unique=True)
    doi_label = models.CharField(max_length=200, unique=True, db_index=True,
                                 validators=[doi_journal_validator])
    issn = models.CharField(max_length=16, default='2542-4653', blank=True)
    active = models.BooleanField(default=True)
    structure = models.CharField(max_length=2,
                                 choices=JOURNAL_STRUCTURE, default=ISSUES_AND_VOLUMES)

    objects = JournalQuerySet.as_manager()

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

    def get_absolute_url(self):
        return reverse('scipost:landing_page', args=(self.doi_label,))

    @property
    def doi_string(self):
        return '10.21468/' + self.doi_label

    @property
    def has_issues(self):
        return self.structure in (ISSUES_AND_VOLUMES, ISSUES_ONLY)

    @property
    def has_volumes(self):
        return self.structure in (ISSUES_AND_VOLUMES)

    @property
    def abbreviation_citation(self):
        return journal_name_abbrev_citation(self.name)

    def get_issues(self):
        if self.structure == ISSUES_AND_VOLUMES:
            return Issue.objects.filter(in_volume__in_journal=self)
        elif self.structure == ISSUES_ONLY:
            return self.issues.all()
        return Issue.objects.none()

    def get_publications(self):
        if self.structure == ISSUES_AND_VOLUMES:
            return Publication.objects.filter(in_issue__in_volume__in_journal=self)
        elif self.structure == ISSUES_ONLY:
            return Publication.objects.filter(in_issue__in_journal=self)
        return self.publications.all()

    def nr_publications(self, tier=None):
        publications = Publication.objects.filter(in_issue__in_volume__in_journal=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        return publications.count()

    def avg_processing_duration(self):
        duration = Publication.objects.filter(
            in_issue__in_volume__in_journal=self).aggregate(
                avg=Avg(F('publication_date') - F('submission_date')))['avg']
        if duration:
            return duration.total_seconds() / 86400
        return 0

    def citation_rate(self, tier=None):
        """
        Returns the citation rate in units of nr citations per article per year.
        """
        publications = Publication.objects.filter(in_issue__in_volume__in_journal=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        ncites = 0
        deltat = 1  # to avoid division by zero
        for pub in publications:
            if pub.citedby and pub.latest_citedby_update:
                ncites += len(pub.citedby)
                deltat += (pub.latest_citedby_update.date() - pub.publication_date).days
        return (ncites * 365.25/deltat)


class Volume(models.Model):
    """
    A Volume may be used as a subgroup of Publications related to a specific Issue object.
    """
    in_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE)
    number = models.PositiveSmallIntegerField()
    start_date = models.DateField(default=timezone.now)
    until_date = models.DateField(default=timezone.now)
    doi_label = models.CharField(max_length=200, unique=True, db_index=True,
                                 validators=[doi_volume_validator])

    class Meta:
        default_related_name = 'volumes'
        ordering = ('-until_date',)
        unique_together = ('number', 'in_journal')

    def __str__(self):
        return str(self.in_journal) + ' Vol. ' + str(self.number)

    def clean(self):
        """
        Check if the Volume is assigned to a valid Journal.
        """
        if not self.in_journal.has_volumes:
            raise ValidationError({
                'in_journal': ValidationError('This journal does not allow for the use of Volumes',
                                              code='invalid'),
            })

    @property
    def doi_string(self):
        return '10.21468/' + self.doi_label

    def nr_publications(self, tier=None):
        publications = Publication.objects.filter(in_issue__in_volume=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        return publications.count()

    def avg_processing_duration(self):
        duration = Publication.objects.filter(
            in_issue__in_volume=self).aggregate(
                avg=Avg(F('publication_date') - F('submission_date')))['avg']
        if duration:
            return duration.total_seconds() / 86400
        return 0

    def citation_rate(self, tier=None):
        """
        Returns the citation rate in units of nr citations per article per year.
        """
        publications = Publication.objects.filter(in_issue__in_volume=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        ncites = 0
        deltat = 1  # to avoid division by zero
        for pub in publications:
            if pub.citedby and pub.latest_citedby_update:
                ncites += len(pub.citedby)
                deltat += (pub.latest_citedby_update.date() - pub.publication_date).days
        return (ncites * 365.25 / deltat)


class Issue(models.Model):
    """
    An Issue may be used as a subgroup of Publications related to a specific Journal object.
    """
    in_journal = models.ForeignKey(
        'journals.Journal', on_delete=models.CASCADE, null=True, blank=True,
        help_text='Assign either an Volume or Journal to the Issue')
    in_volume = models.ForeignKey(
        'journals.Volume', on_delete=models.CASCADE, null=True, blank=True,
        help_text='Assign either an Volume or Journal to the Issue')
    number = models.PositiveSmallIntegerField()
    start_date = models.DateField(default=timezone.now)
    until_date = models.DateField(default=timezone.now)
    status = models.CharField(max_length=20, choices=ISSUE_STATUSES, default=STATUS_PUBLISHED)
    doi_label = models.CharField(max_length=200, unique=True, db_index=True,
                                 validators=[doi_issue_validator])

    # absolute path on filesystem: (JOURNALS_DIR)/journal/vol/issue/
    path = models.CharField(max_length=200)

    objects = IssueQuerySet.as_manager()

    class Meta:
        default_related_name = 'issues'
        ordering = ('-until_date',)
        unique_together = ('number', 'in_volume')

    def __str__(self):
        text = self.issue_number
        if hasattr(self, 'proceedings'):
            return text
        text += ' (%s)' % self.period_as_string
        if self.status == STATUS_DRAFT:
            text += ' (In draft)'
        return text

    def clean(self):
        """
        Check if either a Journal or Volume is assigned to the Issue, else the Issue be floating
        like Musk's red Roadster.
        """
        if not (self.in_journal or self.in_volume):
            raise ValidationError({
                'in_journal': ValidationError('Either assign a Journal or Volume to this Issue',
                                              code='required'),
                'in_volume': ValidationError('Either assign a Journal or Volume to this Issue',
                                             code='required'),
            })
        if self.in_journal and not self.in_journal.has_issues:
            raise ValidationError({
                'in_journal': ValidationError('This journal does not allow for the use of Issues',
                                              code='invalid'),
            })

    def get_absolute_url(self):
        return reverse('scipost:issue_detail', args=[self.doi_label])

    @property
    def doi_string(self):
        return '10.21468/' + self.doi_label

    @property
    def issue_number(self):
        return '%s issue %s' % (self.in_volume, self.number)

    @property
    def short_str(self):
        return 'Vol. %s issue %s' % (self.in_volume.number, self.number)

    @property
    def period_as_string(self):
        if self.start_date.month == self.until_date.month:
            return '%s %s' % (self.until_date.strftime('%B'), self.until_date.strftime('%Y'))
        return '%s - %s' % (self.start_date.strftime('%B'), self.until_date.strftime('%B %Y'))

    def is_current(self):
        return self.start_date <= timezone.now().date() and\
               self.until_date >= timezone.now().date()

    def nr_publications(self, tier=None):
        publications = Publication.objects.filter(in_issue=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        return publications.count()

    def avg_processing_duration(self):
        duration = Publication.objects.filter(
            in_issue=self).aggregate(
                avg=Avg(F('publication_date') - F('submission_date')))['avg']
        if duration:
            return duration.total_seconds() / 86400
        return 0

    def citation_rate(self, tier=None):
        """
        Returns the citation rate in units of nr citations per article per year.
        """
        publications = Publication.objects.filter(in_issue=self)
        if tier:
            publications = publications.filter(
                accepted_submission__eicrecommendations__recommendation=tier)
        ncites = 0
        deltat = 1  # to avoid division by zero
        for pub in publications:
            if pub.citedby and pub.latest_citedby_update:
                ncites += len(pub.citedby)
                deltat += (pub.latest_citedby_update.date() - pub.publication_date).days
        return (ncites * 365.25/deltat)


class Publication(models.Model):
    """
    A Publication is an object directly related to an accepted Submission. It contains metadata,
    the actual publication file, author data, etc. etc.

    It may be directly related to a Journal or to an Issue.
    """
    # Publication data
    accepted_submission = models.OneToOneField('submissions.Submission', on_delete=models.CASCADE,
                                               related_name='publication')
    in_issue = models.ForeignKey(
        'journals.Issue', on_delete=models.CASCADE, null=True, blank=True,
        help_text='Assign either an Issue or Journal to the Publication')
    in_journal = models.ForeignKey(
        'journals.Journal', on_delete=models.CASCADE, null=True, blank=True,
        help_text='Assign either an Issue or Journal to the Publication')
    paper_nr = models.PositiveSmallIntegerField()
    status = models.CharField(max_length=8,
                              choices=PUBLICATION_STATUSES, default=STATUS_DRAFT)

    # Core fields
    title = models.CharField(max_length=300)
    author_list = models.CharField(max_length=1000, verbose_name="author list")
    abstract = models.TextField()
    pdf_file = models.FileField(upload_to='UPLOADS/PUBLICATIONS/%Y/%m/', max_length=200)
    discipline = models.CharField(max_length=20, choices=SCIPOST_DISCIPLINES, default='physics')
    domain = models.CharField(max_length=3, choices=SCIPOST_JOURNALS_DOMAINS)
    subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS,
                                    verbose_name='Primary subject area', default='Phys:QP')
    secondary_areas = ChoiceArrayField(
        models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS), blank=True, null=True)

    # Authors
    authors_registered = models.ManyToManyField('scipost.Contributor', blank=True,
                                                through='PublicationAuthorsTable',
                                                through_fields=('publication', 'contributor'))
    authors_unregistered = models.ManyToManyField('journals.UnregisteredAuthor', blank=True,
                                                  through='PublicationAuthorsTable',
                                                  through_fields=(
                                                    'publication',
                                                    'unregistered_author'))
    authors_claims = models.ManyToManyField('scipost.Contributor', blank=True,
                                            related_name='claimed_publications')
    authors_false_claims = models.ManyToManyField('scipost.Contributor', blank=True,
                                                  related_name='false_claimed_publications')

    cc_license = models.CharField(max_length=32, choices=CC_LICENSES, default=CCBY4)

    # Funders
    grants = models.ManyToManyField('funders.Grant', blank=True)
    funders_generic = models.ManyToManyField('funders.Funder', blank=True)  # not linked to a grant
    institutions = models.ManyToManyField('affiliations.Institution', blank=True)

    # Metadata
    metadata = JSONField(default={}, blank=True, null=True)
    metadata_xml = models.TextField(blank=True)  # for Crossref deposit
    metadata_DOAJ = JSONField(default={}, blank=True, null=True)
    doi_label = models.CharField(max_length=200, unique=True, db_index=True,
                                 validators=[doi_publication_validator])
    BiBTeX_entry = models.TextField(blank=True)
    doideposit_needs_updating = models.BooleanField(default=False)
    citedby = JSONField(default={}, blank=True, null=True)
    number_of_citations = models.PositiveIntegerField(default=0)

    # Date fields
    submission_date = models.DateField(verbose_name='submission date')
    acceptance_date = models.DateField(verbose_name='acceptance date')
    publication_date = models.DateField(verbose_name='publication date')
    latest_citedby_update = models.DateTimeField(null=True, blank=True)
    latest_metadata_update = models.DateTimeField(blank=True, null=True)
    latest_activity = models.DateTimeField(default=timezone.now)

    objects = PublicationQuerySet.as_manager()

    class Meta:
        default_related_name = 'publications'
        ordering = ('-publication_date', '-paper_nr')

    def __str__(self):
        return '{cite}, {title} by {authors}, {date}'.format(
            cite=self.citation,
            title=self.title[:30],
            authors=self.author_list[:30],
            date=self.publication_date.strftime('%Y-%m-%d'))

    def clean(self):
        """
        Check if either a valid Journal or Issue is assigned to the Publication.
        """
        if not (self.in_journal or self.in_issue):
            raise ValidationError({
                'in_journal': ValidationError(
                    'Either assign a Journal or Issue to this Publication', code='required'),
                'in_issue': ValidationError(
                    'Either assign a Journal or Issue to this Publication', code='required'),
            })
        if self.in_journal and self.in_issue:
            # Assigning both a Journal and an Issue will screw up the database
            raise ValidationError({
                'in_journal': ValidationError(
                    'Either assign only a Journal or Issue to this Publication', code='invalid'),
                'in_issue': ValidationError(
                    'Either assign only a Journal or Issue to this Publication', code='invalid'),
            })
        if self.in_issue and not self.in_issue.in_volume.in_journal.has_issues:
            # Assigning both a Journal and an Issue will screw up the database
            raise ValidationError({
                'in_issue': ValidationError(
                    'This journal does not allow the use of Issues',
                    code='invalid'),
            })
        if self.in_journal and self.in_journal.has_issues:
            # Assigning both a Journal and an Issue will screw up the database
            raise ValidationError({
                'in_journal': ValidationError(
                    'This journal does not allow the use of individual Publications',
                    code='invalid'),
            })

    def get_absolute_url(self):
        return reverse('scipost:publication_detail', args=(self.doi_label,))

    def get_cc_license_URI(self):
        for (key, val) in CC_LICENSES_URI:
            if key == self.cc_license:
                return val
        raise KeyError

    def get_all_funders(self):
        from funders.models import Funder
        return Funder.objects.filter(
            models.Q(grants__publications=self) | models.Q(publications=self)).distinct()

    @property
    def doi_string(self):
        return '10.21468/' + self.doi_label

    @property
    def is_draft(self):
        return self.status == STATUS_DRAFT

    @property
    def is_published(self):
        if self.status != PUBLICATION_PUBLISHED:
            return False

        if self.in_issue:
            return self.in_issue.status == STATUS_PUBLISHED
        elif self.in_journal:
            return self.in_journal.active
        return False

    @property
    def has_xml_metadata(self):
        return self.metadata_xml != ''

    @property
    def has_bibtex_entry(self):
        return self.BiBTeX_entry != ''

    @property
    def has_citation_list(self):
        return 'citation_list' in self.metadata and len(self.metadata['citation_list']) > 0

    @property
    def has_funding_statement(self):
        return 'funding_statement' in self.metadata and self.metadata['funding_statement']

    @property
    def citation(self):
        """
        Return Publication name in the preferred citation format.
        """
        if self.in_issue:
            return '{journal} {volume}, {paper_nr} ({year})'.format(
                journal=self.in_issue.in_volume.in_journal.abbreviation_citation,
                volume=self.in_issue.in_volume.number,
                paper_nr=self.get_paper_nr(),
                year=self.publication_date.strftime('%Y'))
        elif self.in_journal:
            return '{journal} {paper_nr} ({year})'.format(
                journal=self.in_journal.abbreviation_citation,
                paper_nr=self.paper_nr,
                year=self.publication_date.strftime('%Y'))
        return '{paper_nr} ({year})'.format(
            paper_nr=self.paper_nr,
            year=self.publication_date.strftime('%Y'))

    def get_journal(self):
        return self.in_journal or self.in_issue.in_volume.in_journal

    def get_paper_nr(self):
        if self.in_journal:
            return self.paper_nr
        return paper_nr_string(self.paper_nr)

    def citation_rate(self):
        """
        Returns the citation rate in units of nr citations per article per year.
        """
        if self.citedby and self.latest_citedby_update:
            ncites = len(self.citedby)
            deltat = (self.latest_citedby_update.date() - self.publication_date).days
            return (ncites * 365.25/deltat)
        else:
            return 0


class Reference(models.Model):
    """
    A Refence is a reference used in a specific Publication.
    """
    reference_number = models.IntegerField()
    publication = models.ForeignKey('journals.Publication', on_delete=models.CASCADE)

    authors = models.CharField(max_length=1028)
    citation = models.CharField(max_length=1028, blank=True)
    identifier = models.CharField(blank=True, max_length=128)
    link = models.URLField(blank=True)

    class Meta:
        unique_together = ('reference_number', 'publication')
        ordering = ['reference_number']
        default_related_name = 'references'

    def __str__(self):
        return '[{}] {}, {}'.format(self.reference_number, self.authors[:30], self.citation[:30])


class Deposit(models.Model):
    """
    Each time a Crossref deposit is made for a Publication,
    a Deposit object instance is created containing the Publication's
    current version of the metadata_xml field.
    All deposit history is thus contained here.
    """
    publication = models.ForeignKey(Publication, on_delete=models.CASCADE)
    timestamp = models.CharField(max_length=40)
    doi_batch_id = models.CharField(max_length=40)
    metadata_xml = models.TextField(blank=True)
    metadata_xml_file = models.FileField(blank=True, null=True, max_length=512)
    deposition_date = models.DateTimeField(blank=True, null=True)
    response_text = models.TextField(blank=True)
    deposit_successful = models.NullBooleanField(default=None)

    class Meta:
        ordering = ['-timestamp']

    def __str__(self):
        _str = ''
        if self.deposition_date:
            _str += '%s for ' % self.deposition_date.strftime('%Y-%m-%D')
        return _str + self.publication.doi_label


class DOAJDeposit(models.Model):
    """
    For the Directory of Open Access Journals.
    """
    publication = models.ForeignKey(Publication, on_delete=models.CASCADE)
    timestamp = models.CharField(max_length=40)
    metadata_DOAJ = JSONField()
    metadata_DOAJ_file = models.FileField(blank=True, null=True, max_length=512)
    deposition_date = models.DateTimeField(blank=True, null=True)
    response_text = models.TextField(blank=True, null=True)
    deposit_successful = models.NullBooleanField(default=None)

    class Meta:
        verbose_name = 'DOAJ deposit'

    def __str__(self):
        return ('DOAJ deposit for ' + self.publication.doi_label)


class GenericDOIDeposit(models.Model):
    """
    Instances of this class represent Crossref deposits for non-publication
    objects such as Reports, Comments etc.
    """
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    content_object = GenericForeignKey()
    timestamp = models.CharField(max_length=40, default='')
    doi_batch_id = models.CharField(max_length=40, default='')
    metadata_xml = models.TextField(blank=True, null=True)
    deposition_date = models.DateTimeField(blank=True, null=True)
    response = models.TextField(blank=True, null=True)
    deposit_successful = models.NullBooleanField(default=None)

    class Meta:
        ordering = ['-timestamp']

    def __str__(self):
        return 'GenericDOIDeposit for %s %s' % (self.content_type, str(self.content_object))