# -*- coding: utf-8 -*-

# Copyright 2009 Jaap Karssenberg <pardus@cpan.org>

'''This module contains utilities to work with config files. It also supports looking up
files according to the Freedesktop.org (XDG) Base Dir specification.
'''

# TODO: remove all mention of ConfigList if it is not being used anymore

import sys
import os
import re
import logging
import types

if sys.version_info >= (2, 6):
	import json # in standard lib since 2.6
else:
	import simplejson as json # extra dependency

from zim.fs import isfile, isdir, Dir, FileNotFoundError
from zim.errors import Error
from zim.parsing import TextBuffer, split_quoted_strings


logger = logging.getLogger('zim.config')


if os.name == 'nt':
	# Windows specific environment variables
	# os.environ does not support setdefault() ...
	if not 'USER' in os.environ or not os.environ['USER']:
		os.environ['USER'] =  os.environ['USERNAME']

	if not 'HOME' in os.environ or not os.environ['HOME']:
		if 'USERPROFILE' in os.environ:
			os.environ['HOME'] = os.environ['USERPROFILE']
		elif 'HOMEDRIVE' in os.environ and 'HOMEPATH' in os.environ:
			home = os.environ['HOMEDRIVE'] + os.environ['HOMEPATH']
			os.environ['HOME'] = home

#assert isdir(os.environ['HOME']), \
#	'ERROR: environment variable $HOME not set correctly'

if not 'USER' in os.environ or not os.environ['USER']:
	# E.g. Maemo doesn't define $USER
	os.environ['USER'] = os.path.basename(os.environ['HOME'])
	logger.info('Environment variable $USER was not set')


ZIM_DATA_DIR = None
XDG_DATA_HOME = None
XDG_DATA_DIRS = None
XDG_CONFIG_HOME = None
XDG_CONFIG_DIRS = None
XDG_CACHE_HOME = None

def _set_basedirs():
	'''This method sets the global configuration paths for according to the
	freedesktop basedir specification.
	'''
	global ZIM_DATA_DIR
	global XDG_DATA_HOME
	global XDG_DATA_DIRS
	global XDG_CONFIG_HOME
	global XDG_CONFIG_DIRS
	global XDG_CACHE_HOME

	# Detect if we are running from the source dir
	if isfile('./zim.py'):
		scriptdir = '.' # maybe running module in test / debug
	else:
		scriptdir = os.path.dirname(os.path.abspath(sys.argv[0]))
	zim_data_dir = Dir(scriptdir + '/data')
	if zim_data_dir.exists():
		ZIM_DATA_DIR = zim_data_dir
	else:
		ZIM_DATA_DIR = None

	if 'XDG_DATA_HOME' in os.environ:
		XDG_DATA_HOME = Dir(os.environ['XDG_DATA_HOME'])
	else:
		XDG_DATA_HOME = Dir('~/.local/share/')

	if 'XDG_DATA_DIRS' in os.environ:
		XDG_DATA_DIRS = map(Dir, os.environ['XDG_DATA_DIRS'].split(':'))
	else:
		XDG_DATA_DIRS = map(Dir, ('/usr/share/', '/usr/local/share/'))

	if 'XDG_CONFIG_HOME' in os.environ:
		XDG_CONFIG_HOME = Dir(os.environ['XDG_CONFIG_HOME'])
	else:
		XDG_CONFIG_HOME = Dir('~/.config/')

	if 'XDG_CONFIG_DIRS' in os.environ:
		XDG_CONFIG_DIRS = map(Dir, os.environ['XDG_CONFIG_DIRS'].split(':'))
	else:
		XDG_CONFIG_DIRS = [Dir('/etc/xdg/')]

	if 'XDG_CACHE_HOME' in os.environ:
		XDG_CACHE_HOME = Dir(os.environ['XDG_CACHE_HOME'])
	else:
		XDG_CACHE_HOME = Dir('~/.cache')

# Call on module initialization to set defaults
_set_basedirs()

