#!/bin/env python3
# -*- coding: utf-8 -*-
# ----------------------------------------------------------------------
# Copyright (c) 2020
#
# See the LICENSE file for details
# see the AUTHORS file for authors
# ----------------------------------------------------------------------

#--------------------
# System wide imports
# -------------------

import sys
import sqlite3
import os.path
import glob
import logging
import csv
import datetime
import math
import hashlib
import time
import re
import collections
import traceback
import argparse
import errno

try:
    # Python 2
    import ConfigParser
except:
    import configparser as ConfigParser

# ---------------------
# Third party libraries
# ---------------------


#--------------
# Constants
# -------------

AZOTEA_BASE_DIR = os.path.join(os.path.expanduser("~"), "azotea")

DEF_DBASE      = os.path.join(AZOTEA_BASE_DIR, "dbase",  "azotea.db")
DEF_CONFIG     = os.path.join(AZOTEA_BASE_DIR, "config", "azotea.ini")
AZOTEA_CFG_DIR = os.path.join(AZOTEA_BASE_DIR, "config")

# -----------------------
# Module global variables
# -----------------------

log = logging.getLogger("fixit")

# ----------
# Exceptions
# ----------

class NoUserInfoError(Exception):
    '''Working Directory does not contain user configuration data.'''
    def __str__(self):
        s = self.__doc__
        if self.args:
            s = "{0} \n".format(s, self.args[0])
        s = '{0}.'.format(s)
        return s

class MixingCandidates(Exception):
    '''Images processed in different directories.'''
    def __str__(self):
        s = self.__doc__
        if self.args:
            s = "{0} \n".format(s, self.args[0])
        s = '{0}.'.format(s)
        return s

class ConfigError(ValueError):
    '''This camera model is not yet supported by AZOTEA'''
    def __str__(self):
        s = self.__doc__
        if self.args:
            s = "{0}: '{1}'".format(s, self.args[0])
        s = '{0}.'.format(s)
        return s

class MetadataError(ValueError):
    '''Error reading EXIF metadata for image'''
    def __str__(self):
        s = self.__doc__
        if self.args:
            s = "{0}: '{1}'".format(s, self.args[0])
        s = '{0}.'.format(s)
        return s

class TimestampError(ValueError):
    '''EXIF timestamp not supported by AZOTEA'''
    def __str__(self):
        s = self.__doc__
        if self.args:
            s = "{0}: '{1}'".format(s, self.args[0])
        s = '{0}.'.format(s)
        return s

class ROI(object):
    """ Region of interest  """

    PATTERN = r'\[(\d+):(\d+),(\d+):(\d+)\]'

    @classmethod
    def strproi(cls, roi_str):
        pattern = re.compile(ROI.PATTERN)
        matchobj = pattern.search(roi_str)
        if matchobj:
            x1 = int(matchobj.group(1))
            x2 = int(matchobj.group(2))
            y1 = int(matchobj.group(3))
            y2 = int(matchobj.group(4))
            return cls(x1,x2,y1,y2)
        else:
            return None


    def __init__(self, x1 ,x2, y1, y2):
        self.x1 = min(x1,x2)
        self.y1 = min(y1,y2)
        self.x2 = max(x1,x2)
        self.y2 = max(y1,y2)

    def dimensions(self):
        '''returns width and height'''
        return abs(self.x2 - self.x1), abs(self.y2 - self.y1)

    def __add__(self, point):
        return ROI(self.x1 + point.x, self.x2 + point.x, self.y1 + point.y, self.y2 + point.y)

    def __radd__(self, point):
        return self.__add__(point)
        
    def __repr__(self):
        return "[{0}:{1},{2}:{3}]".format(self.x1, self.x2, self.y1, self.y2)

# ------------------
# Auxiliar functions
# ------------------

def createParser():
	# create the top-level parser
	name = os.path.split(os.path.dirname(sys.argv[0]))[-1]
	parser = argparse.ArgumentParser(prog=name, description="AZOTEA hash fixing tool")
	# Global options
	parser.add_argument('--config', type=str, default=DEF_CONFIG, help='Optional alternate global configuration file')
	parser.add_argument('-w' ,'--work-dir',  type=str, required=True, help='Input working directory')
	parser.add_argument('-m' ,'--multiuser', default=False, action="store_true", help="Multi-user reduction pipeline flag")
	return parser

