"""Forms for uploading diffs."""

from functools import partial

from dateutil.parser import isoparse
from django import forms
from django.core.exceptions import ValidationError
from django.utils.encoding import force_str
from django.utils.translation import gettext, gettext_lazy as _

from reviewboard.diffviewer.commit_utils import (deserialize_validation_info,
                                                 get_file_exists_in_history)
from reviewboard.diffviewer.differ import DiffCompatVersion
from reviewboard.diffviewer.diffutils import check_diff_size
from reviewboard.diffviewer.filediff_creator import create_filediffs
from reviewboard.diffviewer.models import DiffCommit, DiffSet
from reviewboard.diffviewer.validators import (COMMIT_ID_LENGTH,
                                               validate_commit_id)


class BaseCommitValidationForm(forms.Form):
    """A form mixin for handling validation metadata for commits."""

    validation_info = forms.CharField(
        label=_('Validation metadata'),
        help_text=_('Validation metadata generated by the diff commit '
                    'validation resource.'),
        widget=forms.HiddenInput,
        required=False)

    def clean_validation_info(self):
        """Clean the validation_info field.

        This method ensures that if the field is supplied that it parses as
        base64-encoded JSON.

        Returns:
            dict:
            The parsed validation information.

        Raises:
            django.core.exceptions.ValidationError:
                The value could not be parsed.
        """
        validation_info = self.cleaned_data.get('validation_info', '').strip()

        if not validation_info:
            return {}

        try:
            return deserialize_validation_info(validation_info)
        except (TypeError, ValueError) as e:
            raise ValidationError(
                gettext(
                    'Could not parse validation info "%(validation_info)s": '
                    '%(exc)s'
                ) % {
                    'exc': e,
                    'validation_info': validation_info,
                })


class UploadCommitForm(BaseCommitValidationForm):
    """The form for uploading a diff and creating a DiffCommit."""

    diff = forms.FileField(
        label=_('Diff'),
        help_text=_('The new diff to upload.'))

    parent_diff = forms.FileField(
        label=_('Parent diff'),
        help_text=_('An optional diff that the main diff is based on. '
                    'This is usually used for distributed revision control '
                    'systems (Git, Mercurial, etc.).'),
        required=False)

    commit_id = forms.CharField(
        label=_('Commit ID'),
        help_text=_('The ID of this commit.'),
        max_length=COMMIT_ID_LENGTH,
        validators=[validate_commit_id])

    parent_id = forms.CharField(
        label=_('Parent commit ID'),
        help_text=_('The ID of the parent commit.'),
        max_length=COMMIT_ID_LENGTH,
        validators=[validate_commit_id])

    commit_message = forms.CharField(
        label=_('Description'),
        help_text=_('The commit message.'))

    author_name = forms.CharField(
        label=_('Author name'),
        help_text=_('The name of the author of this commit.'),
        max_length=DiffCommit.NAME_MAX_LENGTH)

    author_email = forms.CharField(
        label=_('Author e-mail address'),
        help_text=_('The e-mail address of the author of this commit.'),
        max_length=DiffCommit.EMAIL_MAX_LENGTH,
        widget=forms.EmailInput)

    author_date = forms.CharField(
        label=_('Author date'),
        help_text=_('The date and time this commit was authored.'))

    committer_name = forms.CharField(
        label=_('Committer name'),
        help_text=_('The name of the committer of this commit.'),
        max_length=DiffCommit.NAME_MAX_LENGTH,
        required=True)

    committer_email = forms.CharField(
        label=_('Committer e-mail address'),
        help_text=_('The e-mail address of the committer of this commit.'),
        max_length=DiffCommit.EMAIL_MAX_LENGTH,
        widget=forms.EmailInput,
        required=True)

    committer_date = forms.CharField(
        label=_('Committer date'),
        help_text=_('The date and time this commit was committed.'),
        required=True)

    def __init__(self, diffset, request=None, *args, **kwargs):
        """Initialize the form.

        Args:
            diffset (reviewboard.diffviewer.models.diffset.DiffSet):
                The DiffSet to attach the created DiffCommit to.

            request (django.http.HttpRequest, optional):
                The HTTP request from the client.

            *args (tuple):
                Additional positional arguments.

            **kwargs (dict):
                Additional keyword arguments.
        """
        super(UploadCommitForm, self).__init__(*args, **kwargs)

        if not diffset.repository.scmtool_class.commits_have_committer:
            del self.fields['committer_date']
            del self.fields['committer_email']
            del self.fields['committer_name']

        self.diffset = diffset
        self.request = request

    def create(self):
        """Create the DiffCommit.

        Returns:
            reviewboard.diffviewer.models.diffcommit.DiffCommit:
            The created DiffCommit.
        """
        assert self.is_valid()

        return DiffCommit.objects.create_from_upload(
            request=self.request,
            validation_info=self.cleaned_data['validation_info'],
            diffset=self.diffset,
            repository=self.diffset.repository,
            base_commit_id=self.diffset.base_commit_id,
            diff_file=self.cleaned_data['diff'],
            parent_diff_file=self.cleaned_data.get('parent_diff'),
            commit_message=self.cleaned_data['commit_message'],
            commit_id=self.cleaned_data['commit_id'],
            parent_id=self.cleaned_data['parent_id'],
            author_name=self.cleaned_data['author_name'],
            author_email=self.cleaned_data['author_email'],
            author_date=self.cleaned_data['author_date'],
            committer_name=self.cleaned_data.get('committer_name'),
            committer_email=self.cleaned_data.get('committer_email'),
            committer_date=self.cleaned_data.get('committer_date'))

    def clean(self):
        """Clean the form.

        Returns:
            dict:
            The cleaned form data.

        Raises:
            django.core.exceptions.ValidationError:
                The form data was not valid.
        """
        super(UploadCommitForm, self).clean()

        if self.diffset.history_id is not None:
            # A diffset will have a history attached if and only if it has been
            # published, in which case we cannot attach further commits to it.
            raise ValidationError(gettext(
                'Cannot upload commits to a published diff.'))

        if (self.diffset.commit_count and
            'validation_info' not in self.cleaned_data and
            'validation_info' not in self.errors):
            # If validation_info is present in `errors`, it will not be in
            # self.cleaned_data. We do not want to report it missing if it
            # failed validation for another reason.
            self._errors['validation_info'] = self.error_class([
                self.fields['validation_info'].error_messages['required'],
            ])

        return self.cleaned_data

    def clean_author_date(self):
        """Parse the date and time in the author_date field.

        Returns:
            datetime.datetime:
            The parsed date and time.
        """
        try:
            return isoparse(self.cleaned_data['author_date'])
        except ValueError:
            raise ValidationError(gettext(
                'This date must be in ISO 8601 format.'))

    def clean_committer_date(self):
        """Parse the date and time in the committer_date field.

        Returns:
            datetime.datetime:
            The parsed date and time.
        """
        try:
            return isoparse(self.cleaned_data['committer_date'])
        except ValueError:
            raise ValidationError(gettext(
                'This date must be in ISO 8601 format.'))


