import collections
import datetime
import decimal
import json
import logging
import os

from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.humanize.templatetags.humanize import ordinal
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import (
    Case,
    F,
    ExpressionWrapper,
    Max,
    OuterRef,
    Q,
    Subquery,
    Sum,
    Value as V,
    When,
)
from django.db.models.functions import Cast, Coalesce
from django.db.models.signals import post_delete
from django.dispatch.dispatcher import receiver
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from wagtail.contrib.settings.models import BaseSetting, register_setting
from wagtail.admin.edit_handlers import (
    FieldPanel,
    StreamFieldPanel,
)
from wagtail.core.fields import StreamField

from opentech.apply.funds.models.mixins import AccessFormData
from opentech.apply.stream_forms.blocks import FormFieldsBlock
from opentech.apply.stream_forms.files import StreamFieldDataEncoder
from opentech.apply.stream_forms.models import BaseStreamForm

from addressfield.fields import ADDRESS_FIELDS_ORDER
from opentech.apply.activity.messaging import MESSAGES, messenger
from opentech.apply.utils.storage import PrivateStorage

logger = logging.getLogger(__name__)


def contract_path(instance, filename):
    return f'projects/{instance.project_id}/contracts/{filename}'


def document_path(instance, filename):
    return f'projects/{instance.project_id}/supporting_documents/{filename}'


def invoice_path(instance, filename):
    return f'projects/{instance.project_id}/payment_invoices/{filename}'


def receipt_path(instance, filename):
    return f'projects/{instance.payment_request.project_id}/payment_receipts/{filename}'


def report_path(instance, filename):
    return f'reports/{instance.report.report_id}/version/{instance.report_id}/{filename}'


class Approval(models.Model):
    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals")
    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals")

    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = ['project', 'by']

    def __str__(self):
        return f'Approval of "{self.project.title}" by {self.by}'


class ContractQuerySet(models.QuerySet):
    def approved(self):
        return self.filter(is_signed=True, approver__isnull=False)


class Contract(models.Model):
    approver = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='contracts')
    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="contracts")

    file = models.FileField(upload_to=contract_path, storage=PrivateStorage())

    is_signed = models.BooleanField("Signed?", default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    approved_at = models.DateTimeField(null=True)

    objects = ContractQuerySet.as_manager()

    @property
    def state(self):
        return 'Signed' if self.is_signed else 'Unsigned'

    def __str__(self):
        return f'Contract for {self.project} ({self.state})'

    def get_absolute_url(self):
        return reverse('apply:projects:contract', args=[self.project.pk, self.pk])


class PacketFile(models.Model):
    category = models.ForeignKey("DocumentCategory", null=True, on_delete=models.CASCADE, related_name="packet_files")
    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="packet_files")

    title = models.TextField()
    document = models.FileField(upload_to=document_path, storage=PrivateStorage())

    def __str__(self):
        return f'Project file: {self.title}'

    def get_remove_form(self):
        """
        Get an instantiated RemoveDocumentForm with this class as `instance`.

        This allows us to build instances of the RemoveDocumentForm for each
        instance of PacketFile in the supporting documents template.  The
        standard Delegated View flow makes it difficult to create these forms
        in the view or template.
       """
        from .forms import RemoveDocumentForm
        return RemoveDocumentForm(instance=self)


@receiver(post_delete, sender=PacketFile)
def delete_packetfile_file(sender, instance, **kwargs):
    # Remove the file and don't save the base model
    instance.document.delete(False)


class PaymentApproval(models.Model):
    request = models.ForeignKey('PaymentRequest', on_delete=models.CASCADE, related_name="approvals")
    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_approvals")

    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f'Approval for {self.request} by {self.by}'


class PaymentReceipt(models.Model):
    payment_request = models.ForeignKey("PaymentRequest", on_delete=models.CASCADE, related_name="receipts")

    file = models.FileField(upload_to=receipt_path, storage=PrivateStorage())

    def __str__(self):
        return os.path.basename(self.file.name)


SUBMITTED = 'submitted'
CHANGES_REQUESTED = 'changes_requested'
UNDER_REVIEW = 'under_review'
PAID = 'paid'
DECLINED = 'declined'
REQUEST_STATUS_CHOICES = [
    (SUBMITTED, 'Submitted'),
    (CHANGES_REQUESTED, 'Changes Requested'),
    (UNDER_REVIEW, 'Under Review'),
    (PAID, 'Paid'),
    (DECLINED, 'Declined'),
]


