from functools import reduce
import time
import math
import copy
import types
import cv2
import numpy as np
import json
from typing import List, Union, Tuple, Any

from ..thresholds.color import built_in_colors
from ..exceptions.exceptions import InvalidCustomFunctionError, CameraError
from ..camera.camera import Camera
from ..partials.filter_applier import filter_applier
from ..thresholds.threshold import Threshold
from ..thresholds.color.color import Color
from ..connections.connection import Connection
from ..directions.director import Director
from ..camera.camera_settings import CameraSettings
from ..connections.network_location import NetworkLocation
from ..directions.directing_functions import center_directions


class Vision:
    """
    The Vision object represents the specifications to detect an object in an image.
    It performs the detection using a Threshold and filter functions.

    Additional capabilities and tuning options are:
        Image filters (Blurs, rotations, cropping),
        Morphological functions,
        Ovl color HSVCalibration,


    """
    def __init__(self, threshold: Threshold = None, contour_filters: List[types.FunctionType] = None,
                 director: Director = None, width=320, height=240, connection: Connection = None,
                 camera: Union[int, str, Camera, cv2.VideoCapture, Any] = None,
                 camera_settings: CameraSettings = None, morphological_functions: List[types.FunctionType] = None,
                 image_filters: List[types.FunctionType] = None, ovl_camera: bool = False, calibration: str = None):
        """
        The object that represents the pipeline of processing, detection, direction of values
        A connection object can be passed
        :param threshold: threshold object, creates the binary mask from a given image
        :param contour_filters: the list of contour_filter functions that
                                remove contours that aren't the target(s)
        :param director: a functions that receives a list or a single contour and returns director
        :param width: the width (in pixels) of images taken with the camera
        :param height: the height (in pixels)
        :param connection: a connection object that passes the result to the connection target
        :param camera: a Camera object (cv2.VideoCapture, ovl.Camera) or source from which to open a camera
        :param camera_settings: Special camera settings like calibration or offset used for
                                image correction and various direction calculations.
        :param morphological_functions: morphological functions used to apply on the binary
                               mask generated by the Threshold.
        :param image_filters: a list of image altering functions that are applied on the image.
        :param ovl_camera: a boolean that makes the camera opened to be ovl.Camera instead of cv2.VideoCapture
        :param calibration: a dictionary containing color calibration (HSVCalibration) coefficients and intercepts,
                            used for applying the calibration on the vision object

        """
        self.width = width
        self.height = height
        self.threshold = threshold
        self.contour_filters = contour_filters or []
        self.director = director or Director(center_directions, failed_detection=9999, target_amount=1)
        self.connection = connection
        self.image_filters = image_filters or []
        self.morphological_functions = morphological_functions or []
        self.camera = None
        self.camera_port = None
        self.camera_settings = camera_settings
        if isinstance(camera, (cv2.VideoCapture, Camera)):
            self.camera = camera
        elif camera is None:
            pass
        else:
            self.camera_setup(camera, width, height, ovl_camera=ovl_camera)

        self.calibration_path = None
        if calibration:
            self.calibration_path = calibration
            calibration = json.load(calibration)
            self.saturation_weight = calibration['saturation'] if 'saturation' in calibration else None
            self.brightness_weight = calibration['brightness'] if 'brightness' in calibration else None

    def __repr__(self):
        return str(self)

    def __str__(self):
        filters = [filter_function.__name__ for filter_function in self.contour_filters]
        threshold = self.threshold
        return "Vision: \n Threshold: {} \n Filters: {}".format(threshold, filters)

    @property
    def target_amount(self):
        """
        The wanted amount of targets
        Determined by self.director
        (0 None or math.inf if there is no limit, 1 if 1 target is wanted etc.)

        """
        if self.director is None:
            return math.inf
        return self.director.target_amount

    def apply_morphological_functions(self, mask, morphological_functions=None):
        """
        Applies all morphological functions on the mask (binary images) created using the threshold,
        Morphological functions are functions that are applied
        to binary images to alter the shape of "detected" regions

        NOTE: Vision.detect is mainly used for full object detection and filtering,
        refer to it for common use of Vision

        :param mask: the mask on which the functions should be applied
        :param morphological_functions: list of morphological_functions to be
         applied instead of self.morphological_functions
        :return: the applied mask

        """
        if type(self.morphological_functions) not in (tuple, list, set):
            return mask
        morphological_functions = morphological_functions or self.morphological_functions
        return reduce(filter_applier, morphological_functions, mask)

    def send(self, data, *args, **kwargs) -> Any:
        """
        Sends data to the destination using self.connection

        :param data: The data to send to the Connection
        :param args: any other arguments for the send function in your connection
        :param kwargs: any other named arguments for the connection object
        :return: Depends on the connection object used, returns its result

        """
        return self.connection.send(*args, **kwargs, data=data)

    def send_to_location(self, data, network_location: NetworkLocation, *args, **kwargs):
        """

        :param data: the data to be sent
        :param network_location: information used to send the data to a specific 'location'
         in the network
        :return: Depends on the connection object

        """
        return self.connection.send_to_location(data, network_location, *args, **kwargs)

    def get_image(self) -> np.ndarray:
        """
        Gets an image from self.camera and applies image filters
        :return: the image, false if failed to get it

        """
        if self.camera is None:
            raise CameraError("No camera given, (Camera is None)")
        if not self.camera.isOpened():
            raise CameraError("Camera given is not open (Has it been closed or disconnected?)")
        output = self.camera.read()
        if len(output) == 2:
            ret, image = output
            return image if ret else False
        else:
            return output

    def get_filtered_image(self):
        """
        Gets an image from self.camera and applies all image filters
        :return: the image filter applied image

        """
        output = self.get_image()
        return self.apply_image_filters(output)

    def apply_filter(self, filter_function, contours, verbose=False):
        """
        Applies a filter function on the contour list, this is used to remove contours
        that do not match desired features

        NOTE: Vision.detect is mainly used for full object detection and filtering,
        refer to it for common use of Vision

        :param filter_function: Filter functions are function with a contour list variable that apply some
            sort of filter on the contours, thus removing ones that don't fit the limit given by the filter.
            for example: straight_rectangle_filter removes contours that are not rectangles that are parallel
            to the frame of the picture
        :param contours: the contours on which the filter should be applied (list of numpy.ndarrays)
        :param verbose: if true_shape does not print anything
        :return: returns the output of the filter function.

        """
        if verbose:
            print('Before "{}": {}'.format(filter_function.__name__, len(contours)))
        filter_function_output = filter_function(contours)

        if isinstance(filter_function_output, tuple):
            if len(filter_function_output) == 2:
                filtered_contours, ratio = filter_function_output[0], filter_function_output[1]
            else:
                raise InvalidCustomFunctionError('Filter function must return between 1 and 2 lists.'
                                                 'Please refer to the Documentation: '
                                                 'https://github.com/1937Elysium/Ovl-Python')
        elif isinstance(filter_function_output, list):
            filtered_contours, ratio = filter_function_output, []
        else:
            raise TypeError('The contour list must be a list or tuple of 2 lists (contours and ratios)')
        return filtered_contours, ratio

    def apply_all_filters(self, contours: List[np.ndarray], verbose=False
                          ) -> Tuple[List[np.ndarray], List[float]]:
        """
        Applies all of the filters on a list of contours
        :param contours: List of contours (numpy arrays) to
        :param verbose: prints out information about filtering process if true (useful for debugging)
        :return: a list of all of the ratios given by the filter function in order.

        """
        ratios = []
        for filter_func in self.contour_filters:
            contours, ratio = self.apply_filter(filter_func, contours, verbose=verbose)
            ratios.append(ratio)
        if verbose:
            print("After all filters: {}".format(len(contours)))
        return contours, ratios

    def apply_image_filters(self, image: np.ndarray) -> np.ndarray:
        """
        Applies all given image filters to the given image
        This is used to apply various image filters on your image in a pipeline,
        like blurs, image cropping, contrasting, sharpening, rotations, translations etc.
        :param image: the image that the image filters should be applied on (numpy array)
        :return: the image with the filters applied

        """
        return reduce(filter_applier, self.image_filters, image)

    def apply_threshold(self, image: np.ndarray, threshold=None) -> np.ndarray:
        """
        Gets a mask (binary image) for a given image and Threshold object
        (uses self.Threshold if given threshold was none)
        :param image: the numpy array of the image
        :param threshold: the Threshold object used to create the binary mask
        :return: the binary mask

        """
        threshold = threshold or self.threshold
        return threshold.convert(image)

    def find_contours_in_mask(self, mask: np.ndarray, return_hierarchy=False, apply_morphs=True
                              ) -> List[np.ndarray]:
        """
        Gets contours from the given mask and apply

        :param mask: binary image (mask), a numpy array
        :param return_hierarchy: if the hierarchy should be returned
        :param apply_morphs: if the morphological functions should be applied.
        :return: the list of contours

        """
        mask = self.apply_morphological_functions(mask) if apply_morphs else mask
        result = cv2.findContours(mask, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
        if len(result) == 3:
            _, contours, hierarchy = result
        elif len(result) == 2:
            contours, hierarchy = result
        else:
            raise ValueError("Invalid output from cv2.findContours, check that your cv2 (OpenCV) version is supported")
        return (contours, hierarchy) if return_hierarchy else contours

    def find_contours(self, image: np.ndarray, threshold=None, return_hierarchy=False) -> List[np.ndarray]:
        """
        Gets a list of all the contours within the threshold that was given

        :param threshold: the threshold that determines the binary masks
        :param image: image from which to get the contours
        :param return_hierarchy: if the hierarchy should be returned
        :return: list of all contours matching the range of hsv colours

        """
        threshold = threshold or self.threshold
        image_mask = self.apply_threshold(image, threshold=threshold)
        return self.find_contours_in_mask(image_mask, return_hierarchy=return_hierarchy)

    def get_directions(self, contours: List[np.ndarray], image: np.ndarray, sorter=None):
        """
        Calculates the directions, based on contours found in the given image

        :param contours: final contours after filtering
        :param image: the image from which to find the contours
        :param sorter: optional parameter, applies a sorter on the given contours
        :return: a string of the director (output of the director function),
                 length depends on the director function

        """
        return self.director.direct(contours, image, camera_settings=self.camera_settings, sorter=sorter)

    def camera_setup(self, source=0, image_width=None, image_height=None, ovl_camera=False):
        """
        Opens up the camera reference and sets a given width and height to all images taken

        :param image_width: the width of the images to be taken, 0 does not set a width
        :param image_height: the height of the images to be taken, 0 does not set a height
        :param source: the location from which to open the camera
         string for network connections int for local USB connections.
        :param ovl_camera: if the camera object should be ovl.Camera
        :return: the camera object, also sets self.camera to the object.

        """

        image_height = image_height or self.height
        image_width = image_width or self.width
        self.camera_port = source
        if ovl_camera:
            camera = Camera(source=source, image_width=image_width, image_height=image_height)
        else:
            camera = cv2.VideoCapture(source)
            if image_width != -1:
                camera.set(3, image_width)
            if image_height != -1:
                camera.set(4, image_height)

        if not camera.isOpened():
            raise CameraError("Camera did not open correctly! Camera source: {}".format(self.camera_port))
        self.camera = camera
        return camera

    def apply_on_sample(self, image: Union[np.ndarray, str], threshold: Threshold = None, display_result: bool = False,
                        delay=0, result_color: Color = built_in_colors.RED_HIGH_HSV, return_hierarchy=False
                        ) -> Tuple[List[np.ndarray], np.ndarray]:
        """
        Finds contours and applies filters on a single image given by a image path or image object (numpy array)

        :param image: an image on which to apply the vision object
        :param threshold: a custom threshold can be passed
        :param display_result: if the contours found should be displayed
        :param delay: waitKey delay (0 if not in a loop, non-zero if it is)
        :param result_color: the colour of the contour outline
        :param return_hierarchy: if the contour hierarchies should be return
        :return: the image (numpy array) and the contours found (list of numpy arrays)
                 Can also return the hierarchies if return_hierarchy is true

        """
        threshold = threshold or self.threshold
        image = cv2.imread(image) if type(image) == str else image
        contours, hierarchy = self.find_contours(image, threshold, return_hierarchy)
        if display_result:
            image_for_display = copy.copy(image)
            cv2.drawContours(image_for_display, contours, -1, result_color)
            cv2.imshow(str(time.time()), image_for_display)
            cv2.waitKey(delay)
        return (contours, image, hierarchy) if return_hierarchy else (contours, image)

    def detect(self, image, verbose=False, return_ratios=False):
        """
        Gets contours and applies all filters and returns the result, thus detecting the object according
        to the specifications in the vision
        :param image: image in which the vision should detect an object
        :param verbose: If information about the filtering should be printed
        :param return_ratios: if the ratios from the filters should be returned
        :return: contours and the filtered image and the ratios if return_ratios is true

        """
        image = self.apply_image_filters(image)
        contours = self.find_contours(image)
        contours, ratios = self.apply_all_filters(contours, verbose)
        return (contours, image, ratios) if return_ratios else (contours, image)