def log_basedirs():
	'''Put the basedirs in use into logging'''
	if ZIM_DATA_DIR:
		logger.debug('Running from a source dir: %s', ZIM_DATA_DIR.dir)
	else:
		logger.debug('Not running from a source dir')
	logger.debug('Set XDG_DATA_HOME to %s', XDG_DATA_HOME)
	logger.debug('Set XDG_DATA_DIRS to %s', XDG_DATA_DIRS)
	logger.debug('Set XDG_CONFIG_HOME to %s', XDG_CONFIG_HOME)
	logger.debug('Set XDG_CONFIG_DIRS to %s', XDG_CONFIG_DIRS)
	logger.debug('Set XDG_CACHE_HOME to %s', XDG_CACHE_HOME)


def data_dirs(path=None):
	'''Generator for paths that contain zim data files. These will be the
	equivalent of e.g. /usr/share/zim, /usr/local/share/zim etc..
	'''
	zimpath = ['zim']
	if path:
		if isinstance(path, basestring):
			path = [path]
		assert not path[0] == 'zim'
		zimpath.extend(path)

	yield XDG_DATA_HOME.subdir(zimpath)

	if ZIM_DATA_DIR:
		if path:
			yield ZIM_DATA_DIR.subdir(path)
		else:
			yield ZIM_DATA_DIR

	for dir in XDG_DATA_DIRS:
		yield dir.subdir(zimpath)

def data_dir(path):
	'''Takes a path relative to the zim data dir and returns the first subdir
	found doing a lookup over all data dirs.
	'''
	for dir in data_dirs(path):
		if dir.exists():
			return dir
	else:
		return None

def data_file(path):
	'''Takes a path relative to the zim data dir and returns the first file
	found doing a lookup over all data dirs.
	'''
	for dir in data_dirs():
		file = dir.file(path)
		if file.exists():
			return file
	else:
		return None

def config_dirs():
	'''Generator that first yields the equivalent of ~/.config/zim and
	/etc/xdg/zim and then continous with the data dirs. Zim is not strictly
	XDG conformant by installing default config files in /usr/share/zim instead
	of in /etc/xdg/zim. Therefore this function yields both.
	'''
	yield XDG_CONFIG_HOME.subdir(('zim'))
	for dir in XDG_CONFIG_DIRS:
		yield dir.subdir(('zim'))
	for dir in data_dirs():
		yield dir

def config_file(path, klass=None):
	'''Takes a path relative to the zim config dir and returns a file equivalent
	to ~/.config/zim/path . Based on the file extension a ConfigDictFile object,
	a ConfigListFile object or a normal File object is returned. In the case a
	ConfigDictFile is returned the default is also set when needed.
	'''
	if isinstance(path, basestring):
		path = [path]
	zimpath = ['zim'] + list(path)
	file = XDG_CONFIG_HOME.file(zimpath)

	if not file.exists():
		for dir in config_dirs():
			default = dir.file(path)
			if default.exists():
				break
		else:
			default = None
	else:
		default = None

	if klass:
		return klass(file, default=default)
	elif path[-1].endswith('.conf'):
		return ConfigDictFile(file, default=default)
	else:
		return TextConfigFile(file, default=default)

def user_dirs():
	'''Returns a dict with directories for the xdg user dirs'''
	dirs = {}
	file = XDG_CONFIG_HOME.file('user-dirs.dirs')
	try:
		for line in file.readlines():
			line = line.strip()
			if line.isspace() or line.startswith('#'):
				continue
			else:
				try:
					assert '=' in line
					key, value = line.split('=', 1)
					value = os.path.expandvars(value.strip('"'))
					dirs[key] = Dir(value)
				except:
					logger.exception('Exception while parsing %s', file)
	except FileNotFoundError:
		pass
	return dirs


def value_check_class(value, default):
	'''Check function for setdefault() which implements the default
	behavior by checking classes of the value and the default.
	No need to specify this function directly as it is the default.
	'''
	klass = default.__class__

	if issubclass(klass, basestring):
		klass = basestring
		if not value: # Special case to avoid empty string
			return default

	if klass is tuple and isinstance(value, list):
		return tuple(value)
	elif not isinstance(value, klass):
		return default
	else:
		return value


