# Copyright (c) 2013, Clemson University
# All rights reserved.
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 
# * Redistributions of source code must retain the above copyright notice, this
#   list of conditions and the following disclaimer.
# 
# * Redistributions in binary form must reproduce the above copyright notice,
#   this list of conditions and the following disclaimer in the documentation
#   and/or other materials provided with the distribution.
# 
# * Neither the name of the {organization} nor the names of its
#   contributors may be used to endorse or promote products derived from
#   this software without specific prior written permission.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

import collections
import fnmatch
import re
import subprocess
import sys
from .common import *

DIFF = 'diff'
SVN = 'svn'
SVNADMIN = 'svnadmin'
SVNLOOK = 'svnlook'

head_rev_rx = re.compile(r'^(?=.)(?P<head>\D[^:]*)?:?(?P<rev>\d+)?$')
mergeinfo_rx = re.compile(r'^(?P<head>.+):(?P<minrev>\d+)(?:-(?P<maxrev>\d+))$')
changed_copy_info_rx = re.compile(r'^[ ]{4}\(from (?P<src>.+)\)$')

HistoryEntry = collections.namedtuple('HistoryEntry', 'rev path')

class SvnRepo(VCSRepo):
  """A Subversion repository

  Unless otherwise specified, valid revisions are:

  - an integer (ex: 194)
  - an integer as a string (ex: "194")
  - a branch or tag name (ex: "HEAD", "trunk", "branches/branch1")
  - a branch or tag name at a specific revision (ex: "trunk:194")

  Revisions have the following meanings:

  - HEAD always maps to the root of the repository (/)
  - Anything else (ex: "trunk", "branches/branch1") maps to the corresponding
    path in the repository
  - The youngest revision is assumed unless a revision is specified

  For example, the following code will list the contents of the directory
  branches/branch1/src from revision 194:

      >>> repo = SvnRepo(path)
      >>> repo.ls('branches/branch1:194', 'src')

  Branches and tags are detected in branches() and tags() by looking at the
  paths specified in repo.branch_glob and repo.tag_glob.  The default values
  for these variables will detect the following repository layout:

  - /trunk - the main development branch
  - /branches/* - branches
  - /tags/* - tags

  If a repository does not fit this layout, everything other than branch and
  tag detection will work as expected.

  """

  @classmethod
  def create(cls, path):
    """Create a new repository"""
    cmd = [SVNADMIN, 'create', path]
    subprocess.check_call(cmd)
    return cls(path)

  @classmethod
  def cleanPath(cls, path):
    path = multislash_rx.sub('/', path)
    if not path.startswith('/'):
      path = '/' + path
    return path

  def __init__(self, path):
    super(SvnRepo, self).__init__(path)
    self.branch_glob = ['/trunk/', '/branches/*/']
    self.tag_glob = ['/tags/*/']

  @property
  def private_path(self):
    """Get the path to a directory which can be used to store arbitrary data

    This directory should not conflict with any of the repository internals.
    The directory should be created if it does not already exist.

    """
    import os
    path = os.path.join(self.path, '.private')
    try:
      os.mkdir(path)
    except OSError as e:
      import errno
      if e.errno != errno.EEXIST:
        raise
    return path

  def _proplist(self, rev, path):
    cmd = [SVNLOOK, 'proplist', '-r', rev, '.', path or '--revprop']
    output = self._command(cmd).decode()
    return [x.strip() for x in output.splitlines()]

  def proplist(self, rev, path=None):
    """List Subversion properties of the path"""
    rev, prefix = self._maprev(rev)
    if path is None:
      return self._proplist(str(rev), None)
    else:
      path = type(self).cleanPath(prefix + path)
      return self._proplist(str(rev), path)

  def _propget(self, prop, rev, path):
    cmd = [SVNLOOK, 'propget', '-r', rev, '.', prop, path or '--revprop']
    return self._command(cmd).decode()

  def propget(self, prop, rev, path=None):
    """Get Subversion property value of the path"""
    rev, prefix = self._maprev(rev)
    if path is None:
      return self._propget(prop, str(rev), None)
    else:
      path = type(self).cleanPath(prefix + path)
      return self._propget(prop, str(rev), path)

  def _mergeinfo(self, rev, path):
    revstr = str(rev)
    if 'svn:mergeinfo' not in self._proplist(revstr, path):
      return []
    results = []
    mergeinfo = self._propget('svn:mergeinfo', revstr, path)
    for line in mergeinfo.splitlines():
      m = mergeinfo_rx.match(line)
      assert m
      head, minrev, maxrev = m.group('head', 'minrev', 'maxrev')
      minrev = int(minrev)
      maxrev = int(maxrev or minrev)
      results.append((head, minrev, maxrev))
    return results

  def _maprev(self, rev):
    if isinstance(rev, int):
      return (rev, '/')
    m = head_rev_rx.match(rev)
    assert m, 'invalid rev'
    head, rev = m.group('head', 'rev')
    if rev:
      rev = int(rev)
    else:
      rev = self.youngest()
    if head is None:
      return (rev, '/')
    elif head == 'HEAD':
      return (rev, '/')
    else:
      return (rev, '/' + head)

  def canonical_rev(self, rev):
    try:
      types = (str, unicode)
    except NameError:
      types = str
    if isinstance(rev, int):
      return rev
    elif isinstance(rev, types) and rev.isdigit():
      return int(rev)
    else:
      rev, prefix = self._maprev(rev)
      return rev

  def ls(self, rev, path, recursive=False, recursive_dirs=False,
         directory=False, report=()):
    rev, prefix = self._maprev(rev)
    revstr = str(rev)
    path = type(self).cleanPath(prefix + path)
    forcedir = False
    if path.endswith('/'):
      forcedir = True
      if path != '/':
        path = path.rstrip('/')
    if path == '/':
      if directory:
        entry = attrdict(path='/', type='d')
        if 'commit' in report:
          entry.commit = self._history(revstr, '/', 1)[0].rev
        return [entry]
      ltrim = 1
      prefix = '/'
    else:
      ltrim = len(path) + 1
      prefix = path + '/'

    cmd = [SVNLOOK, 'tree', '-r', revstr, '--full-paths']
    if not recursive:
      cmd.append('--non-recursive')
    cmd.extend(['.', path])
    p = subprocess.Popen(cmd, cwd=self.path, stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE)
    output, stderr = p.communicate()
    if p.returncode != 0:
      stderr = stderr.decode()
      if p.returncode == 1 and 'File not found' in stderr:
        raise PathDoesNotExist(rev, path)
      raise subprocess.CalledProcessError(p.returncode, cmd, stderr)

    results = []
    lines = output.decode(self.encoding, 'replace').splitlines()
    if forcedir and not lines[0].endswith('/'):
      raise PathDoesNotExist(rev, path)
    if lines[0].endswith('/'):
      if directory:
        lines = lines[:1]
      else:
        lines = lines[1:]
    for name in lines:
      entry_name = name[ltrim:]
      entry = attrdict(path=name.strip('/'))
      if name.endswith('/'):
        if recursive and not recursive_dirs:
          continue
        entry.type = 'd'
        entry_name = entry_name.rstrip('/')
      else:
        proplist = self._proplist(revstr, name)
        if 'svn:special' in proplist:
          link = self._cat(revstr, name).decode(self.encoding, 'replace')
          link = link.split(None, 1)
          if len(link) == 2 and link[0] == 'link':
            entry.type = 'l'
            if 'target' in report:
              entry.target = link[1]
        if 'type' not in entry:
          entry.type = 'f'
          if 'executable' in report:
            entry.executable = 'svn:executable' in proplist
          if 'size' in report:
            entry.size = len(self._cat(revstr, name))
      if entry_name:
        entry.name = entry_name
      if 'commit' in report:
        entry.commit = self._history(revstr, name, 1)[0].rev
      results.append(entry)
    return results

  def _cat(self, rev, path):
    cmd = [SVNLOOK, 'cat', '-r', rev, '.', path.encode(self.encoding)]
    return self._command(cmd)

  def cat(self, rev, path):
    rev, prefix = self._maprev(rev)
    path = type(self).cleanPath(prefix + path)
    ls = self.ls(rev, path, directory=True)
    assert len(ls) == 1
    if ls[0].get('type') != 'f':
      raise BadFileType(rev, path)
    return self._cat(str(rev), path)

  def _readlink(self, rev, path):
    output = self._cat(rev, path)
    link = output.decode(self.encoding, 'replace').split(None, 1)
    assert len(link) == 2 and link[0] == 'link'
    return link[1]

  def readlink(self, rev, path):
    rev, prefix = self._maprev(rev)
    path = type(self).cleanPath(prefix + path)
    ls = self.ls(rev, path, directory=True)
    assert len(ls) == 1
    if ls[0].get('type') != 'l':
      raise BadFileType(rev, path)
    return self._readlink(str(rev), path)

  def youngest(self):
    cmd = [SVNLOOK, 'youngest', '.']
    return int(self._command(cmd))

  def _heads(self, globs):
    root = {}
    for glob in globs:
      n = root
      for p in glob.strip('/').split('/'):
        n = n.setdefault(p, {})
    youngest = self.youngest()
    results = []
    def match(n, path):
      for d in self.ls(youngest, path):
        if d.get('type') == 'd':
          for k, v in n.items():
            if fnmatch.fnmatchcase(d.name, k):
              if path:
                p = path + '/' + d.name
              else:
                p = d.name
              if v:
                match(v, p)
              else:
                results.append(p)
    match(root, '')
    return results

  def branches(self):
    return ['HEAD'] + self._heads(self.branch_glob)

  def tags(self):
    return self._heads(self.tag_glob)

  def heads(self):
    return ['HEAD'] + self._heads(self.branch_glob + self.tag_glob)

  def empty(self):
    cmd = [SVNLOOK, 'history', '.', '-l2']
    output = self._command(cmd)
    return len(output.splitlines()) < 4

  def __contains__(self, rev):
    rev, prefix = self._maprev(rev)
    cmd = [SVNLOOK, 'history', '.', prefix, '-l1', '-r', str(rev)]
    p = subprocess.Popen(cmd, cwd=self.path, stdout=subprocess.PIPE,
                         stderr=subprocess.PIPE)
    stdout, stderr = p.communicate()
    return p.returncode == 0

  def __len__(self):
    cmd = [SVNLOOK, 'history', '.']
    output = self._command(cmd)
    return len(output.splitlines()) - 3

  def log(self, revrange=None, limit=None, firstparent=False, merges=None,
          path=None, follow=False):
    if not (revrange is None or isinstance(revrange, (tuple, list))):
      # a single revision was given
      rev, prefix = self._maprev(revrange)
      h = self._history(rev, prefix, 1)
      rev = h[0].rev
      return self._logentry(rev, prefix)

    if revrange is None:
      results = self._history(self.youngest(), path or '/', limit)
    else:
      if revrange[1] is None:
        include = set()
        rev1 = self.youngest()
        for head in self.heads():
          if head == 'HEAD':
            continue
          if path:
            p = head + '/' + path.lstrip('/')
          else:
            p = type(self).cleanPath(head)
          include.update(self._mergehistory(rev1, p, limit))
      else:
        rev1, prefix1 = self._maprev(revrange[1])
        if path:
          p = type(self).cleanPath(prefix1 + '/' + path)
        else:
          p = prefix1
        if firstparent:
          include = self._history(rev1, p)
        else:
          include = self._mergehistory(rev1, p, limit)

      if revrange[0] is None:
        results = include
      else:
        rev0, prefix0 = self._maprev(revrange[0])
        exclude = self._mergehistory(rev0, prefix0)
        results = include - exclude

      results = sorted(results, key=lambda x: x.rev, reverse=True)

    results = map(lambda x: self._logentry(x.rev, x.path), results)
    if merges is not None:
      if merges:
        results = filter(lambda x: len(x.parents) > 1, results)
      else:
        results = filter(lambda x: len(x.parents) <= 1, results)
    return list(results)

  def _logentry(self, rev, path, history=None):
    import hashlib
    revstr = str(rev)
    cmd = [SVNLOOK, 'info', '.', '-r', revstr]
    cachekey = hashlib.sha1(revstr.encode()).hexdigest()
    entry = self._commit_cache.get(cachekey)
    if entry:
      entry._cached = True
      return entry
    output = self._command(cmd).decode(self.encoding, 'replace')
    author, date, logsize, message = output.split('\n', 3)
    date = parse_isodate(date)
    if history is None:
      history = self._history(rev, path, 2)
    parents = []
    if len(history) > 1:
      prev = history[1].rev
      if path == '/':
        parents.append(prev)
      else:
        parents.append('%s:%d' % (path, prev))
      for head, minrev, maxrev in self._mergeinfo(rev, path):
        if prev < maxrev:
          h = self._history(maxrev, head, 1)
          if head == '/':
            parents.append(h[0].rev)
          else:
            parents.append('%s:%d' % (head, h[0].rev))
    entry = CommitLogEntry(rev, parents, date, author, message)
    if cachekey not in self._commit_cache:
      self._commit_cache[cachekey] = entry
    return entry

  def pdiff(self, rev):
    rev, prefix = self._maprev(rev)
    if rev == 0:
      return ''
    cmd = [SVNLOOK, 'diff', '.', '-r', str(rev)]
    output = self._command(cmd)
    _output = b''
    for line in output.splitlines(True):
      if line.startswith(b'--- '):
        line = b'--- a/' + line[4:]
      if line.startswith(b'+++ '):
        line = b'+++ b/' + line[4:]
      _output += line
    output = _output
    return output

  def diff(self, rev_a, rev_b, path=None):
    import os, shutil, tempfile
    rev_a, prefix_a = self._maprev(rev_a)
    rev_b, prefix_b = self._maprev(rev_b)
    tmpdir = tempfile.mkdtemp(prefix='anyvcs-svn-diff.')
    try:
      path_a = os.path.join(tmpdir, 'a')
      path_b = os.path.join(tmpdir, 'b')
      url_a = 'file://%s/%s@%d' % (self.path, prefix_a, rev_a)
      url_b = 'file://%s/%s@%d' % (self.path, prefix_b, rev_b)
      cmd = [SVN, 'export', '-q', url_a, path_a]
      subprocess.check_call(cmd)
      cmd = [SVN, 'export', '-q', url_b, path_b]
      subprocess.check_call(cmd)
      if path is None:
        cmd = [DIFF, '-urN', 'a', 'b']
      else:
        path = type(self).cleanPath(path)
        cmd = [DIFF, '-urN', 'a' + path, 'b' + path]
      p = subprocess.Popen(cmd, cwd=tmpdir, stdout=subprocess.PIPE)
      stdout, stderr = p.communicate()
      if p.returncode not in (0, 1):
        raise subprocess.CalledProcessError(p.returncode, cmd, stdout)
      return stdout
    finally:
      shutil.rmtree(tmpdir)

  def changed(self, rev):
    rev, prefix = self._maprev(rev)
    if rev == 0:
      return []
    cmd = [SVNLOOK, 'changed', '.', '-r', str(rev), '--copy-info']
    output = self._command(cmd).decode(self.encoding, 'replace')
    lines = output.splitlines()
    lines.reverse()
    results = []
    while lines:
      line = lines.pop()
      status = line[:3]
      path = line[4:].lstrip('/')
      copy = None
      if status.endswith('+'):
        line = lines.pop()
        m = changed_copy_info_rx.match(line)
        assert m
        copy = m.group('src')
      entry = FileChangeInfo(path, str(status), copy)
      results.append(entry)
    return results

  def _history(self, rev, path, limit=None):
    cmd = [SVNLOOK, 'history', '.', '-r', str(rev), path]
    if limit is not None:
      cmd.extend(['-l', str(limit)])
    output = self._command(cmd).decode(self.encoding, 'replace')
    results = []
    for line in output.splitlines()[2:]:
      r, p = line.split(None, 1)
      results.append(HistoryEntry(int(r), p))
    return results

  def _mergehistory(self, rev, path, limit=None):
    results = set(self._history(rev, path, limit))
    for head, minrev, maxrev in self._mergeinfo(rev, path):
      l = maxrev - minrev + 1
      if limit is not None:
        l = min(l, limit)
      h = self._history(maxrev, head, l)
      for r, p in h:
        if r < minrev:
          break
        results.add(HistoryEntry(r, p))
    return results

  def ancestor(self, rev1, rev2):
    rev1, prefix1 = self._maprev(rev1)
    rev2, prefix2 = self._maprev(rev2)
    prefix1 = type(self).cleanPath(prefix1)
    if prefix1 != '/':
      prefix1 = prefix1.rstrip('/')
    prefix2 = type(self).cleanPath(prefix2)
    if prefix2 != '/':
      prefix2 = prefix2.rstrip('/')

    self.ls(rev1, prefix1, directory=True)
    self.ls(rev2, prefix2, directory=True)

    minrev = min(rev1, rev2)
    if prefix1 == prefix2:
      return '%s:%d' % (prefix1, minrev)

    history1 = self._history(minrev, prefix1)
    history2 = self._history(minrev, prefix2)

    youngest = HistoryEntry(0, '/')

    for head, minrev, maxrev in self._mergeinfo(rev1, prefix1):
      for h in history2:
        if h.rev < minrev or h.rev < youngest.rev:
          break
        if h.path == head and minrev <= h.rev <= maxrev:
          youngest = h

    for head, minrev, maxrev in self._mergeinfo(rev2, prefix2):
      for h in history1:
        if h.rev < minrev or h.rev < youngest.rev:
          break
        if h.path == head and minrev <= h.rev <= maxrev:
          youngest = h

    if youngest.rev > 0:
      return '%s:%d' % (youngest.path, youngest.rev)

    i1 = 0
    i2 = 0
    len1 = len(history1)
    len2 = len(history2)
    while i1 < len1 and i2 < len2:
      if history1[i1].rev < history2[i2].rev:
        i2 += 1
      elif history1[i1].rev > history2[i2].rev:
        i1 += 1
      else:
        if history1[i1].path == history2[i2].path:
          return '%s:%d' % (history1[i1].path, history1[i1].rev)
        else:
          i1 += 1
          i2 += 1

    return None

  def _blame(self, rev, path):
    import os
    import xml.etree.ElementTree as ET
    url = 'file://' + os.path.abspath(self.path) + path
    cmd = [SVN, 'blame', '--xml', '-r', rev, url]
    output = self._command(cmd)
    tree = ET.fromstring(output)
    results = []
    cat = self._cat(rev, path)
    target = tree.find('target')
    try:
      iter = target.iter('entry')
    except AttributeError: # added in python 2.7
      iter = target.getiterator('entry')
    for entry, text in zip(iter, cat.splitlines()):
      commit = entry.find('commit')
      rev = int(commit.attrib.get('revision'))
      author = commit.find('author').text
      date = commit.find('date').text
      date = parse_isodate(date)
      results.append(BlameInfo(rev, author, date, text))
    return results

  def blame(self, rev, path):
    rev, prefix = self._maprev(rev)
    path = type(self).cleanPath(prefix + path)
    ls = self.ls(rev, path, directory=True)
    assert len(ls) == 1
    if ls[0].get('type') != 'f':
      raise BadFileType(rev, path)
    return self._blame(str(rev), path)

  def dump(self, stream, progress=None, lower=None, upper=None,
           incremental=False, deltas=False):
    """Dump the repository to a dumpfile stream.

    :param stream: A file stream to which the dumpfile is written
    :param progress: A file stream to which progress is written
    :param lower: Must be a numeric version number
    :param upper: Must be a numeric version number

    See ``svnadmin help dump`` for details on the other arguments.

    """
    cmd = [SVNADMIN, 'dump', '.']
    if progress is None:
      cmd.append('-q')
    if lower is not None:
      cmd.append('-r')
      if upper is None:
        cmd.append(str(int(lower)))
      else:
        cmd.append('%d:%d' % (int(lower), int(upper)))
    if incremental:
      cmd.append('--incremental')
    if deltas:
      cmd.append('--deltas')
    p = subprocess.Popen(cmd, cwd=self.path, stdout=stream, stderr=progress)
    p.wait()
    if p.returncode != 0:
      raise subprocess.CalledProcessError(p.returncode, cmd)

  def load(self, stream, progress=None, ignore_uuid=False, force_uuid=False,
           use_pre_commit_hook=False, use_post_commit_hook=False,
           parent_dir=None):
    """Load a dumpfile stream into the repository.

    :param stream: A file stream from which the dumpfile is read
    :param progress: A file stream to which progress is written

    See ``svnadmin help load`` for details on the other arguments.

    """
    cmd = [SVNADMIN, 'load', '.']
    if progress is None:
      cmd.append('-q')
    if ignore_uuid:
      cmd.append('--ignore-uuid')
    if force_uuid:
      cmd.append('--force-uuid')
    if use_pre_commit_hook:
      cmd.append('--use-pre-commit-hook')
    if use_post_commit_hook:
      cmd.append('--use-post-commit-hook')
    if parent_dir:
      cmd.extend(['--parent-dir', parent_dir])
    p = subprocess.Popen(cmd, cwd=self.path, stdin=stream,
                         stdout=progress, stderr=subprocess.PIPE)
    stderr = p.stderr.read()
    p.wait()
    if p.returncode != 0:
      raise subprocess.CalledProcessError(p.returncode, cmd, stderr)