class PaymentRequestQueryset(models.QuerySet):
    def in_progress(self):
        return self.exclude(status__in=[DECLINED, PAID])

    def rejected(self):
        return self.filter(status=DECLINED)

    def not_rejected(self):
        return self.exclude(status=DECLINED)

    def total_value(self, field):
        return self.aggregate(total=Coalesce(Sum(field), V(0)))['total']

    def paid_value(self):
        return self.filter(status=PAID).total_value('paid_value')

    def unpaid_value(self):
        return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value('requested_value')


class PaymentRequest(models.Model):
    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="payment_requests")
    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_requests")

    requested_value = models.DecimalField(
        default=0,
        max_digits=10,
        decimal_places=2,
        validators=[MinValueValidator(decimal.Decimal('0.01'))],
    )
    paid_value = models.DecimalField(
        max_digits=10,
        decimal_places=2,
        validators=[MinValueValidator(decimal.Decimal('0.01'))],
        null=True
    )

    invoice = models.FileField(upload_to=invoice_path, storage=PrivateStorage())
    requested_at = models.DateTimeField(auto_now_add=True)
    date_from = models.DateTimeField()
    date_to = models.DateTimeField()
    comment = models.TextField(blank=True)
    status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED)

    objects = PaymentRequestQueryset.as_manager()

    def __str__(self):
        return f'Payment requested for {self.project}'

    @property
    def has_changes_requested(self):
        return self.status == CHANGES_REQUESTED

    @property
    def status_display(self):
        return self.get_status_display()

    def can_user_delete(self, user):
        if user.is_applicant:
            if self.status in (SUBMITTED, CHANGES_REQUESTED):
                return True

        if user.is_apply_staff:
            if self.status in {SUBMITTED}:
                return True

        return False

    def can_user_edit(self, user):
        if user.is_applicant:
            if self.status in {SUBMITTED, CHANGES_REQUESTED}:
                return True

        if user.is_apply_staff:
            if self.status in {SUBMITTED}:
                return True

        return False

    def can_user_change_status(self, user):
        if not user.is_apply_staff:
            return False  # Users can't change status

        if self.status in {PAID, DECLINED}:
            return False

        return True

    @property
    def value(self):
        return self.paid_value or self.requested_value

    def get_absolute_url(self):
        return reverse('apply:projects:payments:detail', args=[self.pk])


COMMITTED = 'committed'
CONTRACTING = 'contracting'
IN_PROGRESS = 'in_progress'
CLOSING = 'closing'
COMPLETE = 'complete'
PROJECT_STATUS_CHOICES = [
    (COMMITTED, 'Committed'),
    (CONTRACTING, 'Contracting'),
    (IN_PROGRESS, 'In Progress'),
    (CLOSING, 'Closing'),
    (COMPLETE, 'Complete'),
]


class ProjectQuerySet(models.QuerySet):
    def active(self):
        "Projects that are not finished"
        return self.exclude(status=COMPLETE)

    def in_progress(self):
        "Projects that users need to interact with, submitting reports or payment request"
        return self.filter(
            status__in=(IN_PROGRESS, CLOSING,)
        )

    def complete(self):
        return self.filter(status=COMPLETE)

    def in_approval(self):
        return self.filter(
            is_locked=True,
            status=COMMITTED,
            approvals__isnull=True,
        )

    def by_end_date(self, desc=False):
        order = getattr(F('proposed_end'), 'desc' if desc else 'asc')(nulls_last=True)

        return self.order_by(order)

    def with_amount_paid(self):
        return self.annotate(
            amount_paid=Coalesce(Sum('payment_requests__paid_value'), V(0)),
        )

    def with_last_payment(self):
        return self.annotate(
            last_payment_request=Max('payment_requests__requested_at'),
        )

    def with_start_date(self):
        return self.annotate(
            start=Cast(
                Subquery(
                    Contract.objects.filter(
                        project=OuterRef('pk'),
                    ).approved().order_by(
                        'approved_at'
                    ).values('approved_at')[:1]
                ),
                models.DateField(),
            )
        )

    def for_table(self):
        return self.with_amount_paid().with_last_payment().select_related(
            'report_config',
            'submission__page',
            'lead',
        )