def value_allow_empty(value, default):
	'''Check function for setdefault() which follows the default
	behavior checking classes, but allows the configured value to
	be empty.
	'''
	# Same as value_check_class() but without special case for basestring
	klass = default.__class__

	if issubclass(klass, basestring):
		klass = basestring

	if klass is tuple and isinstance(value, list):
		return tuple(value)
	elif not isinstance(value, klass):
		return default
	else:
		return value


def value_is_coord(value, default):
	'''Check function for setdefault() which enforces a coordinate
	(a tuple or list of 2 ints).
	'''
	if isinstance(value, list):
		value = tuple(value)

	if (
		isinstance(value, tuple)
		and len(value) == 2
		and isinstance(value[0], int)
		and isinstance(value[1], int)
	):
		return value
	else:
		return default


class ListDict(dict):
	'''Class that behaves like a dict but keeps items in same order.
	Used as base class for e.g. for config objects were writing should be
	in a predictable order.
	'''

	def __init__(self):
		self.order = []
		self._modified = False

	@property
	def modified(self):
		'''Recursive property'''
		if self._modified:
			return True
		else:
			return any(v.modified for v in self.values()
									if isinstance(v, ListDict))

	def set_modified(self, modified):
		if modified:
			self._modified = True
		else:
			self._modified = False
			for v in self.values():
				if isinstance(v, ListDict):
					v.set_modified(False)

	def update(D, E=None, **F):
		if E and hasattr(E, 'keys'):
			for k in E: D[k] = E[k]
		elif E:
			for (k, v) in E: D[k] = v
		for k in F: D[k] = F[k]

	def __setitem__(self, k, v):
		dict.__setitem__(self, k, v)
		self._modified = True
		if not k in self.order:
			self.order.append(k)

	def __delitem__(self, k):
		dict.__delitem__(self, k)
		self.order.remove(k)

	def pop(self, k):
		v = dict.pop(self, k)
		self.order.remove(k)

	# Would expect that setdefault() triggers __setitem__
	# but this seems not to be the case in the standard implementation
	# And we added some extra functionality here
	def setdefault(self, key, default, check=None):
		'''Like dict.setdefault() but with some extra restriction
		because we assume un-safe user input. If no extra arguments
		are given it will compare the classes of the set value and the
		default to ensure we get what we expect. An exception is made
		when value is None, in that case it is good practise to always
		specify a class or check function. When the default is a string
		we check the value to be an instance of basestring (ignoring
		difference between str and unicode). Another special case is when
		the default is a tuple and the value is a list, in this case the
		value will be cast to a tuple.

		If 'check' is given and is a class the existing value will be
		checked to be of that class and reset to default if it is not.
		Same spacial case for tuples applies here.

		If 'check' is given and it is a function it will be used to
		check the value in the dictionary if it exists. The check
		function should have two arguments, first is the value to be
		tested, the second is the default value given to setdefault().
		The return value will be used in the config dict.

		If 'check' is given and is a set the value will be tested
		against this set.
		'''
		if not key in self:
			self.__setitem__(key, default)
			return self[key]

		if check is None:
			assert not default is None, 'Bad practise to set default to None without check'
			check = value_check_class

		if isinstance(check, (type, types.ClassType)): # is a class
			klass = check
			if not isinstance(self[key], klass):
				if klass is tuple and isinstance(self[key], list):
					# Special case because json does not know difference list or tuple
					modified = self.modified
					self.__setitem__(key, tuple(self[key]))
					self.set_modified(modified) # don't change modified state
				else:
					logger.warn(
						'Invalid config value for %s: "%s" - should be of type %s',
						key, self[key], klass)
					self.__setitem__(key, default)
			else:
				pass # value is OK
		elif isinstance(check, set):
			if not self[key] in check:
				logger.warn(
						'Invalid config value for %s: "%s" - should be one of %s',
						key, self[key], unicode(check))
				self.__setitem__(key, default)
			else:
				pass # value is OK
		else: # assume callable
			v = check(self[key], default)
			if isinstance(v, tuple) and isinstance(self[key], list) \
			and v == tuple(self[key]):
				# Special case because json does not know difference list or tuple
				modified = self.modified
				self.__setitem__(key, v)
				self.set_modified(modified) # don't change modified state
			elif v != self[key]:
				logger.warn(
					'Invalid config value for %s: "%s"',
					key, self[key])
				self.__setitem__(key, v)

		return self[key]

	def keys(self):
		return self.order[:]

	def items(self):
		return map(lambda k: (k, self[k]), self.order)

	def set_order(self, order):
		'''Change the order in which items are listed by setting a list
		of keys. Keys not in the list are moved to the end. Keys that are in
		the list but not in the dict will be ignored.
		'''
		order = order[:] # copy
		oldorder = set(self.order)
		neworder = set(order)
		for k in neworder - oldorder: # keys not in the dict
			order.remove(k)
		for k in oldorder - neworder: # keys not in the list
			order.append(k)
		neworder = set(order)
		assert neworder == oldorder
		self.order = order