class UploadDiffForm(forms.Form):
    """The form for uploading a diff and creating a DiffSet."""

    path = forms.FileField(
        label=_('Diff'),
        help_text=_('The new diff to upload.'))

    parent_diff_path = forms.FileField(
        label=_('Parent Diff'),
        help_text=_('An optional diff that the main diff is based on. '
                    'This is usually used for distributed revision control '
                    'systems (Git, Mercurial, etc.).'),
        required=False)

    basedir = forms.CharField(
        label=_('Base Directory'),
        help_text=_('The absolute path in the repository the diff was '
                    'generated in.'))

    base_commit_id = forms.CharField(
        label=_('Base Commit ID'),
        help_text=_('The ID/revision this change is built upon.'),
        required=False)

    def __init__(self, repository, request=None, *args, **kwargs):
        """Initialize the form.

        Args:
            repository (reviewboard.scmtools.models.Repository):
                The repository the diff will be uploaded against.

            request (django.http.HttpRequest, optional):
                The HTTP request from the client.

            *args (tuple):
                Additional positional arguments.

            **kwrgs (dict):
                Additional keyword arguments.
        """
        super(UploadDiffForm, self).__init__(*args, **kwargs)

        self.repository = repository
        self.request = request

        if repository.diffs_use_absolute_paths:
            # This SCMTool uses absolute paths, so there's no need to ask
            # the user for the base directory.
            del(self.fields['basedir'])

    def clean_base_commit_id(self):
        """Clean the ``base_commit_id`` field.

        Returns:
            unicode:
            The ``base_commit_id`` field stripped of leading and trailing
            whitespace, or ``None`` if that value would be empty.
        """
        return self.cleaned_data['base_commit_id'].strip() or None

    def clean_basedir(self):
        """Clean the ``basedir`` field.

        Returns:
            unicode:
            The basedir field as a unicode string with leading and trailing
            whitespace removed.
        """
        if self.repository.diffs_use_absolute_paths:
            return ''

        return force_str(self.cleaned_data['basedir'].strip())

    def create(self, diffset_history=None):
        """Create the DiffSet.

        Args:
            diffset_history (reviewboard.diffviewer.models.diffset_history.
                             DiffSetHistory):
                The DiffSet history to attach the created DiffSet to.

        Returns:
            reviewboard.diffviewer.models.diffset.DiffSet:
            The created DiffSet.
        """
        assert self.is_valid()

        return DiffSet.objects.create_from_upload(
            repository=self.repository,
            diffset_history=diffset_history,
            diff_file=self.cleaned_data['path'],
            parent_diff_file=self.cleaned_data.get('parent_diff_path'),
            basedir=self.cleaned_data.get('basedir', ''),
            base_commit_id=self.cleaned_data['base_commit_id'],
            request=self.request)