class Project(BaseStreamForm, AccessFormData, models.Model):
    lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects')
    submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects')

    title = models.TextField()

    contact_legal_name = models.TextField(_('Person or Organisation name'), default='')
    contact_email = models.TextField(_('Email'), default='')
    contact_address = models.TextField(_('Address'), default='')
    contact_phone = models.TextField(_('Phone'), default='')
    value = models.DecimalField(
        default=0,
        max_digits=10,
        decimal_places=2,
        validators=[MinValueValidator(decimal.Decimal('0.01'))],
    )
    proposed_start = models.DateTimeField(_('Proposed Start Date'), null=True)
    proposed_end = models.DateTimeField(_('Proposed End Date'), null=True)

    status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED)

    form_data = JSONField(encoder=StreamFieldDataEncoder, default=dict)
    form_fields = StreamField(FormFieldsBlock(), null=True)

    # tracks read/write state of the Project
    is_locked = models.BooleanField(default=False)

    # tracks updates to the Projects fields via the Project Application Form.
    user_has_updated_details = models.BooleanField(default=False)

    activities = GenericRelation(
        'activity.Activity',
        content_type_field='source_content_type',
        object_id_field='source_object_id',
        related_query_name='project',
    )
    created_at = models.DateTimeField(auto_now_add=True)

    sent_to_compliance_at = models.DateTimeField(null=True)

    objects = ProjectQuerySet.as_manager()

    def __str__(self):
        return self.title

    @property
    def status_display(self):
        return self.get_status_display()

    def get_address_display(self):
        try:
            address = json.loads(self.contact_address)
        except json.JSONDecodeError:
            return ''
        else:
            return ', '.join(
                address.get(field)
                for field in ADDRESS_FIELDS_ORDER
                if address.get(field)
            )

    @classmethod
    def create_from_submission(cls, submission):
        """
        Create a Project from the given submission.

        Returns a new Project or the given ApplicationSubmissions existing
        Project.
        """
        if not settings.PROJECTS_ENABLED:
            logging.error(f'Tried to create a Project for Submission ID={submission.id} while projects are disabled')
            return None

        # OneToOne relations on the targetted model cannot be accessed without
        # an exception when the relation doesn't exist (is None).  Since we
        # want to fail fast here, we can use hasattr instead.
        if hasattr(submission, 'project'):
            return submission.project

        return Project.objects.create(
            submission=submission,
            title=submission.title,
            user=submission.user,
            contact_email=submission.user.email,
            contact_legal_name=submission.user.full_name,
            contact_address=submission.form_data.get('address', ''),
            value=submission.form_data.get('value', 0),
        )

    @property
    def start_date(self):
        # Assume project starts when OTF are happy with the first signed contract
        first_approved_contract = self.contracts.approved().order_by('approved_at').first()
        if not first_approved_contract:
            return None

        return first_approved_contract.approved_at.date()

    @property
    def end_date(self):
        # Aiming for the proposed end date as the last day of the project
        # If still ongoing assume today is the end
        return max(
            self.proposed_end.date(),
            timezone.now().date(),
        )

    def paid_value(self):
        return self.payment_requests.paid_value()

    def unpaid_value(self):
        return self.payment_requests.unpaid_value()

    def clean(self):
        if self.proposed_start is None:
            return

        if self.proposed_end is None:
            return

        if self.proposed_start > self.proposed_end:
            raise ValidationError(_('Proposed End Date must be after Proposed Start Date'))

    def save(self, *args, **kwargs):
        creating = not self.pk

        if creating:
            files = self.extract_files()
        else:
            self.process_file_data(self.form_data)

        super().save(*args, **kwargs)

        if creating:
            self.process_file_data(files)

    def editable_by(self, user):
        if self.editable:
            return True

        # Approver can edit it when they are approving
        return user.is_approver and self.can_make_approval

    @property
    def editable(self):
        if self.status not in (CONTRACTING, COMMITTED):
            return True

        # Someone has approved the project - consider it locked while with contracting
        if self.approvals.exists():
            return False

        # Someone must lead the project to make changes
        return self.lead and not self.is_locked

    def get_absolute_url(self):
        if settings.PROJECTS_ENABLED:
            return reverse('apply:projects:detail', args=[self.id])
        return '#'

    @property
    def can_make_approval(self):
        return self.is_locked and self.status == COMMITTED

    def can_request_funding(self):
        """
        Should we show this Project's funding block?
        """
        return self.status in (CLOSING, IN_PROGRESS)

    @property
    def can_send_for_approval(self):
        """
        Wrapper to expose the pending approval state

        We don't want to expose a "Sent for Approval" state to the end User so
        we infer it from the current status being "Comitted" and the Project
        being locked.
        """
        correct_state = self.status == COMMITTED and not self.is_locked
        return correct_state and self.user_has_updated_details

    @property
    def requires_approval(self):
        return not self.approvals.exists()

    def get_missing_document_categories(self):
        """
        Get the number of documents required to meet each DocumentCategorys minimum
        """
        # Count the number of documents in each category currently
        existing_categories = DocumentCategory.objects.filter(packet_files__project=self)
        counter = collections.Counter(existing_categories)

        # Find the difference between the current count and recommended count
        for category in DocumentCategory.objects.all():
            current_count = counter[category]
            difference = category.recommended_minimum - current_count
            if difference > 0:
                yield {
                    'category': category,
                    'difference': difference,
                }

    @property
    def is_in_progress(self):
        return self.status == IN_PROGRESS

    def send_to_compliance(self, request):
        """Notify Compliance about this Project."""

        messenger(
            MESSAGES.SENT_TO_COMPLIANCE,
            request=request,
            user=request.user,
            source=self,
        )

        self.sent_to_compliance_at = timezone.now()
        self.save(update_fields=['sent_to_compliance_at'])


