from collections import namedtuple

from django.contrib.admin.models import ADDITION, CHANGE, DELETION
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import pre_save

from main.models import Arch, Package, Repo
from main.utils import database_vendor, set_created_field
from packages.alpm import AlpmAPI


class PackageRelation(models.Model):
    '''
    Represents maintainership (or interest) in a package by a given developer.
    It is not a true foreign key to packages as we want to key off
    pkgbase/pkgname instead, as well as preserve this information across
    package deletes, adds, and in all repositories.
    '''
    MAINTAINER = 1
    WATCHER = 2
    TYPE_CHOICES = (
        (MAINTAINER, 'Maintainer'),
        (WATCHER, 'Watcher'),
    )
    pkgbase = models.CharField(max_length=255)
    user = models.ForeignKey(User, related_name="package_relations", on_delete=models.CASCADE)
    type = models.PositiveIntegerField(choices=TYPE_CHOICES, default=MAINTAINER)
    created = models.DateTimeField(editable=False)

    class Meta:
        unique_together = (('pkgbase', 'user', 'type'),)

    def get_associated_packages(self):
        return Package.objects.normal().filter(pkgbase=self.pkgbase)

    def repositories(self):
        packages = self.get_associated_packages()
        return sorted({p.repo for p in packages})

    def last_update(self):
        return Update.objects.filter(pkgbase=self.pkgbase).latest()

    def __str__(self):
        return f'{self.pkgbase}: {self.user} ({self.get_type_display()})'


class SignoffSpecificationManager(models.Manager):
    def get_from_package(self, pkg):
        '''Utility method to pull all relevant name-version fields from a
        package and get a matching signoff specification.'''
        return self.get(
            pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel,
            epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo)

    def get_or_default_from_package(self, pkg):
        '''utility method to pull all relevant name-version fields from a
        package and get a matching signoff specification, or return the default
        base case.'''
        try:
            return self.get(
                pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel,
                epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo)
        except SignoffSpecification.DoesNotExist:
            return fake_signoff_spec(pkg.arch)


class SignoffSpecification(models.Model):
    '''
    A specification for the signoff policy for this particular revision of a
    package. The default is requiring two signoffs for a given package. These
    are created only if necessary; e.g., if one wanted to override the
    required=2 attribute, otherwise a sane default object is used.
    '''
    pkgbase = models.CharField(max_length=255, db_index=True)
    pkgver = models.CharField(max_length=255)
    pkgrel = models.CharField(max_length=255)
    epoch = models.PositiveIntegerField(default=0)
    arch = models.ForeignKey(Arch, on_delete=models.CASCADE)
    repo = models.ForeignKey(Repo, on_delete=models.CASCADE)
    user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
    created = models.DateTimeField(editable=False)
    required = models.PositiveIntegerField(default=2,
                                           help_text="How many signoffs are required for this package?")
    enabled = models.BooleanField(default=True,
                                  help_text="Is this package eligible for signoffs?")
    known_bad = models.BooleanField(default=False,
                                    help_text="Is this package known to be broken in some way?")
    comments = models.TextField(null=True, blank=True)

    objects = SignoffSpecificationManager()

    @property
    def full_version(self):
        if self.epoch > 0:
            return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel)
        return f'{self.pkgver}-{self.pkgrel}'

    def __str__(self):
        return f'{self.pkgbase}-{self.full_version}'


# Fake signoff specs for when we don't have persisted ones in the database.
# These have all necessary attributes of the real thing but are lighter weight
# and have no chance of being persisted.
FakeSignoffSpecification = namedtuple(
    'FakeSignoffSpecification',
    ('required', 'enabled', 'known_bad', 'comments'))


def fake_signoff_spec(arch):
    return FakeSignoffSpecification(required=arch.required_signoffs, enabled=True, known_bad=False, comments='')


class SignoffManager(models.Manager):
    def get_from_package(self, pkg, user, revoked=False):
        '''Utility method to pull all relevant name-version fields from a
        package and get a matching signoff.'''
        not_revoked = not revoked
        return self.get(
            pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel,
            epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo,
            revoked__isnull=not_revoked, user=user)

    def get_or_create_from_package(self, pkg, user):
        '''Utility method to pull all relevant name-version fields from a
        package and get or create a matching signoff.'''
        return self.get_or_create(
            pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel,
            epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo,
            revoked=None, user=user)

    def for_package(self, pkg):
        return self.select_related('user').filter(
            pkgbase=pkg.pkgbase, pkgver=pkg.pkgver, pkgrel=pkg.pkgrel,
            epoch=pkg.epoch, arch=pkg.arch, repo=pkg.repo)