class ConfigDict(ListDict):
	'''Config object which wraps a dict of dicts.
	These are represented as INI files where each sub-dict is a section.
	Sections are auto-vivicated when getting a non-existing key.
	Each section is in turn a ListDict.
	'''

	def __getitem__(self, k):
		if not k in self:
			self[k] = ListDict()
		return dict.__getitem__(self, k)

	def parse(self, text):
		if isinstance(text, basestring):
			text = text.splitlines(True)
		section = None
		for line in text:
			line = line.strip()
			if not line or line.startswith('#'):
				continue
			elif line.startswith('[') and line.endswith(']'):
				name = line[1:-1].strip()
				section = self[name]
			elif '=' in line:
				parameter, value = line.split('=', 1)
				parameter = str(parameter.rstrip()) # no unicode
				try:
					value = self._decode_value(value.lstrip())
					section[parameter] = value
				except:
					logger.warn('Failed to parse value for: %s', parameter)
			else:
				logger.warn('Could not parse line: %s', line)

	# Seperated out as this will be slightly different for .desktop files
	def _decode_value(self, value):
		if len(value) == 0:
			return ''
		if value == 'True': return True
		elif value == 'False': return False
		elif value == 'None': return None
		elif value[0] in ('{', '['):
			return json.loads(value)
		else:
			try:
				value = int(value)
				return value
			except: pass

			try:
				value = float(value)
				return value
			except: pass

			return json.loads('"%s"' % value.replace('"', '\\"')) # force string

	def dump(self):
		lines = []
		for section, parameters in self.items():
			if parameters:
				lines.append('[%s]\n' % section)
				for param, value in parameters.items():
					lines.append('%s=%s\n' % (param, self._encode_value(value)))
				lines.append('\n')
		return lines

	def _encode_value(self, value):
		if isinstance(value, basestring):
			return json.dumps(value)[1:-1] # get rid of quotes
		elif value is True: return 'True'
		elif value is False: return 'False'
		elif value is None: return 'None'
		elif isinstance(value, object) and value.__class__.__name__.endswith('Path'):
			# Hack to avoid importing Path here to test isinstance
			return value.name
		else:
			return json.dumps(value, separators=(',',':'))
				# specify separators for compact encoding


class ConfigFile(ListDict):
	'''Base class for ConfigDictFile and ConfigListFile, can not be
	instantiated on its own.
	'''

	def __init__(self, file, default=None):
		ListDict.__init__(self)
		self.file = file
		self.default = default
		try:
			self.read()
			self.set_modified(False)
		except FileNotFoundError:
			pass

	def read(self):
		# TODO: flush dict first ?
		try:
			logger.debug('Loading %s', self.file.path)
			self.parse(self.file.readlines())
		except FileNotFoundError:
			if self.default:
				logger.debug('File not found, loading %s', self.default.path)
				self.parse(self.default.readlines())
			else:
				raise

	def write(self):
		self.file.writelines(self.dump())
		self.set_modified(False)

	def write_async(self):
		operation = self.file.writelines_async(self.dump())
		# TODO do we need async error handling here ?
		self.set_modified(False)
		return operation


class ConfigDictFile(ConfigFile, ConfigDict):
	pass


class TextConfigFile(list):
	'''Like ConfigFile, but just represents a list of lines'''

	# TODO think of a way of uniting this class with ConfigFile

	def __init__(self, file, default=None):
		self.file = file
		self.default = default
		try:
			self.read()
		except FileNotFoundError:
			pass

	def read(self):
		# TODO: flush list first ?
		try:
			self[:] = self.file.readlines()
		except FileNotFoundError:
			if self.default:
				self[:] = self.default.readlines()
			else:
				raise

	def write(self):
		self.file.writelines(self)


