from __future__ import unicode_literals

from time import time

from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.functional import cached_property
from django.utils.http import urlquote
from django.utils.translation import ugettext_lazy as _
from djblets.cache.backend import cache_memoize, make_cache_key
from djblets.db.fields import JSONField
from djblets.log import log_timed
from django.utils import six

from reviewboard.hostingsvcs.models import HostingServiceAccount
from reviewboard.hostingsvcs.service import get_hosting_service
from reviewboard.scmtools.managers import RepositoryManager, ToolManager
from reviewboard.scmtools.signals import (checked_file_exists,
                                          checking_file_exists,
                                          fetched_file, fetching_file)
from reviewboard.scmtools.core import FileNotFoundError
from reviewboard.site.models import LocalSite


@python_2_unicode_compatible
class Tool(models.Model):
    name = models.CharField(max_length=32, unique=True)
    class_name = models.CharField(max_length=128, unique=True)

    objects = ToolManager()

    # Templates can't access variables on a class properly. It'll attempt to
    # instantiate the class, which will fail without the necessary parameters.
    # So, we use these as convenient wrappers to do what the template can't do.
    supports_authentication = property(
        lambda x: x.scmtool_class.supports_authentication)
    supports_raw_file_urls = property(
        lambda x: x.scmtool_class.supports_raw_file_urls)
    supports_ticket_auth = property(
        lambda x: x.scmtool_class.supports_ticket_auth)
    supports_pending_changesets = property(
        lambda x: x.scmtool_class.supports_pending_changesets)
    field_help_text = property(
        lambda x: x.scmtool_class.field_help_text)

    def __str__(self):
        return self.name

    def get_scmtool_class(self):
        if not hasattr(self, '_scmtool_class'):
            path = self.class_name
            i = path.rfind('.')
            module, attr = path[:i], path[i + 1:]

            try:
                mod = __import__(six.binary_type(module), {}, {},
                                 [six.binary_type(attr)])
            except ImportError as e:
                raise ImproperlyConfigured(
                    'Error importing SCM Tool %s: "%s"' % (module, e))

            try:
                self._scmtool_class = getattr(mod, attr)
            except AttributeError:
                raise ImproperlyConfigured(
                    'Module "%s" does not define a "%s" SCM Tool'
                    % (module, attr))

        return self._scmtool_class
    scmtool_class = property(get_scmtool_class)

    class Meta:
        ordering = ("name",)