class Signoff(models.Model):
    '''
    A signoff for a package (by pkgbase) at a given point in time. These are
    not keyed directly to a Package object so they don't ever get deleted when
    Packages come and go from testing repositories.
    '''
    pkgbase = models.CharField(max_length=255, db_index=True)
    pkgver = models.CharField(max_length=255)
    pkgrel = models.CharField(max_length=255)
    epoch = models.PositiveIntegerField(default=0)
    arch = models.ForeignKey(Arch, on_delete=models.CASCADE)
    repo = models.ForeignKey(Repo, on_delete=models.CASCADE)
    user = models.ForeignKey(User, related_name="package_signoffs", on_delete=models.CASCADE)
    created = models.DateTimeField(editable=False, db_index=True)
    revoked = models.DateTimeField(null=True)
    comments = models.TextField(null=True, blank=True)

    objects = SignoffManager()

    @property
    def packages(self):
        return Package.objects.normal().filter(pkgbase=self.pkgbase,
                                               pkgver=self.pkgver, pkgrel=self.pkgrel, epoch=self.epoch,
                                               arch=self.arch, repo=self.repo)

    @property
    def full_version(self):
        if self.epoch > 0:
            return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel)
        return f'{self.pkgver}-{self.pkgrel}'

    def __str__(self):
        revoked = ''
        if self.revoked:
            revoked = ' (revoked)'
        return f'{self.pkgbase}-{self.full_version}: {self.user}{revoked}'


class FlagRequest(models.Model):
    '''
    A notification the package is out-of-date submitted through the web site.
    '''
    user = models.ForeignKey(User, blank=True, null=True, on_delete=models.CASCADE)
    user_email = models.EmailField('email address')
    created = models.DateTimeField(editable=False, db_index=True)
    ip_address = models.GenericIPAddressField('IP address', unpack_ipv4=True)
    pkgbase = models.CharField(max_length=255, db_index=True)
    pkgver = models.CharField(max_length=255)
    pkgrel = models.CharField(max_length=255)
    epoch = models.PositiveIntegerField(default=0)
    repo = models.ForeignKey(Repo, on_delete=models.CASCADE)
    num_packages = models.PositiveIntegerField('number of packages', default=1)
    message = models.TextField('message to developer', blank=True)
    is_spam = models.BooleanField(default=False,
                                  help_text="Is this comment from a real person?")
    is_legitimate = models.BooleanField(default=True,
                                        help_text="Is this actually an out-of-date flag request?")

    class Meta:
        get_latest_by = 'created'

    def who(self):
        if self.user:
            return self.user.get_full_name()
        return self.user_email

    @property
    def full_version(self):
        # Difference here from other implementations at the moment: we need to
        # handle the case of pkgver and pkgrel being null as this table didn't
        # originally have version columns.
        if self.pkgver == '' and self.pkgrel == '':
            return ''
        if self.epoch > 0:
            return '%d:%s-%s' % (self.epoch, self.pkgver, self.pkgrel)
        return f'{self.pkgver}-{self.pkgrel}'

    def get_associated_packages(self):
        return Package.objects.normal().filter(
            pkgbase=self.pkgbase,
            repo__testing=self.repo.testing,
            repo__staging=self.repo.staging).order_by(
            'pkgname', 'repo__name', 'arch__name')

    def __str__(self):
        return f'{self.pkgbase} from {self.who()} on {self.created}'


class FlagDenylist(models.Model):
    keyword = models.CharField(max_length=255)

    def __str__(self):
        return f"{self.keyword}"


class UpdateManager(models.Manager):
    def log_update(self, old_pkg, new_pkg):
        '''Utility method to help log an update. This will determine the type
        based on how many packages are passed in, and will pull the relevant
        necessary fields off the given packages.
        Note that in some cases, this is a no-op if we know this database type
        supports triggers to add these rows instead.'''
        if database_vendor(Package, 'write') in ('sqlite', 'postgresql'):
            # we log updates using database triggers for these backends
            return
        update = Update()
        if new_pkg:
            update.action_flag = ADDITION
            update.package = new_pkg
            update.arch = new_pkg.arch
            update.repo = new_pkg.repo
            update.pkgname = new_pkg.pkgname
            update.pkgbase = new_pkg.pkgbase
            update.new_pkgver = new_pkg.pkgver
            update.new_pkgrel = new_pkg.pkgrel
            update.new_epoch = new_pkg.epoch
        if old_pkg:
            if new_pkg:
                update.action_flag = CHANGE
                # ensure we should even be logging this
                if old_pkg.pkgver == new_pkg.pkgver and old_pkg.pkgrel == new_pkg.pkgrel \
                   and old_pkg.epoch == new_pkg.epoch:
                    # all relevant fields were the same; e.g. a force update
                    return
            else:
                update.action_flag = DELETION
                update.arch = old_pkg.arch
                update.repo = old_pkg.repo
                update.pkgname = old_pkg.pkgname
                update.pkgbase = old_pkg.pkgbase

            update.old_pkgver = old_pkg.pkgver
            update.old_pkgrel = old_pkg.pkgrel
            update.old_epoch = old_pkg.epoch

        update.save(force_insert=True)
        return update


