from __future__ import print_function, unicode_literals

import logging
import pkg_resources
import re
import six
import sys

from rbtools.utils.process import die, execute


# The clients are lazy loaded via load_scmclients()
SCMCLIENTS = None


class PatchResult(object):
    """The result of a patch operation.

    This stores state on whether the patch could be applied (fully or
    partially), whether there are conflicts that can be resolved (as in
    conflict markers, not reject files), which files conflicted, and the
    patch output.
    """
    def __init__(self, applied, has_conflicts=False,
                 conflicting_files=[], patch_output=None):
        self.applied = applied
        self.has_conflicts = has_conflicts
        self.conflicting_files = conflicting_files
        self.patch_output = patch_output


class SCMClient(object):
    """A base representation of an SCM tool.

    These are used for fetching repository information and generating diffs.
    """
    name = None

    supports_diff_extra_args = False
    supports_diff_exclude_patterns = False
    supports_patch_revert = False

    can_amend_commit = False
    can_merge = False
    can_push_upstream = False
    can_delete_branch = False

    def __init__(self, config=None, options=None):
        self.config = config or {}
        self.options = options
        self.capabilities = None

    def get_repository_info(self):
        return None

    def check_options(self):
        pass

    def get_changenum(self, revisions):
        """Return the change number for the given revisions.

        This is only used when the client is supposed to send a change number
        to the server (such as with Perforce).

        Args:
            revisions (dict):
                A revisions dictionary as returned by ``parse_revision_spec``.

        Returns:
            unicode:
            The change number to send to the Review Board server.
        """
        return None

    def scan_for_server(self, repository_info):
        """Find the server path.

        This will search for the server name in the .reviewboardrc config
        files. These are loaded with the current directory first, and searching
        through each parent directory, and finally $HOME/.reviewboardrc last.
        """
        return self._get_server_from_config(self.config, repository_info)

    def parse_revision_spec(self, revisions=[]):
        """Parses the given revision spec.

        The 'revisions' argument is a list of revisions as specified by the
        user. Items in the list do not necessarily represent a single revision,
        since the user can use SCM-native syntaxes such as "r1..r2" or "r1:r2".
        SCMTool-specific overrides of this method are expected to deal with
        such syntaxes.

        This will return a dictionary with the following keys:
            'base':        A revision to use as the base of the resulting diff.
            'tip':         A revision to use as the tip of the resulting diff.
            'parent_base': (optional) The revision to use as the base of a
                           parent diff.

        These will be used to generate the diffs to upload to Review Board (or
        print). The diff for review will include the changes in (base, tip],
        and the parent diff (if necessary) will include (parent, base].

        If a single revision is passed in, this will return the parent of that
        revision for 'base' and the passed-in revision for 'tip'.

        If zero revisions are passed in, this will return revisions relevant
        for the "current change". The exact definition of what "current" means
        is specific to each SCMTool backend, and documented in the
        implementation classes.
        """
        return {
            'base': None,
            'tip': None,
        }

    def diff(self, revisions, include_files=[], exclude_patterns=[],
             extra_args=[]):
        """
        Returns the generated diff and optional parent diff for this
        repository.

        The return value must be a dictionary, and must have, at a minimum,
        a 'diff' field. A 'parent_diff' can also be provided.

        It may also return 'base_commit_id', representing the revision/ID of
        the commit that the diff or parent diff is based on. This exists
        because in some diff formats, this may different from what's provided
        in the diff.
        """
        return {
            'diff': None,
            'parent_diff': None,
            'base_commit_id': None,
        }

    def _get_server_from_config(self, config, repository_info):
        if 'REVIEWBOARD_URL' in config:
            return config['REVIEWBOARD_URL']
        elif 'TREES' in config:
            trees = config['TREES']
            if not isinstance(trees, dict):
                die('Warning: "TREES" in config file is not a dict!')

            # If repository_info is a list, check if any one entry is in trees.
            path = None

            if isinstance(repository_info.path, list):
                for path in repository_info.path:
                    if path in trees:
                        break
                else:
                    path = None
            elif repository_info.path in trees:
                path = repository_info.path

            if path and 'REVIEWBOARD_URL' in trees[path]:
                return trees[path]['REVIEWBOARD_URL']

        return None

    def _get_p_number(self, base_path, base_dir):
        """Return the appropriate value for the -p argument to patch.

        This function returns an integer. If the integer is -1, then the -p
        option should not be provided to patch. Otherwise, the return value is
        the argument to patch -p.
        """
        if base_path and base_dir.startswith(base_path):
            return base_path.count('/') + 1
        else:
            return -1

    def _strip_p_num_slashes(self, files, p_num):
        """Strips the smallest prefix containing p_num slashes from file names.

        To match the behavior of the patch -pX option, adjacent slashes are
        counted as a single slash.
        """
        if p_num > 0:
            regex = re.compile(r'[^/]*/+')
            return [regex.sub('', f, p_num) for f in files]
        else:
            return files

    def _execute(self, cmd, *args, **kwargs):
        """
        Prints the results of the executed command and returns
        the data result from execute.
        """
        return execute(cmd, ignore_errors=True, *args, **kwargs)

    def has_pending_changes(self):
        """Checks if there are changes waiting to be committed.

        Derived classes should override this method if they wish to support
        checking for pending changes.
        """
        raise NotImplementedError

    def apply_patch(self, patch_file, base_path, base_dir, p=None,
                    revert=False):
        """Apply the patch and return a PatchResult indicating its success."""
        # Figure out the -p argument for patch. We override the calculated
        # value if it is supplied via a commandline option.
        p_num = p or self._get_p_number(base_path, base_dir)

        cmd = ['patch']

        if revert:
            cmd.append('-R')

        if p_num >= 0:
            cmd.append('-p%d' % p_num)

        cmd.extend(['-i', six.text_type(patch_file)])

        # Ignore return code 2 in case the patch file consists of only empty
        # files, which 'patch' can't handle. Other 'patch' errors also give
        # return code 2, so we must check the command output.
        rc, patch_output = execute(cmd, extra_ignore_errors=(2,),
                                   return_error_code=True)
        only_garbage_in_patch = ('patch: **** Only garbage was found in the '
                                 'patch input.\n')

        if (patch_output and patch_output.startswith('patch: **** ') and
            patch_output != only_garbage_in_patch):
            die('Failed to execute command: %s\n%s' % (cmd, patch_output))

        # Check the patch for any added/deleted empty files to handle.
        if self.supports_empty_files():
            try:
                with open(patch_file, 'rb') as f:
                    patch = f.read()
            except IOError as e:
                logging.error('Unable to read file %s: %s', patch_file, e)
                return

            patched_empty_files = self.apply_patch_for_empty_files(
                patch, p_num, revert=revert)

            # If there are no empty files in a "garbage-only" patch, the patch
            # is probably malformed.
            if (patch_output == only_garbage_in_patch and
                not patched_empty_files):
                die('Failed to execute command: %s\n%s' % (cmd, patch_output))

        # TODO: Should this take into account apply_patch_for_empty_files ?
        #       The return value of that function is False both when it fails
        #       and when there are no empty files.
        return PatchResult(applied=(rc == 0), patch_output=patch_output)

    def create_commit(self, message, author, run_editor,
                      files=[], all_files=False):
        """Creates a commit based on the provided message and author.

        Derived classes should override this method if they wish to support
        committing changes to their repositories.
        """
        raise NotImplementedError

    def get_commit_message(self, revisions):
        """Returns the commit message from the commits in the given revisions.

        This pulls out the first line from the commit messages of the
        given revisions. That is then used as the summary.
        """
        commit_message = self.get_raw_commit_message(revisions)
        lines = commit_message.splitlines()

        if not lines:
            return None

        result = {
            'summary': lines[0],
        }

        # Try to pull the body of the commit out of the full commit
        # description, so that we can skip the summary.
        if len(lines) >= 3 and lines[0] and not lines[1]:
            result['description'] = '\n'.join(lines[2:]).strip()
        else:
            result['description'] = commit_message

        return result

    def delete_branch(self, branch_name, merged_only=True):
        """Deletes the specified branch.

        If merged_only is False, then the branch will be deleted even if not
        yet merged into an upstream branch.
        """
        raise NotImplementedError

    def merge(self, target, destination, message, author, squash=False,
              run_editor=False):
        """Merges the target branch with destination branch."""
        raise NotImplementedError

    def push_upstream(self, remote_branch):
        """Pushes the current branch to upstream."""
        raise NotImplementedError

    def get_raw_commit_message(self, revisions):
        """Extracts the commit messages on the commits in the given revisions.

        Derived classes should override this method in order to allow callers
        to fetch commit messages. This is needed for description guessing.

        If a derived class is unable to fetch the description, ``None`` should
        be returned.

        Callers that need to differentiate the summary from the description
        should instead use get_commit_message().