@register_setting
class ProjectSettings(BaseSetting):
    compliance_email = models.TextField("Compliance Email")


class DocumentCategory(models.Model):
    name = models.CharField(max_length=254)
    recommended_minimum = models.PositiveIntegerField()

    def __str__(self):
        return self.name

    class Meta:
        ordering = ('name',)
        verbose_name_plural = 'Document Categories'


class ProjectApprovalForm(BaseStreamForm, models.Model):
    name = models.CharField(max_length=255)
    form_fields = StreamField(FormFieldsBlock())

    panels = [
        FieldPanel('name'),
        StreamFieldPanel('form_fields'),
    ]

    def __str__(self):
        return self.name


class ReportConfig(models.Model):
    """Persists configuration about the reporting schedule etc"""

    WEEK = "week"
    MONTH = "month"
    FREQUENCY_CHOICES = [
        (WEEK, "Weeks"),
        (MONTH, "Months"),
    ]

    project = models.OneToOneField("Project", on_delete=models.CASCADE, related_name="report_config")
    schedule_start = models.DateField(null=True)
    occurrence = models.PositiveSmallIntegerField(default=1)
    frequency = models.CharField(choices=FREQUENCY_CHOICES, default=MONTH, max_length=5)

    def get_frequency_display(self):
        next_report = self.current_due_report()

        if self.frequency == self.MONTH:
            if self.schedule_start and self.schedule_start.day == 31:
                day_of_month = 'last day'
            else:
                day_of_month = ordinal(next_report.end_date.day)
            if self.occurrence == 1:
                return f"Monthly on the { day_of_month } of the month"
            return f"Every { self.occurrence } months on the { day_of_month } of the month"

        weekday = next_report.end_date.strftime('%A')

        if self.occurrence == 1:
            return f"Every week on { weekday }"
        return f"Every {self.occurrence} weeks on { weekday }"

    def is_up_to_date(self):
        return len(self.project.reports.to_do()) == 0

    def outstanding_reports(self):
        return len(self.project.reports.to_do())

    def has_very_late_reports(self):
        return self.project.reports.any_very_late()

    def past_due_reports(self):
        return self.project.reports.to_do()

    def last_report(self):
        today = timezone.now().date()
        return self.project.reports.filter(
            Q(end_date__lt=today) | Q(current__isnull=False)
        ).first()

    def current_due_report(self):
        # Project not started - no reporting required
        if not self.project.start_date:
            return None

        today = timezone.now().date()

        last_report = self.last_report()

        schedule_date = self.schedule_start or self.project.start_date

        if last_report:
            if last_report.end_date < schedule_date:
                # reporting schedule changed schedule_start is now the next report date
                next_due_date = schedule_date
            else:
                # we've had a report since the schedule date so base next deadline from the report
                next_due_date = self.next_date(last_report.end_date)
        else:
            # first report required
            if self.schedule_start and self.schedule_start >= today:
                # Schedule changed since project inception
                next_due_date = self.schedule_start
            else:
                # schedule_start is the first day the project so the "last" period
                # ended one day before that. If date is in past we required report now
                next_due_date = max(
                    self.next_date(schedule_date - relativedelta(days=1)),
                    today,
                )

        report, _ = self.project.reports.update_or_create(
            project=self.project,
            end_date__gte=today,
            current__isnull=True,
            defaults={'end_date': next_due_date}
        )
        return report

    def next_date(self, last_date):
        delta_frequency = self.frequency + 's'
        delta = relativedelta(**{delta_frequency: self.occurrence})
        next_date = last_date + delta
        return next_date