class Update(models.Model):
    UPDATE_ACTION_CHOICES = (
        (ADDITION, 'Addition'),
        (CHANGE, 'Change'),
        (DELETION, 'Deletion'),
    )

    package = models.ForeignKey(Package, related_name="updates",
                                null=True, on_delete=models.SET_NULL)
    repo = models.ForeignKey(Repo, related_name="updates", on_delete=models.CASCADE)
    arch = models.ForeignKey(Arch, related_name="updates", on_delete=models.CASCADE)
    pkgname = models.CharField(max_length=255, db_index=True)
    pkgbase = models.CharField(max_length=255)
    action_flag = models.PositiveSmallIntegerField('action flag', choices=UPDATE_ACTION_CHOICES)
    created = models.DateTimeField(editable=False, db_index=True)

    old_pkgver = models.CharField(max_length=255, null=True)
    old_pkgrel = models.CharField(max_length=255, null=True)
    old_epoch = models.PositiveIntegerField(null=True)

    new_pkgver = models.CharField(max_length=255, null=True)
    new_pkgrel = models.CharField(max_length=255, null=True)
    new_epoch = models.PositiveIntegerField(null=True)

    objects = UpdateManager()

    class Meta:
        get_latest_by = 'created'

    def is_addition(self):
        return self.action_flag == ADDITION

    def is_change(self):
        return self.action_flag == CHANGE

    def is_deletion(self):
        return self.action_flag == DELETION

    @property
    def old_version(self):
        if self.action_flag == ADDITION:
            return None
        if self.old_epoch > 0:
            return '%d:%s-%s' % (self.old_epoch, self.old_pkgver, self.old_pkgrel)
        return f'{self.old_pkgver}-{self.old_pkgrel}'

    @property
    def new_version(self):
        if self.action_flag == DELETION:
            return None
        if self.new_epoch > 0:
            return '%d:%s-%s' % (self.new_epoch, self.new_pkgver, self.new_pkgrel)
        return f'{self.new_pkgver}-{self.new_pkgrel}'

    def elsewhere(self):
        return Package.objects.normal().filter(pkgname=self.pkgname, arch=self.arch)

    def replacements(self):
        pkgs = Package.objects.normal().filter(replaces__name=self.pkgname)
        if not self.arch.agnostic:
            # make sure we match architectures if possible
            arches = set(Arch.objects.filter(agnostic=True))
            arches.add(self.arch)
            pkgs = pkgs.filter(arch__in=arches)
        return pkgs

    def get_absolute_url(self):
        return f'/packages/{self.repo.name.lower()}/{self.arch.name}/{self.pkgname}/'

    def __str__(self):
        return f'{self.get_action_flag_display()} of {self.pkgname} on {self.created}'


class PackageGroup(models.Model):
    '''
    Represents a group a package is in. There is no actual group entity,
    only names that link to given packages.
    '''
    pkg = models.ForeignKey(Package, related_name='groups', on_delete=models.CASCADE)
    name = models.CharField(max_length=255, db_index=True)

    def __str__(self):
        return f"{self.name}: {self.pkg}"

    class Meta:
        ordering = ('name',)


class License(models.Model):
    pkg = models.ForeignKey(Package, related_name='licenses', on_delete=models.CASCADE)
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name

    class Meta:
        ordering = ('name',)


