# Copyright 2015 Google Inc. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Wrapper to manipulate GCP git repository."""

import errno
import os
import re
import subprocess
import textwrap

from googlecloudsdk.core import exceptions
from googlecloudsdk.core import log
from googlecloudsdk.core import properties
from googlecloudsdk.core.util import files
from googlecloudsdk.core.util import platforms
import uritemplate


# This regular expression is used to extract the URL of the 'origin' remote by
# scraping 'git remote show origin'.
_ORIGIN_URL_RE = re.compile(r'remote origin\n.*Fetch URL: (?P<url>.+)\n', re.M)
# This is the minimum version of git required to use credential helpers.
_HELPER_MIN = (2, 0, 1)

_TRAILING_SPACES = re.compile(r'(^|^.*[^\\ ]|^.*\\ ) *$')


class Error(exceptions.Error):
  """Exceptions for this module."""


class UnknownRepositoryAliasException(Error):
  """Exception to be thrown when a repository alias provided cannot be found."""


class CannotInitRepositoryException(Error):
  """Exception to be thrown when a repository cannot be created."""


class CannotFetchRepositoryException(Error):
  """Exception to be thrown when a repository cannot be fetched."""


class GitVersionException(Error):
  """Exceptions for when git version is too old."""

  def __init__(self, fmtstr, cur_version, min_version):
    self.cur_version = cur_version
    super(GitVersionException, self).__init__(
        fmtstr.format(cur_version=cur_version, min_version=min_version))


class InvalidGitException(Error):
  """Exceptions for when git version is empty or invalid."""

  def __init__(self, message):
    super(InvalidGitException, self).__init__(message)


class GcloudIsNotInPath(Error):
  """Exception for when the gcloud cannot be found."""


def CheckGitVersion(version_lower_bound=None):
  """Returns true when version of git is >= min_version.

  Args:
    version_lower_bound: (int,int,int), The lowest allowed version, or None to
      just check for the presence of git.

  Returns:
    True if version >= min_version.

  Raises:
    GitVersionException: if `git` was found, but the version is incorrect.
    InvalidGitException: if `git` was found, but the output of `git version` is
      not as expected.
    NoGitException: if `git` was not found.
  """
  try:
    output = subprocess.check_output(['git', 'version'])
    if not output:
      raise InvalidGitException('The git version string is empty.')
    if not output.startswith('git version '):
      raise InvalidGitException(('The git version string must start with '
                                 'git version .'))
    match = re.search(r'(\d+)\.(\d+)\.(\d+)', output)
    if not match:
      raise InvalidGitException('The git version string must contain a '
                                'version number.')

    cur_version = match.group(1, 2, 3)
    current_version = tuple([int(item) for item in cur_version])
    if version_lower_bound and current_version < version_lower_bound:
      min_version = '.'.join(str(i) for i in version_lower_bound)
      raise GitVersionException(
          ('Your git version {cur_version} is older than the minimum version '
           '{min_version}. Please install a newer version of git.'),
          output, min_version)
  except OSError as e:
    if e.errno == errno.ENOENT:
      raise NoGitException()
    raise
  return True


class NoGitException(Error):
  """Exceptions for when git is not available."""

  def __init__(self):
    super(NoGitException, self).__init__(
        textwrap.dedent("""\
        Cannot find git. Please install git and try again.

        You can find git installers at [http://git-scm.com/downloads], or use
        your favorite package manager to install it on your computer. Make sure
        it can be found on your system PATH.
        """))


def _GetRepositoryURI(project, alias):
  """Get the URI for a repository, given its project and alias.

  Args:
    project: str, The project name.
    alias: str, The repository alias.

  Returns:
    str, The repository URI.
  """
  return uritemplate.expand(
      'https://source.developers.google.com/p/{project}/r/{alias}',
      {'project': project, 'alias': alias})


def _GetGcloudScript():
  """Get name of the gcloud script."""

  if (platforms.OperatingSystem.Current() ==
      platforms.OperatingSystem.WINDOWS):
    gcloud_ext = '.cmd'
  else:
    gcloud_ext = ''

  gcloud_name = 'gcloud'
  gcloud = files.FindExecutableOnPath(gcloud_name, pathext=[gcloud_ext])

  if not gcloud:
    raise GcloudIsNotInPath(
        'Could not verify that gcloud is in the PATH. '
        'Please make sure the Cloud SDK bin folder is in PATH.')

  return gcloud_name + gcloud_ext