def merge_two_dicts(d1, d2):
    '''Valid for Python 2 & Python 3'''
    merged = d1.copy()   # start with d1 keys and values
    merged.update(d2)    # modifies merged with d2 keys and values & returns None
    return merged


def chop(string, sep=None):
    '''Chop a list of strings, separated by sep and 
    strips individual string items from leading and trailing blanks'''
    chopped = [ elem.strip() for elem in string.split(sep) ]
    if len(chopped) == 1 and chopped[0] == '':
        chopped = []
    return chopped

# ----------------------------
# Log file auxiliar functions
# ---------------------------

def configureLogging():
	level = logging.INFO
	log.setLevel(level)
	# Log formatter
	#fmt = logging.Formatter('%(asctime)s - %(name)s [%(levelname)s] %(message)s')
	fmt = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
	# create console handler and set level to debug
	ch = logging.StreamHandler()
	ch.setFormatter(fmt)
	ch.setLevel(level)
	log.addHandler(ch)
	
# ------------------------------
# Config file auxiliar functions
# ------------------------------

def valueOrNone(string, typ):
    return None if not len(string) else typ(string)

def valueOrDefault(string, typ, default):
    return default if not len(string) else typ(string)

def load_config_file(filepath):
    '''
    Load options from configuration file whose path is given
    Returns a dictionary
    '''
    if filepath is None or not (os.path.exists(filepath)):
        raise IOError(errno.ENOENT,"No such file or directory", filepath)

    parser  = ConfigParser.RawConfigParser()
    # str is for case sensitive options
    parser.optionxform = str
    parser.read(filepath)
    log.info("Opening configuration file %s", filepath)

    options = {}
    options['observer']      = parser.get("observer","observer")
    options['organization']  = parser.get("observer","organization")
    options['email']         = parser.get("observer","email")
    options['focal_length']  = parser.get("camera","focal_length")
    options['f_number']      = parser.get("camera","f_number")
    options['bias']          = parser.get("camera","bias")
    options['location']      = parser.get("location","location")
    
    x0 = parser.get("image","x0"); x0 = valueOrDefault(x0, int, 0)
    y0 = parser.get("image","y0"); y0 = valueOrDefault(y0, int, 0)
    width  = parser.getint("image","width")
    height = parser.getint("image","height")
    options['roi']           = ROI(x0, x0 + width, y0, y0 + height)

    options['scale']         = parser.get("image","scale")
    options['dark_roi']      = parser.get("image","dark_roi")
    options['filter']        = parser.get("file","filter")

    # Handle empty keyword cases and transform them to None's
    options['bias']          = valueOrNone(options['bias'], int)
    options['focal_length']  = valueOrNone(options['focal_length'], int)
    options['f_number']      = valueOrNone(options['f_number'], float)
    options['scale']         = valueOrNone(options['scale'], float)
    options['email']         = valueOrNone(options['email'], str)
    options['organization']  = valueOrNone(options['organization'], str)
    options['dark_roi']      = valueOrNone(options['dark_roi'], str)

    return options


def merge_options(cmdline_opts, file_opts):
    # Read the command line arguments and config file options
    cmdline_opts = vars(cmdline_opts) # command line options as dictionary
    cmdline_set  = set(cmdline_opts)
    fileopt_set  = set(file_opts)
    conflict_keys = fileopt_set.intersection(cmdline_set)
    options      = merge_two_dicts(file_opts, cmdline_opts)
    # Solve conflicts due to the fact that command line always sets 'None'
    # for missing aruments and take precedence over file opts
    # in the above dictionary merge
    for key in conflict_keys:
        if cmdline_opts[key] is None and file_opts[key] is not None:
            options[key] = file_opts[key]
    options  = argparse.Namespace(**options)
    return options


# ---------------------------
# Database auxiliar functions
# ---------------------------