class RelatedToBase(models.Model):
    '''A base class for conflicts/provides/replaces/etc.'''
    name = models.CharField(max_length=255, db_index=True)
    version = models.CharField(max_length=255, default='')

    def get_best_satisfier(self):
        '''Find a satisfier for this related package that best matches the
        given criteria. It will not search provisions, but will find packages
        named and matching repo characteristics if possible.'''
        pkgs = Package.objects.normal().filter(pkgname=self.name)
        # TODO: this may in fact be faster- select only the fields we know will
        # actually get used, saving us some bandwidth and hopefully query
        # construction time. However, reality hasn't quite proved it out yet.
        # pkgs = Package.objects.select_related('repo', 'arch').only(
        #        'id', 'pkgname', 'epoch', 'pkgver', 'pkgrel',
        #        'repo__id', 'repo__name', 'repo__testing', 'repo__staging',
        #        'arch__id', 'arch__name').filter(pkgname=self.name)
        if not self.pkg.arch.agnostic:
            # make sure we match architectures if possible
            arches = self.pkg.applicable_arches()
            pkgs = pkgs.filter(arch__in=arches)
        # if we have a comparison operation, make sure the packages we grab
        # actually satisfy the requirements
        if self.comparison and self.version:
            alpm = AlpmAPI()
            pkgs = [pkg for pkg in pkgs if not alpm.available or alpm.compare_versions(pkg.full_version,
                                                                                       self.comparison, self.version)]
        if len(pkgs) == 0:
            # couldn't find a package in the DB
            # it should be a virtual depend (or a removed package)
            return None
        if len(pkgs) == 1:
            return pkgs[0]
        # more than one package, see if we can't shrink it down
        # grab the first though in case we fail
        pkg = pkgs[0]
        # prevents yet more DB queries, these lists should be short;
        # after each grab the best available in case we remove all entries
        pkgs = [p for p in pkgs if p.repo.staging == self.pkg.repo.staging]
        if len(pkgs) > 0:
            pkg = pkgs[0]

        pkgs = [p for p in pkgs if p.repo.testing == self.pkg.repo.testing]
        if len(pkgs) > 0:
            pkg = pkgs[0]

        return pkg

    def get_providers(self):
        '''Return providers of this related package. Does *not* include exact
        matches as it checks the Provision names only, use get_best_satisfier()
        instead for exact matches.'''
        pkgs = Package.objects.normal().filter(provides__name=self.name).order_by().distinct()
        if not self.pkg.arch.agnostic:
            # make sure we match architectures if possible
            arches = self.pkg.applicable_arches()
            pkgs = pkgs.filter(arch__in=arches)

        # If we have a comparison operation, make sure the packages we grab
        # actually satisfy the requirements.
        alpm = AlpmAPI()
        if alpm.available and self.comparison and self.version:
            pkgs = pkgs.prefetch_related('provides')
            new_pkgs = []
            for package in pkgs:
                for provide in package.provides.all():
                    if provide.name != self.name:
                        continue
                    if alpm.compare_versions(provide.version, self.comparison, self.version):
                        new_pkgs.append(package)
            pkgs = new_pkgs

        # Sort providers by preference. We sort those in same staging/testing
        # combination first, followed by others. We sort by a (staging,
        # testing) match tuple that will be (True, True) in the best case.
        key_func = lambda x: (x.repo.staging == self.pkg.repo.staging, x.repo.testing == self.pkg.repo.testing)
        return sorted(pkgs, key=key_func, reverse=True)

    def __str__(self):
        if self.version:
            return f'{self.name}{self.comparison}{self.version}'
        return self.name

    class Meta:
        abstract = True
        ordering = ('name',)


class Depend(RelatedToBase):
    DEPTYPE_CHOICES = (
        ('D', 'Depend'),
        ('O', 'Optional Depend'),
        ('M', 'Make Depend'),
        ('C', 'Check Depend'),
    )

    pkg = models.ForeignKey(Package, related_name='depends', on_delete=models.CASCADE)
    comparison = models.CharField(max_length=255, default='')
    description = models.TextField(null=True, blank=True)
    deptype = models.CharField(max_length=1, default='D', choices=DEPTYPE_CHOICES)

    def __str__(self):
        '''For depends, we may also have a description and a modifier.'''
        to_str = super(Depend, self).__str__()
        if self.description:
            return f'{to_str}: {self.description}'
        return to_str


class Conflict(RelatedToBase):
    pkg = models.ForeignKey(Package, related_name='conflicts', on_delete=models.CASCADE)
    comparison = models.CharField(max_length=255, default='')


class Provision(RelatedToBase):
    pkg = models.ForeignKey(Package, related_name='provides', on_delete=models.CASCADE)
    # comparison must be '=' for provides

    @property
    def comparison(self):
        if self.version is not None and self.version != '':
            return '='
        return None


class Replacement(RelatedToBase):
    pkg = models.ForeignKey(Package, related_name='replaces', on_delete=models.CASCADE)
    comparison = models.CharField(max_length=255, default='')


# hook up some signals
for sender in (FlagRequest, PackageRelation,
               SignoffSpecification, Signoff, Update):
    pre_save.connect(set_created_field, sender=sender, dispatch_uid="packages.models")

# vim: set ts=4 sw=4 et:
