# commit.py
# Copyright (C) 2008, 2009 Michael Trier (mtrier@gmail.com) and contributors
#
# This module is part of GitPython and is released under
# the BSD License: http://www.opensource.org/licenses/bsd-license.php
import base

from git.typ import ObjectType
from tree import Tree
from cStringIO import StringIO

from git.util import (
						hex_to_bin,
						Actor,
						RepoAliasMixin,
						Iterable,
						Actor,
						Stats
						)

from util import (
					Traversable,
					Serializable,
					altz_to_utctz_str,
					parse_actor_and_date
				)
from git.diff import Diffable
from git.base import IStream
from cStringIO import StringIO

from util import parse_date
from time import altzone, time

import os
import sys

__all__ = ('Commit', )

class Commit(Diffable, Iterable, RepoAliasMixin, base.Object, Traversable, Serializable):
	"""Wraps a git Commit object.
	
	This class will act lazily on some of its attributes and will query the 
	value on demand only if it involves calling the git binary."""
	__slots__ = tuple()
	
	# ENVIRONMENT VARIABLES
	# read when creating new commits
	env_author_date = "GIT_AUTHOR_DATE"
	env_committer_date = "GIT_COMMITTER_DATE"
	
	# CONFIGURATION KEYS
	conf_encoding = 'i18n.commitencoding'
	
	# INVARIANTS
	default_encoding = "UTF-8"
	
	
	# object configuration 
	type = ObjectType.commit
	type_id = ObjectType.commit_id
	
	__slots__ = ("tree",
				 "author", "authored_date", "author_tz_offset",
				 "committer", "committed_date", "committer_tz_offset",
				 "message", "parents", "encoding")
	_id_attribute_ = "binsha"
	
	
	def count(self, paths='', **kwargs):
		"""Count the number of commits reachable from this commit

		:param paths:
			is an optinal path or a list of paths restricting the return value 
			to commits actually containing the paths

		:param kwargs:
			Additional options to be passed to git-rev-list. They must not alter
			the ouput style of the command, or parsing will yield incorrect results
		:return: int defining the number of reachable commits"""
		# yes, it makes a difference whether empty paths are given or not in our case
		# as the empty paths version will ignore merge commits for some reason.
		if paths:
			return len(self.repo.git.rev_list(self.hexsha, '--', paths, **kwargs).splitlines())
		else:
			return len(self.repo.git.rev_list(self.hexsha, **kwargs).splitlines())
		

	@property
	def name_rev(self):
		"""
		:return:
			String describing the commits hex sha based on the closest Reference.
			Mostly useful for UI purposes"""
		return self.repo.git.name_rev(self)

	@classmethod
	def iter_items(cls, repo, rev, paths='', **kwargs):
		"""Find all commits matching the given criteria.

		:param repo: is the Repo
		:param rev: revision specifier, see git-rev-parse for viable options
		:param paths:
			is an optinal path or list of paths, if set only Commits that include the path 
			or paths will be considered
		:param kwargs:
			optional keyword arguments to git rev-list where
			``max_count`` is the maximum number of commits to fetch
			``skip`` is the number of commits to skip
			``since`` all commits since i.e. '1970-01-01'
		:return: iterator yielding Commit items"""
		if 'pretty' in kwargs:
			raise ValueError("--pretty cannot be used as parsing expects single sha's only")
		# END handle pretty
		args = list()
		if paths:
			args.extend(('--', paths))
		# END if paths

		proc = repo.git.rev_list(rev, args, as_process=True, **kwargs)
		return cls._iter_from_process_or_stream(repo, proc)
		
	def iter_parents(self, paths='', **kwargs):
		"""Iterate _all_ parents of this commit.
		
		:param paths:
			Optional path or list of paths limiting the Commits to those that 
			contain at least one of the paths
		:param kwargs: All arguments allowed by git-rev-list
		:return: Iterator yielding Commit objects which are parents of self """
		# skip ourselves
		skip = kwargs.get("skip", 1)
		if skip == 0:	# skip ourselves 
			skip = 1
		kwargs['skip'] = skip
		
		return self.iter_items(self.repo, self, paths, **kwargs)

	@property
	def stats(self):
		"""Create a git stat from changes between this commit and its first parent 
		or from all changes done if this is the very first commit.
		
		:return: git.Stats"""
		if not self.parents:
			text = self.repo.git.diff_tree(self.hexsha, '--', numstat=True, root=True)
			text2 = ""
			for line in text.splitlines()[1:]:
				(insertions, deletions, filename) = line.split("\t")
				text2 += "%s\t%s\t%s\n" % (insertions, deletions, filename)
			text = text2
		else:
			text = self.repo.git.diff(self.parents[0].hexsha, self.hexsha, '--', numstat=True)
		return Stats._list_from_string(self.repo, text)

		
	@classmethod
	def create_from_tree(cls, repo, tree, message, parent_commits=None, head=False):
		"""Commit the given tree, creating a commit object.
		
		:param repo: Repo object the commit should be part of 
		:param tree: Tree object or hex or bin sha 
			the tree of the new commit
		:param message: Commit message. It may be an empty string if no message is provided.
			It will be converted to a string in any case.
		:param parent_commits:
			Optional Commit objects to use as parents for the new commit.
			If empty list, the commit will have no parents at all and become 
			a root commit.
			If None , the current head commit will be the parent of the 
			new commit object
		:param head:
			If True, the HEAD will be advanced to the new commit automatically.
			Else the HEAD will remain pointing on the previous commit. This could 
			lead to undesired results when diffing files.
			
		:return: Commit object representing the new commit
			
		:note:
			Additional information about the committer and Author are taken from the
			environment or from the git configuration, see git-commit-tree for 
			more information"""
		parents = parent_commits
		if parent_commits is None:
			try:
				parent_commits = [ repo.head.commit ]
			except ValueError:
				# empty repositories have no head commit
				parent_commits = list()
			# END handle parent commits
		# END if parent commits are unset
		
		# retrieve all additional information, create a commit object, and 
		# serialize it
		# Generally: 
		# * Environment variables override configuration values
		# * Sensible defaults are set according to the git documentation
		
		# COMMITER AND AUTHOR INFO
		cr = repo.config_reader()
		env = os.environ
		
		committer = Actor.committer(cr)
		author = Actor.author(cr)
		
		# PARSE THE DATES
		unix_time = int(time())
		offset = altzone
		
		author_date_str = env.get(cls.env_author_date, '')
		if author_date_str:
			author_time, author_offset = parse_date(author_date_str)
		else:
			author_time, author_offset = unix_time, offset
		# END set author time
		
		committer_date_str = env.get(cls.env_committer_date, '')
		if committer_date_str: 
			committer_time, committer_offset = parse_date(committer_date_str)
		else:
			committer_time, committer_offset = unix_time, offset
		# END set committer time
		
		# assume utf8 encoding
		enc_section, enc_option = cls.conf_encoding.split('.')
		conf_encoding = cr.get_value(enc_section, enc_option, cls.default_encoding)
		
		
		# if the tree is no object, make sure we create one - otherwise
		# the created commit object is invalid
		if isinstance(tree, str):
			tree = repo.tree(tree)
		# END tree conversion
		
		# CREATE NEW COMMIT
		new_commit = cls(repo, cls.NULL_BIN_SHA, tree, 
						author, author_time, author_offset, 
						committer, committer_time, committer_offset,
						message, parent_commits, conf_encoding)
		
		stream = StringIO()
		new_commit._serialize(stream)
		streamlen = stream.tell()
		stream.seek(0)
		
		istream = repo.odb.store(IStream(cls.type, streamlen, stream))
		new_commit.binsha = istream.binsha
		
		if head:
			# need late import here, importing git at the very beginning throws
			# as well ... 
			import git.refs
			try:
				repo.head.set_commit(new_commit, logmsg="commit: %s" % message)
			except ValueError:
				# head is not yet set to the ref our HEAD points to
				# Happens on first commit
				import git.refs
				master = git.refs.Head.create(repo, repo.head.ref, new_commit, logmsg="commit (initial): %s" % message)
				repo.head.set_reference(master, logmsg='commit: Switching to %s' % master)
			# END handle empty repositories
		# END advance head handling 
		
		return new_commit
		
	def __init__(self, odb, binsha, tree=None, author=None, authored_date=None, author_tz_offset=None,
				 committer=None, committed_date=None, committer_tz_offset=None, 
				 message=None,  parents=None, encoding=None):
		"""Instantiate a new Commit. All keyword arguments taking None as default will 
		be implicitly set on first query. 
		
		:param binsha: 20 byte sha1
		:param parents: tuple( Commit, ... ) 
			is a tuple of commit ids or actual Commits
		:param tree: Tree
			Tree object
		:param author: Actor
			is the author string ( will be implicitly converted into an Actor object )
		:param authored_date: int_seconds_since_epoch
			is the authored DateTime - use time.gmtime() to convert it into a 
			different format
		:param author_tz_offset: int_seconds_west_of_utc
			is the timezone that the authored_date is in
		:param committer: Actor
			is the committer string
		:param committed_date: int_seconds_since_epoch
			is the committed DateTime - use time.gmtime() to convert it into a 
			different format
		:param committer_tz_offset: int_seconds_west_of_utc
			is the timezone that the authored_date is in
		:param message: string
			is the commit message
		:param encoding: string
			encoding of the message, defaults to UTF-8
		:param parents:
			List or tuple of Commit objects which are our parent(s) in the commit 
			dependency graph
		:return: git.Commit
		
		:note: Timezone information is in the same format and in the same sign 
			as what time.altzone returns. The sign is inverted compared to git's 
			UTC timezone."""
		super(Commit,self).__init__(odb, binsha)
		if tree is not None:
			assert isinstance(tree, Tree), "Tree needs to be a Tree instance, was %s" % type(tree)
		if tree is not None:
			self.tree = tree
		if author is not None:
			self.author = author
		if authored_date is not None:
			self.authored_date = authored_date
		if author_tz_offset is not None:
			self.author_tz_offset = author_tz_offset
		if committer is not None:
			self.committer = committer
		if committed_date is not None:
			self.committed_date = committed_date
		if committer_tz_offset is not None:
			self.committer_tz_offset = committer_tz_offset
		if message is not None:
			self.message = message
		if parents is not None:
			self.parents = parents
		if encoding is not None:
			self.encoding = encoding
		
	@classmethod
	def _get_intermediate_items(cls, commit):
		return commit.parents

	def _set_cache_(self, attr):
		if attr in Commit.__slots__:
			# read the data in a chunk, its faster - then provide a file wrapper
			binsha, typename, self.size, stream = self.odb.stream(self.binsha)
			self._deserialize(StringIO(stream.read()))
		else:
			super(Commit, self)._set_cache_(attr)
		# END handle attrs

	@property
	def summary(self):
		""":return: First line of the commit message"""
		return self.message.split('\n', 1)[0]
		
	@classmethod
	def _iter_from_process_or_stream(cls, odb, proc_or_stream):
		"""Parse out commit information into a list of Commit objects
		We expect one-line per commit, and parse the actual commit information directly
		from our lighting fast object database

		:param proc: git-rev-list process instance - one sha per line
		:return: iterator returning Commit objects"""
		stream = proc_or_stream
		if not hasattr(stream,'readline'):
			stream = proc_or_stream.stdout
			
		readline = stream.readline
		while True:
			line = readline()
			if not line:
				break
			hexsha = line.strip()
			if len(hexsha) > 40:
				# split additional information, as returned by bisect for instance
				hexsha, rest = line.split(None, 1)
			# END handle extra info
			
			assert len(hexsha) == 40, "Invalid line: %s" % hexsha
			yield cls(odb, hex_to_bin(hexsha))
		# END for each line in stream
	
	#{ Serializable Implementation
	
	def _serialize(self, stream):
		write = stream.write
		write("tree %s\n" % self.tree)
		for p in self.parents:
			write("parent %s\n" % p)
			
		a = self.author
		aname = a.name
		if isinstance(aname, unicode):
			aname = aname.encode(self.encoding)
		# END handle unicode in name
		
		c = self.committer
		fmt = "%s %s <%s> %s %s\n"
		write(fmt % ("author", aname, a.email, 
						self.authored_date, 
						altz_to_utctz_str(self.author_tz_offset)))
			
		# encode committer
		aname = c.name
		if isinstance(aname, unicode):
			aname = aname.encode(self.encoding)
		# END handle unicode in name
		write(fmt % ("committer", aname, c.email, 
						self.committed_date,
						altz_to_utctz_str(self.committer_tz_offset)))
		
		if self.encoding != self.default_encoding:
			write("encoding %s\n" % self.encoding)
		
		write("\n")
		
		# write plain bytes, be sure its encoded according to our encoding
		if isinstance(self.message, unicode):
			write(self.message.encode(self.encoding))
		else:
			write(self.message)
		# END handle encoding
		return self
	
	def _deserialize(self, stream):
		""":param from_rev_list: if true, the stream format is coming from the rev-list command
		Otherwise it is assumed to be a plain data stream from our object"""
		readline = stream.readline
		self.tree = Tree(self.odb, hex_to_bin(readline().split()[1]), Tree.tree_id<<12, '')

		self.parents = list()
		next_line = None
		while True:
			parent_line = readline()
			if not parent_line.startswith('parent'):
				next_line = parent_line
				break
			# END abort reading parents
			self.parents.append(type(self)(self.odb, hex_to_bin(parent_line.split()[-1])))
		# END for each parent line
		self.parents = tuple(self.parents)
		
		self.author, self.authored_date, self.author_tz_offset = parse_actor_and_date(next_line)
		self.committer, self.committed_date, self.committer_tz_offset = parse_actor_and_date(readline())
		
		
		# now we can have the encoding line, or an empty line followed by the optional
		# message.
		self.encoding = self.default_encoding
		# read encoding or empty line to separate message
		enc = readline()
		enc = enc.strip()
		if enc:
			self.encoding = enc[enc.find(' ')+1:]
			# now comes the message separator 
			readline()
		# END handle encoding
		
		# decode the authors name
		try:
			self.author.name = self.author.name.decode(self.encoding) 
		except UnicodeDecodeError:
			print >> sys.stderr, "Failed to decode author name '%s' using encoding %s" % (self.author.name, self.encoding)
		# END handle author's encoding
		
		# decode committer name
		try:
			self.committer.name = self.committer.name.decode(self.encoding) 
		except UnicodeDecodeError:
			print >> sys.stderr, "Failed to decode committer name '%s' using encoding %s" % (self.committer.name, self.encoding)
		# END handle author's encoding
		
		# a stream from our data simply gives us the plain message
		# The end of our message stream is marked with a newline that we strip
		self.message = stream.read()
		try:
			self.message = self.message.decode(self.encoding)
		except UnicodeDecodeError:
			print >> sys.stderr, "Failed to decode message '%s' using encoding %s" % (self.message, self.encoding)
		# END exception handling 
		return self
	
	#} END serializable implementation