def open_database(dbase_path):
    if not os.path.exists(dbase_path):
        with open(dbase_path, 'w') as f:
            pass
        log.info("Created database file {0}".format(dbase_path))
    else:
    	log.info("Opened database file {0}".format(dbase_path))
    return sqlite3.connect(dbase_path)


def work_dir_cleanup(connection):
	cursor = connection.cursor()
	cursor.execute("DROP TABLE IF EXISTS candidate_t")
	connection.commit()


def md5_hash(filepath):
	'''Compute a hash from the image'''
	BLOCK_SIZE = 1048576 # 1MByte, the size of each read from the file
	file_hash = hashlib.md5()
	with open(filepath, 'rb') as f:
		block = f.read(BLOCK_SIZE) 
		while len(block) > 0:
			file_hash.update(block)
			block = f.read(BLOCK_SIZE)
	return file_hash.digest()

def blake_hash(filepath):
	'''Compute a hash from the image'''
	BLOCK_SIZE = 1048576 # 1MByte, the size of each read from the file
	file_hash = hashlib.blake2b(digest_size=32)
	with open(filepath, 'rb') as f:
		block = f.read(BLOCK_SIZE) 
		while len(block) > 0:
			file_hash.update(block)
			block = f.read(BLOCK_SIZE)
	return file_hash.digest()


# ----------------
# Driver functions
# ----------------


def scan_work_dir(connection, work_dir, filt):
	file_list  = glob.glob(os.path.join(work_dir, filt))
	log.info("Found {0} candidates matching filter {1}.".format(len(file_list), filt))
	log.info("Computing hashes. This may take a while")
	names_hashes_list = [ {'name': os.path.basename(p), 'old_hash': md5_hash(p), 'new_hash': blake_hash(p)} for p in file_list ]
	cursor = connection.cursor()
	cursor.executemany(
		'''
		UPDATE image_t
		SET hash = :new_hash
		WHERE hash = :old_hash
		''', names_hashes_list)
	connection.commit()
	log.info("Substituted MD5 hash in database with Blake2 hash,")


def do_image_reduce(connection, options):
	log.info("#"*48)
	tmp  = os.path.basename(options.work_dir)
	if tmp == '':
		options.work_dir = options.work_dir[:-1]
	log.info("Working Directory: %s", options.work_dir)
	file_options = load_config_file(options.config)
	options      = merge_options(options, file_options)
	scan_work_dir(connection, options.work_dir, options.filter)


def do_image_multidir_reduce(connection, options):
	with os.scandir(options.work_dir) as it:
		dirs  = [ entry.path for entry in it if entry.is_dir() ]
		files = [ entry.path for entry in it if entry.is_file() ]
	if dirs:
		if files:
			log.warning("Ignoring files in %s", options.work_dir)
		for item in dirs:
			options.work_dir = item
			try:
				do_image_reduce(connection, options)
			except ConfigError as e:
				pass
			time.sleep(1.5)
	else:
		do_image_reduce(connection, options)


def image_reduce(connection, options):
	if not options.multiuser:
		do_image_multidir_reduce(connection, options)
	else:
		# os.scandir() only available from Python 3.6   
		with os.scandir(options.work_dir) as it:
			dirs = [ (entry.name, entry.path) for entry in it if entry.is_dir() ]
		if dirs:
			for key, path in dirs:
				options.config   = os.path.join(AZOTEA_CFG_DIR, key + '.ini')
				options.work_dir = path
				try:
					do_image_multidir_reduce(connection, options)
				except IOError as e:
					log.warning("No %s.ini file, skipping observer", key)
		else:
			raise NoUserInfoError(options.work_dir)


# ================ #
# MAIN ENTRY POINT #
# ================ #


def main():
	'''
	Utility entry point
	'''
	try:
		options = createParser().parse_args(sys.argv[1:])
		configureLogging()
		log.info("======= AZOTEA HASH FIXING TOOL =======")
		connection = open_database(DEF_DBASE)
		image_reduce(connection, options)
	except KeyboardInterrupt as e:
		log.critical("[%s] Interrupted by user ", __name__)
	except Exception as e:
		log.critical("[%s] Fatal error => %s", __name__, str(e) )
		traceback.print_exc()
	finally:
		if connection:
			work_dir_cleanup(connection)

main()

