# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.

# This software is distributed under a commercial license for personal,
# educational, corporate or any other use.
# The software as a whole or any parts of it is prohibited for distribution or
# use without obtaining a license from 3D Control Systems, Inc.

# All software licenses are subject to the 3DPrinterOS terms of use
# (available at https://www.3dprinteros.com/terms-and-conditions/),
# and privacy policy (available at https://www.3dprinteros.com/privacy-policy/)

import os
import sys
import time
import signal
import base64

sys.path.append(os.getcwd())

import log
import config
import user_login
import http_client


class Camera:

    MAX_CAMERA_INDEX = 10
    FAILS_BEFORE_REINIT = 10
    X_RESOLUTION = 640
    Y_RESOLUTION = 480
    KOEF_RESOLUTION = X_RESOLUTION / Y_RESOLUTION
    X_SMALL_RESOLUTION = 64
    Y_SMALL_RESOLUTION = 48
    MAX_SMALL_IMAGE_SIZE = 2000
    MAX_IMAGE_SIZE = 50000
    MIN_LOOP_TIME  = 1.0
    QUALITY = 30 #jpeg
    FPS = 5
    IMAGE_EXT = "jpeg"
    SAME_IMAGE = 'S'

    DEBUG = config.get_settings()["camera"]["logging"]


    @log.log_exception
    def __init__(self):
        self.pre_init()
        self.stop_flag = False
        self.init_settings()
        self.init_parameters()
        self.init()
        self.init_user_token()
        self.start()

    def pre_init(self):
        kwargs = {}
        if config.get_settings()['camera']['logging']:
            kwargs["log_file_name"] = log.CAMERA_LOG_FILE
        self.logger = log.create_logger(__name__, **kwargs)
        #logging.basicConfig()
        signal.signal(signal.SIGINT, self.intercept_signal)
        signal.signal(signal.SIGTERM, self.intercept_signal)

    def init_settings(self):
        self.image_extension = self.IMAGE_EXT
        self.image_quality = self.QUALITY
        self.min_loop_time = self.MIN_LOOP_TIME
        self.fps = self.FPS
        self.hardware_resize = config.get_settings()["camera"]["hardware_resize"]
        self.send_as_imagejpeg = config.get_settings()["camera"]["binary_jpeg"]
        self.frame_skip = config.get_settings()["camera"]["frame_skip"]

    def init_parameters(self):
        self.cloud_camera_state = {}
        self.last_sent_frame_time = {}
        self.active_camera_number = 0
        self.resized = []
        self.fails = []

    def init(self):
        import numpy as np
        import cv2 as opencv
        self.np = np
        self.cv2 = opencv
        self.cv2_use_int = False

    def init_user_token(self):
        if len(sys.argv) > 2:
            self.token = sys.argv[1]
            mac = sys.argv[2]
        else:
            self.token = None
            mac = None
        if config.get_settings()['protocol']['user_login']:
            self.http_client = http_client.HTTPClient(self, keep_connection_flag=True)
            self.logger.info("Camera: using UserLogin protocol")
            if not self.token:
                ul = user_login.UserLogin(self)
                ul.wait()
                self.token = ul.user_token
        else:
            self.http_client = http_client.HTTPClientPrinterAPIV1(self, keep_connection_flag=True)
            self.logger.info("Camera: using APIPrinter protocol")
            if not self.token:
                auth_tokens = user_login.UserLogin.load_printer_auth_tokens()
                if not auth_tokens:
                    self.logger.warning("No auth_token found to start camera. Camera quit...")
                elif len(auth_tokens) > 1:
                    self.logger.warning("Several auth_tokens stored in login file! Camera can't determine correct one to use. Guessing correct one...")
                if auth_tokens:
                    self.token = auth_tokens[-1][1] #TODO add ability to get proper auth_token for each usb_info
            #self.logger.debug("Camera auth_token=" + self.token)
        if not self.token:
            self.logger.info("Camera: no token to start. Exit...")
            sys.exit(1)
        if mac:
            self.http_client.mac = mac #we need to use MAC from client to ensure that it's not changed on camera restart

    def start(self):
        self.search_cameras()
        self.main_loop()

    def intercept_signal(self, signal_code, frame):
        self.logger.info("SIGINT or SIGTERM received. Closing Camera Module...")
        self.close()

    def close(self):
        self.stop_flag = True

    def search_cameras(self):
        self.init_parameters()
        self.captures = []
        for index in range(0, self.MAX_CAMERA_INDEX):
            if self.stop_flag:
                break
            self.logger.debug("Probing for camera N%d..." % index)
            capture = self.cv2.VideoCapture(index)
            capture.set(self.cv2.CAP_PROP_FPS, self.fps)
            if capture.isOpened():
                self.logger.info("Camera FPS:" + str(capture.get(self.cv2.CAP_PROP_FPS)))
                self.init_capture(capture, index)
                self.logger.info("...got camera at index %d" % index)
            else:
                self.logger.info("Camera at index %d can't be opened" % index)
                del(capture)
        self.logger.info("Got %d operational cameras" % len(self.captures))

    def init_capture(self, capture, index):
        self.resized.append(self.set_resolution(capture))
        self.captures.append(capture)
        self.fails.append(0)

    def set_resolution(self, cap):
        if self.hardware_resize:
            try:
                x = cap.get(self.cv2.CV_CAP_PROP_FRAME_WIDTH)
                y = cap.get(self.cv2.CV_CAP_PROP_FRAME_HEIGHT)
            except AttributeError:
                return False
            if x == self.X_RESOLUTION and y == self.Y_RESOLUTION:
                return True
            # protection against setting wrong parameters(some cameras have different params on this indexes)
            elif x > 100 and y > 100:
                try:
                    result = cap.set(self.cv2.CV_CAP_PROP_FRAME_WIDTH, self.X_RESOLUTION) and \
                             cap.set(self.cv2.CV_CAP_PROP_FRAME_HEIGHT, self.Y_RESOLUTION)
                except:
                    result = False
                return result

    def get_resize_resolution(self, frame):
        number = self.active_camera_number
        height, width = frame.shape[:2]
        if self.cloud_camera_state.get(number):
            sizes = self.X_RESOLUTION, self.Y_RESOLUTION
        else:
            sizes = self.X_SMALL_RESOLUTION, self.Y_SMALL_RESOLUTION
        if width > sizes[0] or height > sizes[1]:
            if self.KOEF_RESOLUTION == width / height:
                return sizes
            koef = min(sizes[0] / width, sizes[1] / height)
            return round(width * koef), round(height * koef)

    def resize_cv2_frame(self, frame):
        number = self.active_camera_number
        if not self.resized[number] or not self.cloud_camera_state.get(number):  # resize using software
            try:
                sizes = self.get_resize_resolution(frame)
                if not sizes:
                    return frame
                if self.cv2_use_int:
                    sizes = (int(sizes[0]), int(sizes[1]))
                frame = self.cv2.resize(frame, sizes, interpolation=self.cv2.INTER_NEAREST)
            except Exception as e:
                if isinstance(e, TypeError) and not self.cv2_use_int:
                    # some opencv version accept integer args here
                    self.cv2_use_int = True
                    self.fails[number] += 1
                    self.logger.warning("TypeError while software resize of frame: " + str(e))
                    return self.resize_cv2_frame(frame)
                self.resized[number] = True
                self.logger.warning("Error while software resize of frame: " + str(e))
        return frame

    def get_image_from_cv2_frame(self, frame):
        if frame.any():
            encode_param = [
                int(self.cv2.IMWRITE_JPEG_QUALITY),
                40 if not self.cloud_camera_state.get(self.active_camera_number) else self.image_quality
            ]
            try:
                result, encoded_frame = self.cv2.imencode(self.image_extension, frame, encode_param)
            except Exception as e:
                self.logger.warning('Failed to encode camera frame: ' + str(e))
                result, encoded_frame = None, None
            if result:
                data = self.np.array(encoded_frame)
                string_data = data.tostring()
                # self.logger.debug("Successfully captured and encoded from" + str(capture))
                return string_data

    def resize_image(self, img):
        number = self.active_camera_number
        max_image_size = self.MAX_IMAGE_SIZE if self.cloud_camera_state.get(number) else self.MAX_SMALL_IMAGE_SIZE
        if not img or len(img) <= max_image_size:
            return img
        try:
            buf = self.np.fromstring(img, dtype=self.np.uint8)
            frame = self.cv2.imdecode(buf, self.cv2.IMREAD_UNCHANGED)
        except Exception as e:
            self.logger.warning('Failed to decode camera frame: ' + str(e))
            return img
        return self.get_image_from_cv2_frame(self.resize_cv2_frame(frame))

    def is_same_image_frame(self):
        number = self.active_camera_number
        return not self.cloud_camera_state.get(number)

    def make_shot(self):
        number = self.active_camera_number
        capture = self.captures[number]
        #self.logger.debug("Capturing frame from " + str(capture))
        try:
            for _ in range(self.frame_skip+1): # buffer will contain old images if not flushed this way
                capture.grab()
            state, frame = capture.retrieve()
        except Exception as e:
            self.logger.error("Error while capturing frame: " + str(e))
        else:
            if state and frame.any():
                self.fails[number] = 0
            else:
                self.fails[number] += 1
                return
            if self.is_same_image_frame():
                return self.SAME_IMAGE
            frame = self.resize_cv2_frame(frame)
            return self.get_image_from_cv2_frame(frame)

    def get_camera_number_for_cloud(self):
        return self.active_camera_number + 1

    def send_frame(self, frame):
        number = self.active_camera_number
        if frame != Camera.SAME_IMAGE:
            self.last_sent_frame_time[number] = time.monotonic()
        send_number = self.get_camera_number_for_cloud()
        message = self.token, send_number, "Camera" + str(send_number)
        #self.logger.debug("Camera %d sending frame to server..." % send_number)
        if self.send_as_imagejpeg:
            if frame == Camera.SAME_IMAGE:
                frame == ""
            answer = self.pack_and_send_as_imagejpeg(message, frame)
        else:
            if frame != Camera.SAME_IMAGE:
                frame = base64.b64encode(frame)
            answer = self.pack_and_send(message, frame)
        if answer is None:
            self.logger.debug("Camera %d can't send frame to server - HTTP error" % send_number)
        else:
            self.cloud_camera_state[number] = answer.get('state', 0)
            if Camera.DEBUG:
                self.logger.debug("Camera request:" + str(message))
                self.logger.debug("Camera reply:" + str(answer))
                if frame == Camera.SAME_IMAGE:
                    self.logger.debug("Frame: 'S'")
                else:
                    self.logger.debug("Frame: %dB", len(frame))

    def pack_and_send(self, message, frame):
        message = list(message)
        message.append(frame)
        return self.http_client.pack_and_send('camera', *message)

    def pack_and_send_as_imagejpeg(self, message, frame):
        target_url_path, package_message = self.http_client.pack(http_client.HTTPClient.CAMERA_IMAGEJPEG, *message)
        headers = { "Content-Type": "image/jpeg",
                "Content-Length": len(frame),
                "Camera-Properties": package_message }
        return self.http_client.send(target_url_path, frame, headers)

    def main_loop(self):
        while not self.stop_flag:
            frame_start_time = time.monotonic()
            reinit_needed = False
            min_loop_time = self.min_loop_time
            for number, capture in enumerate(self.captures):
                min_loop_time = self.min_loop_time
                self.active_camera_number = number
                if self.fails[number] > self.FAILS_BEFORE_REINIT:
                    reinit_needed = True
                frame = self.make_shot()
                if frame:
                    #self.logger.debug("Got frame from camera N" + str(number))
                    self.send_frame(frame)
                else:
                    self.logger.warning("No frame from camera N" + str(number))
            while time.monotonic() < frame_start_time + 0.001 + min_loop_time * len(self.captures):
                time.sleep(0.01)
            if reinit_needed:
                self.logger.warning("Error of camera %d. Starting cameras reinitialisation..." % number)
                self.close_captures()
                self.search_cameras()
                self.logger.warning("...done reinitialising cameras.")
        self.close_captures()
        self.http_client.close()
        sys.exit(0)

    def close_captures(self):
        for capture in self.captures:
            capture.release()
            self.logger.info("Closed camera capture " + str(capture))
            #del(capture)

    def register_error(self, code, message, is_blocking=False, is_info=False):
        self.logger.warning("Error N%d. %s" % (code, message))

if __name__ == '__main__':
    Camera()