@python_2_unicode_compatible
class Repository(models.Model):
    name = models.CharField(max_length=64)
    path = models.CharField(max_length=255)
    mirror_path = models.CharField(max_length=255, blank=True)
    raw_file_url = models.CharField(
        _('Raw file URL mask'),
        max_length=255,
        blank=True,
        help_text=_("A URL mask used to check out a particular revision of a "
                    "file using HTTP. This is needed for repository types "
                    "that can't access remote files natively. "
                    "Use <tt>&lt;revision&gt;</tt> and "
                    "<tt>&lt;filename&gt;</tt> in the URL in place of the "
                    "revision and filename parts of the path."))
    username = models.CharField(max_length=32, blank=True)
    password = models.CharField(max_length=128, blank=True)
    extra_data = JSONField(null=True)

    tool = models.ForeignKey(Tool, related_name="repositories")
    hosting_account = models.ForeignKey(
        HostingServiceAccount,
        related_name='repositories',
        verbose_name=_('Hosting service account'),
        blank=True,
        null=True)

    bug_tracker = models.CharField(
        _('Bug tracker URL'),
        max_length=256,
        blank=True,
        help_text=_("This should be the full path to a bug in the bug tracker "
                    "for this repository, using '%s' in place of the bug ID."))
    encoding = models.CharField(
        max_length=32,
        blank=True,
        help_text=_("The encoding used for files in this repository. This is "
                    "an advanced setting and should only be used if you're "
                    "sure you need it."))
    visible = models.BooleanField(
        _('Show this repository'),
        default=True,
        help_text=_('Use this to control whether or not a repository is '
                    'shown when creating new review requests. Existing '
                    'review requests are unaffected.'))

    archived = models.BooleanField(
        _('Archived'),
        default=False,
        help_text=_("Archived repositories do not show up in lists of "
                    "repositories, and aren't open to new review requests."))

    archived_timestamp = models.DateTimeField(null=True, blank=True)

    # Access control
    local_site = models.ForeignKey(LocalSite,
                                   verbose_name=_('Local site'),
                                   blank=True,
                                   null=True)
    public = models.BooleanField(
        _('publicly accessible'),
        default=True,
        help_text=_('Review requests and files on public repositories are '
                    'visible to anyone. Private repositories must explicitly '
                    'list the users and groups that can access them.'))

    users = models.ManyToManyField(
        User,
        limit_choices_to={'is_active': True},
        blank=True,
        related_name='repositories',
        verbose_name=_('Users with access'),
        help_text=_('A list of users with explicit access to the repository.'))
    review_groups = models.ManyToManyField(
        'reviews.Group',
        limit_choices_to={'invite_only': True},
        blank=True,
        related_name='repositories',
        verbose_name=_('Review groups with access'),
        help_text=_('A list of invite-only review groups whose members have '
                    'explicit access to the repository.'))

    objects = RepositoryManager()

    BRANCHES_CACHE_PERIOD = 60 * 5  # 5 minutes
    COMMITS_CACHE_PERIOD_SHORT = 60 * 5  # 5 minutes
    COMMITS_CACHE_PERIOD_LONG = 60 * 60 * 24  # 1 day

    def get_scmtool(self):
        cls = self.tool.get_scmtool_class()
        return cls(self)

    @cached_property
    def hosting_service(self):
        if self.hosting_account:
            return self.hosting_account.service

        return None

    @cached_property
    def bug_tracker_service(self):
        """Returns selected bug tracker service if one exists."""
        if self.extra_data.get('bug_tracker_use_hosting'):
            return self.hosting_service
        else:
            bug_tracker_type = self.extra_data.get('bug_tracker_type')
            if bug_tracker_type:
                bug_tracker_cls = get_hosting_service(bug_tracker_type)

                # TODO: we need to figure out some way of storing a second
                # hosting service account for bug trackers.
                return bug_tracker_cls(HostingServiceAccount())

        return None

    @property
    def supports_post_commit(self):
        """Whether or not this repository supports post-commit creation.

        If this is True, the get_branches and get_commits methods will be
        implemented to fetch information about the committed revisions, and
        get_change will be implemented to fetch the actual diff. This is used
        by ReviewRequest.update_from_commit_id.
        """
        hosting_service = self.hosting_service
        if hosting_service:
            return hosting_service.supports_post_commit
        else:
            return self.get_scmtool().supports_post_commit

    def get_credentials(self):
        """Returns the credentials for this repository.

        This returns a dictionary with 'username' and 'password' keys.
        By default, these will be the values stored for the repository,
        but if a hosting service is used and the repository doesn't have
        values for one or both of these, the hosting service's credentials
        (if available) will be used instead.
        """
        username = self.username
        password = self.password

        if self.hosting_account and self.hosting_account.service:
            username = username or self.hosting_account.username
            password = password or self.hosting_account.service.get_password()

        return {
            'username': username,
            'password': password,
        }

    def archive(self, save=True):
        """Archives a repository.

        The repository won't appear in any public lists of repositories,
        and won't be used when looking up repositories. Review requests
        can't be posted against an archived repository.

        New repositories can be created with the same information as the
        archived repository.
        """
        # This should be sufficiently unlikely to create duplicates. time()
        # will use up a max of 8 characters, so we slice the name down to
        # make the result fit in 64 characters
        self.name = 'ar:%s:%x' % (self.name[:50], int(time()))
        self.archived = True
        self.public = False
        self.archived_timestamp = timezone.now()

        if save:
            self.save()

    def get_file(self, path, revision, base_commit_id=None, request=None):
        """Returns a file from the repository.

        This will attempt to retrieve the file from the repository. If the
        repository is backed by a hosting service, it will go through that.
        Otherwise, it will attempt to directly access the repository.
        """
        # We wrap the result of get_file in a list and then return the first
        # element after getting the result from the cache. This prevents the
        # cache backend from converting to unicode, since we're no longer
        # passing in a string and the cache backend doesn't recursively look
        # through the list in order to convert the elements inside.
        #
        # Basically, this fixes the massive regressions introduced by the
        # Django unicode changes.
        return cache_memoize(
            self._make_file_cache_key(path, revision, base_commit_id),
            lambda: [self._get_file_uncached(path, revision, base_commit_id,
                                             request)],
            large_data=True)[0]

    def get_file_exists(self, path, revision, base_commit_id=None,
                        request=None):
        """Returns whether or not a file exists in the repository.

        If the repository is backed by a hosting service, this will go
        through that. Otherwise, it will attempt to directly access the
        repository.

        The result of this call will be cached, making future lookups
        of this path and revision on this repository faster.
        """
        key = self._make_file_exists_cache_key(path, revision, base_commit_id)

        if cache.get(make_cache_key(key)) == '1':
            return True

        exists = self._get_file_exists_uncached(path, revision,
                                                base_commit_id, request)

        if exists:
            cache_memoize(key, lambda: '1')

        return exists

    def get_branches(self):
        """Returns a list of branches."""
        hosting_service = self.hosting_service

        cache_key = make_cache_key('repository-branches:%s' % self.pk)
        if hosting_service:
            branches_callable = lambda: hosting_service.get_branches(self)
        else:
            branches_callable = self.get_scmtool().get_branches

        return cache_memoize(cache_key, branches_callable,