def _NormalizeToUnixPath(path, strip=True):
  """Returns a path with '/' for the directory separator.

  The regular expressions used in .gitignore processing use '/' as the directory
  separator, but many APIs will convert '/' to '\' on Windows. This method can
  be used to ensure consistent name formation on any platform. (To date, '/'
  and '\' are the only directory separators in commond use). We can't just use
  normpath, because '\' has special meaning in regular expressions.

  Note that there is a potential corner case here when a Unix file name contains
  a backslash. The worst case effect here is that such a file might not be
  ignored when it should.

  Args:
    path: The path to normalize.
    strip: If True, also strip any trailing '/' characters.
  Returns:
    The normalized path.
  """
  path = path.replace(os.sep, '/')
  if strip and path != '/':
    return path.rstrip('/')
  else:
    return path


class GitIgnoreHandler(object):
  """Processes .gitignore rules.

  This class handles .gitignore files over a directory hierarchy, applying
  rules recursively. It is intended to be a full implementation of the rules
  described at https://git-scm.com/docs/gitignore, though it is much less
  restrictive about re-inclusion of files (at the cost of requiring a scan
  of fully-excluded directories).

  It does not handle the core.excludesFile setting in the user's .gitconfig.
  """

  def __init__(self):
    self._ignore_rules = {}
    self. _ProcessIgnoreFile(os.path.expanduser('~/.config/git/ignore'), '/')

  def AddIgnoreRules(self, path, rules):
    """Adds rules for ignoring files under the given path.

    Args:
      path: The path where the rules apply.
      rules: A list of (RegEx, Bool) pairs indicating that files matching the
        RegEx should (or should not) be ignored. (A True value indicates files
        matching the pattern should be ignored). The patterns will be compared
        to full path names, and should specify '/' as the directory specifier,
        regardless of platform.
    """
    self._ignore_rules[_NormalizeToUnixPath(path)] = rules

  def ProcessGitIgnore(self, path):
    """Processes the .gitignore file (if any) in the given path.

      Updates the internal path->rules mapping based on the .gitignore file.

    Args:
      path: The path to a directory which may contain a .gitignore file.
    """
    self._ProcessIgnoreFile(os.path.join(path, '.git/info/exclude'), path)
    self._ProcessIgnoreFile(os.path.join(path, '.gitignore'), path)

  def ShouldIgnoreFile(self, path):
    """Test if a file should be ignored based on the given patterns.

    Compares the path to each pattern in ignore_patterns, in order. If it
    matches a pattern whose Bool is True, the file should be excluded unless it
    also matches a later pattern which has a bool of False. Similarly, if a name
    matches a pattern with a Bool that is False, it should be included
    unless it also matches a later pattern which has a bool of True.

    Args:
      path: The file name to test.
    Returns:
      True if the file should be ignored, False otherwise.
    """
    # Normalize separators, but leave any trailing '/' to allow explicit
    # directory matches.
    path = _NormalizeToUnixPath(path, strip=False)
    rules = self._GetRules(path)
    ret = False
    for pattern, should_ignore in rules:
      if pattern.match(path):
        log.debug('{0}: matches {1} => ignore=={2}'.format(
            path, pattern.pattern, should_ignore))
        ret = should_ignore
    return ret

  def GetFiles(self, root):
    """Yields all files in the given directory tree which should not be ignored.

    Args:
      root: The directory to walk
    Yields:
      [path] The full path to every file under the given root directory which
        should not be ignored.
    """
    for base, _, file_list in os.walk(root):
      self.ProcessGitIgnore(base)
      for f in file_list:
        # Exclude file_list based on the .gitignore rules.
        filename = os.path.join(base, f)
        if not self.ShouldIgnoreFile(filename):
          yield filename

  def _ProcessIgnoreFile(self, git_ignore_file, target_dir):
    """Processes a .gitignore file.

      Updates the internal path->rules mapping based on the .gitignore file.

    Args:
      git_ignore_file: The path to a directory a .gitignore file which may not
        exist.
      target_dir: The directory where the rules in the gitignore file apply.
    """
    if not os.path.exists(git_ignore_file):
      return
    path = _NormalizeToUnixPath(target_dir)
    log.debug('Processing {0}'.format(git_ignore_file))
    ret = []
    with open(git_ignore_file, 'r') as f:
      for line in f:
        pattern, should_ignore = self._ParseLine(line, path)
        if pattern:
          ret.append((pattern, should_ignore))
    if path in self._ignore_rules:
      self._ignore_rules[path].extend(ret)
    else:
      self._ignore_rules[path] = ret

  def _GetRules(self, path):
    """Returns the set of rules that apply to a given path.

    Searches all parent paths for rules, returning the concatenation of all the
    rules.

    Args:
      path: The path to check.

    Returns:
      A list of (RegEx, Bool) pairs indicating file name patterns to include/
      exclude.
    """