class ReportQueryset(models.QuerySet):
    def done(self):
        return self.filter(
            Q(current__isnull=False) | Q(skipped=True),
        )

    def to_do(self):
        today = timezone.now().date()
        return self.filter(
            current__isnull=True,
            skipped=False,
            end_date__lt=today,
        ).order_by('end_date')

    def any_very_late(self):
        two_weeks_ago = timezone.now().date() - relativedelta(weeks=2)
        return self.to_do().filter(end_date__lte=two_weeks_ago)

    def submitted(self):
        return self.filter(current__isnull=False)

    def for_table(self):
        return self.annotate(
            last_end_date=Subquery(
                Report.objects.filter(
                    project=OuterRef('project_id'),
                    end_date__lt=OuterRef('end_date')
                ).values('end_date')[:1]
            ),
            project_start_date=Subquery(
                Project.objects.filter(
                    pk=OuterRef('project_id'),
                ).with_start_date().values('start')[:1]
            ),
            start=Case(
                When(
                    last_end_date__isnull=False,
                    # Expression Wrapper doesn't cast the calculated object
                    # Use cast to get an actual date object
                    then=Cast(
                        ExpressionWrapper(
                            F('last_end_date') + datetime.timedelta(days=1),
                            output_field=models.DateTimeField(),
                        ),
                        models.DateField(),
                    ),
                ),
                default=F('project_start_date'),
                output_field=models.DateField(),
            )
        )


class Report(models.Model):
    skipped = models.BooleanField(default=False)
    end_date = models.DateField()
    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports")
    submitted = models.DateTimeField(null=True)
    notified = models.DateTimeField(null=True)
    current = models.OneToOneField(
        "ReportVersion",
        on_delete=models.CASCADE,
        related_name='live_for_report',
        null=True,
    )
    draft = models.OneToOneField(
        "ReportVersion",
        on_delete=models.CASCADE,
        related_name='draft_for_report',
        null=True,
    )

    objects = ReportQueryset.as_manager()

    class Meta:
        ordering = ('-end_date',)

    def get_absolute_url(self):
        return reverse('apply:projects:reports:detail', kwargs={'pk': self.pk})

    @property
    def past_due(self):
        return timezone.now().date() > self.end_date

    @property
    def is_very_late(self):
        two_weeks_ago = timezone.now().date() - relativedelta(weeks=2)
        two_weeks_late = self.end_date < two_weeks_ago
        not_submitted = not self.current
        return not_submitted and two_weeks_late

    @property
    def can_submit(self):
        return self.start_date <= timezone.now().date()

    @property
    def submitted_date(self):
        if self.submitted:
            return self.submitted.date()

    @cached_property
    def start_date(self):
        last_report = self.project.reports.filter(end_date__lt=self.end_date).first()
        if last_report:
            return last_report.end_date + relativedelta(days=1)

        return self.project.start_date


class ReportVersion(models.Model):
    report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions")
    submitted = models.DateTimeField()
    public_content = models.TextField()
    private_content = models.TextField()
    draft = models.BooleanField()
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        related_name="reports",
        null=True,
    )


class ReportPrivateFiles(models.Model):
    report = models.ForeignKey("ReportVersion", on_delete=models.CASCADE, related_name="files")
    document = models.FileField(upload_to=report_path, storage=PrivateStorage())

    @property
    def filename(self):
        return os.path.basename(self.document.name)

    def __str__(self):
        return self.filename

    def get_absolute_url(self):
        return reverse('apply:projects:reports:document', kwargs={'pk': self.report.report_id, 'file_pk': self.pk})