class ValidateCommitForm(BaseCommitValidationForm):
    """A form for validating of DiffCommits."""

    diff = forms.FileField(
        label=_('Diff'),
        help_text=_('The new diff to upload.'))

    parent_diff = forms.FileField(
        label=_('Parent diff'),
        help_text=_('An optional diff that the main diff is based on. '
                    'This is usually used for distributed revision control '
                    'systems (Git, Mercurial, etc.).'),
        required=False)

    commit_id = forms.CharField(
        label=_('Commit ID'),
        help_text=_('The ID of this commit.'),
        max_length=COMMIT_ID_LENGTH,
        validators=[validate_commit_id])

    parent_id = forms.CharField(
        label=_('Parent commit ID'),
        help_text=_('The ID of the parent commit.'),
        max_length=COMMIT_ID_LENGTH,
        validators=[validate_commit_id])

    base_commit_id = forms.CharField(
        label=_('Base commit ID'),
        help_text=_('The base commit ID that the commits are based off of.'),
        required=False)

    def __init__(self, repository, request=None, *args, **kwargs):
        """Initialize the form.

        Args:
            repository (reviewboard.scmtools.models.Repository):
                The repository against which the diff is being validated.

            request (django.http.HttpRequest, optional):
                The HTTP request from the client.

            *args (tuple):
                Additional positional arguments to pass to the base
                class initializer.

            **kwargs (dict):
                Additional keyword arguments to pass to the base class
                initializer.
        """
        super(ValidateCommitForm, self).__init__(*args, **kwargs)

        self.repository = repository
        self.request = request

    def clean(self):
        """Clean the form.

        Returns:
            dict:
            The cleaned form data.

        Raises:
            django.core.exceptions.ValidationError:
                The form data was not valid.
        """
        super(ValidateCommitForm, self).clean()

        validation_info = self.cleaned_data.get('validation_info')

        if validation_info:
            errors = []

            parent_id = self.cleaned_data.get('parent_id')
            commit_id = self.cleaned_data.get('commit_id')

            if commit_id and commit_id in validation_info:
                errors.append(gettext('This commit was already validated.'))
            elif parent_id and parent_id not in validation_info:
                errors.append(gettext('The parent commit was not validated.'))

            if errors:
                self._errors['validation_info'] = self.error_class(errors)
                self.cleaned_data.pop('validation_info')

        return self.cleaned_data

    def validate_diff(self):
        """Validate the DiffCommit.

        This will attempt to parse the given diff (and optionally parent
        diff) into :py:class:`FileDiffs
        <reviewboard.diffviewer.models.filediff.FileDiff>`. This will not
        result in anything being committed to the database.

        Returns:
            tuple:
            A 2-tuple containing the following:

            * A list of the created FileDiffs.
            * A list of the parent FileDiffs, or ``None``.

        Raises:
            reviewboard.diffviewer.errors.DiffParserError:
                The diff could not be parsed.

            reviewboard.diffviewer.errors.DiffTooBigError:
                The diff was too big.

            reviewboard.diffviewer.errors.EmptyDiffError:
                The diff did not contain any changes.

            reviewboard.scmtools.errors.FileNotFoundError:
                A file was not found in the repository.

            reviewboard.scmtools.errors.SCMError:
                An error occurred within the SCMTool.
        """
        assert self.is_valid()

        diff_file = self.cleaned_data['diff']
        parent_diff_file = self.cleaned_data.get('parent_diff')
        validation_info = self.cleaned_data.get('validation_info')

        check_diff_size(diff_file, parent_diff_file)

        if parent_diff_file:
            parent_diff_file_contents = parent_diff_file.read()
        else:
            parent_diff_file_contents = None

        base_commit_id = self.cleaned_data['base_commit_id']

        diffset = DiffSet(name='diff',
                          revision=0,
                          basedir='',
                          repository=self.repository,
                          diffcompat=DiffCompatVersion.DEFAULT,
                          base_commit_id=base_commit_id)

        get_file_exists = partial(get_file_exists_in_history,
                                  validation_info or {},
                                  self.repository,
                                  self.cleaned_data['parent_id'])

        return create_filediffs(
            diff_file_contents=diff_file.read(),
            parent_diff_file_contents=parent_diff_file_contents,
            repository=self.repository,
            basedir='',
            base_commit_id=base_commit_id,
            get_file_exists=get_file_exists,
            diffset=diffset,
            request=self.request,
            diffcommit=None,
            validate_only=True)
