#!/usr/pkg/bin/python3.9
import sys, os
from collections import defaultdict 
from ..classifier import *
__all__ = ['TextInstance', 'InstanceSet', 'Analyzer']

if sys.version_info[0] >= 3:
	xrange = range
	izip = zip
else:
	from itertools import izip

class TextInstance:
	'''
	:class:`TextInstance` represents a text instance. It includes the index, 
	the true label, the predicted label, the text, and the decision values 
	of the text instance. Normally you do not directly create an instance. 
	Instead, it is usually manipulated by :class:`InstanceSet`. For 
	more information, please see the usage in :class:`InstanceSet`.
	'''

	def __init__(self, idx, true_y = '', predicted_y = '', text = '', extra_svm_feats = [], decvals = None):
		self.idx = idx #: Instance index in the text source.
		
		#: The true label (if provided in the text source in the prediction phase).
		self.true_y = true_y 
		
		#: The predicted label.
		self.predicted_y = predicted_y

		#: The original text. The value is an empty :class:`str` 
		#: (``''``) at the beginning. The value is filled after
		#: :func:`PredInst.load_text` is called.
		self.text = text
		#: The extra svm features. The value is an empty :class:`str`
		#: at the beginning. The value is filled after 
		#: :func:`PredInst.load_text` is called.
		self.extra_svm_feats = extra_svm_feats

		#: A :class:`list` of decision values. The length should be the
		#: number of classes.
		self.decvals = decvals

	def __str__(self):
		string = '''text = {text}
true label = {true_y}
predicted label = {predicted_y}
'''.format(text = self.text, true_y = self.true_y, predicted_y = self.predicted_y)
		if self.extra_svm_feats:
			string += 'extra svm features = {extra}\n'.format(extra = self.extra_svm_feats)
		return string
	
class InstanceSet:
	'''
	:class:`InstanceSet` is a group of :class:`TextInstance` instances. It is used to
	get a subset of interested data. It should be initialized with a prediction
	result file (and a testing data). By default, the path to the testing data
	is stored in the prediction result file so you can only give the path to
	prediction result file.

		>>> from libshorttext.analyzer import *
		>>> insts = InstanceSet('prediction_result_path')

	If you have moved testing data, then you must re-assign the path to testing
	data.

		>>> from libshorttext.analyzer import *
		>>> insts = InstanceSet('prediction_result_path', 'testing_data_path')
	'''

	def __init__(self, rst_src = None, text_src = None):
		self.insts = None
		self.correct = None
		self.filepath = None
		self.extra_svm_files = []
		self.true_labels = None
		self.predict_labels = None
		self.quantity = None
		self.selectors = []
		if rst_src is not None:
			self._load(rst_src, text_src)

	def __iter__(self):
		return iter(self.insts)

	def __getitem__(self, idx):
		return self.insts[idx]
	
	def select(self, *sel_funcs):
		'''
		This function helps users find interested data. The arguments 
		are `selector functions`, where both the argument and returned
		values are lists. There are several build-in selector functions.
		Refer to :ref:`selectorfunctions`.
		
		>>> from libshorttext.analyzer import *
		>>> insts = InstanceSet('prediction_result_path')
		>>> insts1 = insts.select(wrong, with_labels(['Books', 'Music'])) 
		'''
		### How to link to the section??
		insts = self.insts
		selectors = self.selectors[:]
		for sel_func in sel_funcs:
			insts = sel_func(insts)
			selectors.append(sel_func._libshorttext_msg or '')
		#if not insts:
		#	raise Exception("No instance selected.")
		sel_insts = InstanceSet()
		sel_insts.filepath = self.filepath
		sel_insts.extra_svm_files = self.extra_svm_files
		sel_insts.selectors = selectors
		sel_insts.insts = insts
		return sel_insts

	def load_text(self):
		'''
		The text of instances are not stored in the prediction result file,
		so you need to call this function to load texts from testing data.

		>>> from libshorttext.analyzer import *
		>>> insts = InstanceSet('prediction_result_path')
		>>> insts.load_text()

		This method also load the extra svm features if extra svm files
		are used when training.
		'''
		EMPTY_MESSAGE = '**None**'
		sorted_insts = sorted(self.insts, key = lambda inst: inst.idx)
		i = 0
		for idx, lines in enumerate(izip(*([open(self.filepath, 'r')] + [open(f, 'r') for f in self.extra_svm_files]))):
			line = lines[0]
			extra_svm_feats = lines[1:]
			nr_extra_svm_feats = len(extra_svm_feats)
			if idx > sorted_insts[-1].idx:
				break
			if idx == sorted_insts[i].idx:
				try:
					sorted_insts[i].text = line.split('\t',1)[1].strip()
				except:
					sorted_insts[i].text = EMPTY_MESSAGE

				sorted_insts[i].extra_svm_feats = [None] * nr_extra_svm_feats
				for j, extra_svm_feat in enumerate(extra_svm_feats):
					try:
						sorted_insts[i].extra_svm_feats[j] = dict(map(lambda t: (int(t[0]), float(t[1])), [feat.split(':') for feat in extra_svm_feat.split(None, 1)[1].split()]))
					except:
						sorted_insts[i].extra_svm_feats[j] = EMPTY_MESSAGE
				i += 1
			
	def _load(self, src, text_src):
		if isinstance(src, PredictionResult):
			pass
		elif isinstance(src, str):
			result = PredictionResult()
			result.load(src)
		else:
			raise Exception('"result" should be PredictionResult or string.')
	
		if not result.analyzable():
			raise ValueError('The given result is not analyzable.')
	
		# +++ Need to move to another place.			   
		#if self.model._hashcode != result.model_id:
		#	sys.stderr.write('Warning: model ID is different from that in the predicted result. Do you use a different model to analyze?\n')
	
		if text_src is None:
			self.filepath = result.text_src
		else:
			self.filepath = text_src
		self.extra_svm_files = result.extra_svm_files
		predicted_y = result.predicted_y
		self.acc = result.get_accuracy()
		decvals = result.decvals
		true_y = result.true_y
				   
		self.insts, self.true_labels, self.predict_labels = [], set(), set()
		for idx in range(len(true_y)):
			self.insts += [TextInstance(idx, true_y = true_y[idx], predicted_y = predicted_y[idx], decvals = list(decvals[idx]))]
			self.true_labels.add(true_y[idx])
			self.predict_labels.add(predicted_y[idx])
	