class HeaderParsingError(Error):

	description = '''\
Invalid data was found in a block with headers.
This probably means the header block was corrupted
and can not be read correctly.'''

	def __init__(self, line):
		self.msg = 'Invalid header >>%s<<' % line.strip('\n')


class HeadersDict(ListDict):
	'''This class maps a set of headers in the rfc822 format.

	Header names are always kept in "title()" format to ensure
	case-insensitivity.
	'''

	_is_header_re = re.compile('^([\w\-]+):\s+(.*)')
	_is_continue_re = re.compile('^(\s+)(?=\S)')

	def __init__(self, text=None):
		ListDict.__init__(self)
		if not text is None:
			self.parse(text)

	def __getitem__(self, k):
		return ListDict.__getitem__(self, k.title())

	def __setitem__(self, k, v):
		return ListDict.__setitem__(self, k.title(), v)

	def read(self, lines):
		'''Checks for headers at the start of the list of lines and if any
		reads them into the dict until the first empty line. Will shift any
		lines belonging to the header block, so after this method returns the
		input does no longer contain the header block.
		'''
		self._parse(lines, fatal=False)
		if lines and lines[0].isspace():
			lines.pop(0)

	def parse(self, text):
		'''Adds headers defined in 'text' to the dict. Text can either be
		a string or a list of lines.

		Raises a HeaderParsingError when 'text' is not a valid header block.
		Trailing whitespace is ignored.
		'''
		if isinstance(text, basestring):
			lines = text.rstrip().splitlines(True)
		else:
			lines = text[:] # make copy so we do not destry the original
		self._parse(lines)

	def _parse(self, lines, fatal=True):
		header = None
		while lines:
			is_header = self._is_header_re.match(lines[0])
			if is_header:
				header = is_header.group(1)
				value  = is_header.group(2)
				self[header] = value.strip()
			elif self._is_continue_re.match(lines[0]) and not header is None:
				self[header] += '\n' + lines[0].strip()
			else:
				if fatal:
					raise HeaderParsingError, lines[0]
				else:
					break
			lines.pop(0)

	def dump(self, strict=False):
		'''Returns the dict as a list of lines defining a rfc822 header block.

		If 'strict' is set to True lines will be properly terminated
		with '\r\n' instead of '\n'.
		'''
		buffer = []
		for k, v in self.items():
			v = v.strip().replace('\n', '\n\t')
			buffer.extend((k, ': ', v, '\n'))
		text = ''.join(buffer)

		if strict:
			text = text.replace('\n', '\r\n')

		return text.splitlines(True)


class HierarchicDict(object):
	'''Dict which considers keys to be hierarchic (separator is ':' for
	obvious reasons). Each key gives a dict which shows shadows of all
	parents in the hierarchy. This is specifically used to store
	namespace properties for zim notebooks.
	'''

	__slots__ = ('dict')

	def __init__(self):
		self.dict = {}

	def __getitem__(self, k):
		if not isinstance(k, basestring):
			k = k.name # assume zim path
		return HierarchicDictFrame(self.dict, k)


class HierarchicDictFrame(object):

	__slots__ = ('dict', 'key')

	def __init__(self, dict, key):
		self.dict = dict
		self.key = key

	def _keys(self):
		yield self.key
		parts = self.key.split(':')
		parts.pop()
		while parts:
			yield ':'.join(parts)
			parts.pop()
		yield '' # top level namespace

	def get(self, k, default=None):
		try:
			v = self.__getitem__(k)
		except KeyError:
			return default
		else:
			return v

	def __getitem__(self, k):
		for key in self._keys():
			if key in self.dict and k in self.dict[key]:
				return self.dict[key][k]
		else:
			raise KeyError

	def __setitem__(self, k, v):
		if not self.key in self.dict:
			self.dict[self.key] = {}
		self.dict[self.key][k] = v

	def remove(self, k):
		if self.key in self.dict and k in self.dict[self.key]:
			return self.dict[self.key].pop(k)
		else:
			raise KeyError