class Analyzer:
	'''
	:class:`Analyzer` is a tool for analyzing a group of instances, which is
	controlled by :class:`InstanceSet`. Typically :class:`Analyzer` is initialized
	with a path to a model.

		>>> from libshorttext.analyzer import *
		>>> analyzer = Analyzer('model_path')

	It can also be initialized with a :class:`libshorttext.classifier.TextModel`
	instance.
	
		>>> from libshorttext.analyzer import *
		>>> from libshorttext.classifier import *
		>>> text_model = TextModel('model_path')
		>>> analyzer = Analyzer(text_model)
		
	You can also construct an analyzer without a model. However,
	model-dependent functions cannot be used.

		>>> from libshorttext.analyzer import *
		>>> analyzer = Analyzer()
	'''
	
	def __init__(self, model = None):
		self.labels = None
		self.model = None
		if model is not None:
			self.load_model(model)
		
	def load_model(self, model):
		'''
		:func:`load_model` is used to load a model into
		:class:`Analyzer`. If you did not load a model in the constructor or if you
		would like to use another model, you can use this function.

		There are two ways to load a model: from an instance of
		:class:`libshorttext.classifier.TextModel` or a path to a model.

		>>> from libshorttext.analyzer import *
		>>> analyzer = Analyzer('original_model_path')
		>>> analyzer.load_model('new_model_path')
		'''
		
		if isinstance(model, TextModel):
			self.model = model
		elif isinstance(model, str):
			self.model = TextModel()
			self.model.load(model)
		else:
			raise Exception('"model" should be TextModel or string.')
		self.labels = self.model.get_labels()
		
	def analyze_single(self, target, amount = 5, output = None, extra_svm_feats = []):
		'''
		:func:`analyze_single` is used to analyze a single instance. It prints
		weights of all features in some classes (default 5). The output is
		sorted according to decision values in descending order. *target* can be an
		instance or a string that you want to analyze. *amount* is how many instances
		you want to print. If *output* is specified by a path to a file, the
		result will be outputted to the file instead of on the screen.

			>>> from libshorttext.analyzer import *
			>>> analyzer = Analyzer('model_path')
			>>> insts = InstanceSet('prediction_result_path')
			>>> insts.load_text()
			>>> analyzer.analyze_single(insts[61], 3)
			                    Jewelry & Watches  Cameras & Photo  Coins & Paper Money
			pb                          7.589e-19        2.041e-01            0.000e+00
			green                      -8.897e-02        1.227e-02           -1.507e-01
			mm                          5.922e-01        6.731e-01            1.256e-03
			onyx silver                 1.382e-01       -6.198e-02           -4.743e-19
			48                         -1.792e-02        2.188e-02           -1.346e-04
			pendant                     1.107e+00       -1.039e-01           -1.409e-01
			silver pendant              2.455e-01       -7.826e-02           -8.379e-02
			silver                      8.533e-01       -2.205e-02            8.076e-01
			onyx                        1.520e-01       -6.198e-02           -4.743e-19
			**decval**                  9.937e-01        1.944e-01            1.444e-01
			>>> analyzer.analyze_single('MICKEY MOUSE POT STAKE', 3)
			                Home & Garden  Video Games & Consoles  Computers/Tablets & Networking
			mickey              9.477e-02              -3.168e-02                       6.722e-02
			mouse               2.119e-01               2.039e-01                      -2.212e-02
			pot                 8.897e-01              -5.167e-02                      -2.466e-02
			stake               4.057e-01              -2.147e-02                      -3.699e-02
			mickey mouse        1.146e-01              -3.168e-02                       6.784e-02
			mouse pot           4.041e-01              -2.147e-02                      -1.588e-02
			pot stake           5.363e-01              -2.147e-02                      -1.588e-02
			**decval**          1.004e+00               9.255e-03                       7.385e-03


		If *target* is a :class:`str` and extra svm files are used in 
		training, the same number of extra svm features can be 
		specified in *extra_svm_feats*. Extra svm features should be 
		a list of dictionaries. If *target* is a :class:`TextInstance`,
		the extra features in the :class:`TextInstance` will be used.
		'''
		if self.model is None:
			raise Exception('Model not loaded.')
		if isinstance(target,str):
			text = target
			true_y = None
			result = predict_single_text(text, self.model, extra_svm_feats = extra_svm_feats)
			decvals = result.decvals
		elif isinstance(target,TextInstance):
			if target.text is None:
				raise Exception('Please load texts first.')
			text, extra_svm_feats, true_y = target.text, target.extra_svm_feats, target.true_y
			decvals = target.decvals
		if isinstance(output, str):
			output = open(output, 'w')

		features, weights, labels = self.model.get_weight(text, extra_svm_feats = extra_svm_feats)
		nr_labels = len(labels)
		nr_feats = len(features)
		if not features or not weights:
			raise Exception('Invalid instance.')
		features = [' '.join(feature) for feature in features]
		features += ['**decval**']
		weights_table = [[0]*nr_labels]*(nr_feats+1)
		sorted_idx = sorted(xrange(nr_labels), key=lambda i:decvals[i], reverse=True)
		labels = [labels[idx] for idx in sorted_idx]

		for feat in xrange(nr_feats):
			formatter = lambda idx: '{0:.3e}'.format(weights[feat][idx])
			weights_table[feat] = [formatter(idx) for idx in sorted_idx]
		weights_table[-1] = ['{0:.3e}'.format(decvals[idx]) for idx in sorted_idx]

		if amount != 0:
			labels = labels[:amount]
		draw_table(features, labels, weights_table, output)
		if true_y is not None:
			print('True label: {0}'.format(true_y))

	def _calculate_info(self, pred_insts):
		pred_insts.quantity = len(pred_insts.insts)
		pred_insts.true_labels, pred_insts.predict_labels, pred_insts.correct = \
			set(), set(), 0
		for inst in pred_insts.insts:
			pred_insts.true_labels.add(inst.true_y)
			pred_insts.predict_labels.add(inst.predicted_y)
			if inst.true_y == inst.predicted_y:
				pred_insts.correct += 1
		
	def info(self, pred_insts, output = None):
		'''
		:func:`info` gets information about a group of instances (an object
		of :class:`InstanceSet`). *pred_insts* is the target instances. If *output*
		is specified by a path to a file, the result will be outputted to the file
		instead of on the screen.

			>>> from libshorttext.analyzer import *
			>>> analyzer = Analyzer('model_path')
			>>> insts = InstanceSet('prediction_result_path')
			>>> insts = insts.select(with_labels(['Books', 'Music', 'Art']))
			>>> analyzer.info(insts)
			Number of instances: 554
			Accuracy: 0.983754512635 (545/554)
			True labels: "Art"  "Books"  "Music"
			Predict labels: "Art"  "Books"  "Music"
			Text source:
			/home/guestwalk/working/short_text/svn/software-dev/test_file
			Selectors:
			-> labels: "Books", "Music", "Art"
		'''
		if isinstance(output, str):
			output = open(output, 'w')
		if pred_insts.quantity is None:
			self._calculate_info(pred_insts)
		acc = float(pred_insts.correct)/pred_insts.quantity

		string = '''Number of instances: {quantity}
Accuracy: {acc} ({correct}/{quantity}) 
True labels: {true_y}
Predicted labels: {predicted_y}
Text source: {text_src}
Selectors: \n-> {selectors}'''\
			  .format(quantity = pred_insts.quantity, correct = pred_insts.correct,\
					  acc = acc, true_y = '"'+'"  "'.join(pred_insts.true_labels)+'"',\
					  predicted_y = '"'+'"  "'.join(pred_insts.predict_labels)+'"',\
					  text_src = os.path.abspath(pred_insts.filepath),\
					  selectors = '\n-> '.join(pred_insts.selectors))

		write(string, output)

	def gen_confusion_table(self, pred_insts, output = None):
		'''
		:func:`gen_confusion_table` generates a confusion table of a group of
		predicted instances *pred_insts*. If *output* is specified by a path 
		to a file, the result will be outputted to the file instead of  
		on the screen.

			>>> from libshorttext.analyzer import *
			>>> analyzer = Analyzer('model_path')
			>>> insts = InstanceSet('prediction_result_path')
			>>> insts = insts.select(with_labels(['Books', 'Music', 'Art']))
			>>> analyzer.gen_confusion_table(insts)
			         Books  Music  Art
			Books      169      1    0
			Music        2    214    0
			Art          6      0  162
		'''
		if isinstance(output, str):
			output = open(output, 'w')
		if pred_insts.quantity is None:
			self._calculate_info(pred_insts)
		labels = pred_insts.true_labels.union(pred_insts.predict_labels)
		#columns = rows
			
		invalid_labels = []
		for label in labels:
			if label not in pred_insts.true_labels and label not in pred_insts.predict_labels:
				invalid_labels.append(label)
		if invalid_labels:
			invalid_labels = ' '.join(invalid_labels)
			raise Exception('Labels {0} are invalid.'.format(invalid_labels))

		labels_dic = dict(zip(labels, xrange(len(labels))))
		confusion_table = [[0 for i in range(len(labels_dic))] for j in range(len(labels_dic))]
		for inst in pred_insts.insts:
			if inst.true_y in labels_dic and inst.predicted_y in labels_dic:
				confusion_table[labels_dic[inst.true_y]][labels_dic[inst.predicted_y]] += 1
		for idx_row, row in enumerate(confusion_table):
			for idx_col, col in enumerate(row):
				confusion_table[idx_row][idx_col] = str(confusion_table[idx_row][idx_col])

		draw_table(labels, labels, confusion_table, output)
		
		if output:
			output.close()
	
def write(string, output = None):
	if output is None:
		print(string)
	else:
		output.write(string + '\n')

		
def draw_table(rows, columns, table, output = None):
	offset = 2
	column_widths = []
	title_width = max([len(row) for row in rows]) + offset
		
	for col_idx, column in enumerate(columns):
		column_widths.append(max([len(table[row_idx][col_idx]) \
				for row_idx, row in enumerate(rows)] + [len(column)]) + offset)
		
	string = ''.ljust(title_width)
	for idx, column in enumerate(columns):
		string += column.rjust(column_widths[idx])
	write(string, output)

	for row_idx, row in enumerate(rows):
		string = row.ljust(title_width)
		for col_idx, column in enumerate(columns):
			string += table[row_idx][col_idx].rjust(column_widths[col_idx])
		write(string, output)
