import argparse
import base64
import hashlib
import logging
import os
import signal
import sys
import subprocess
import time
import threading

import config
import http_client
import log
import platforms
import user_login
try:
    from camera_server import HTTPMJEPServer
except ImportError:
    HTTPMJEPServer = None


class BaseCaptureWrapper:

    FAILS_BEFORE_REINIT = 10
    X_RESOLUTION = 640
    Y_RESOLUTION = 480
    REAL_RES_THRESHOLD = 128
    SYNC_GRABS_COUNT = 5
    KOEF_RESOLUTION = X_RESOLUTION / Y_RESOLUTION
    MAX_IMAGE_SIZE = 50000
    QUALITY = 30 #jpeg
    FPS = 1
    MIN_NONZEROS_IN_ID = 4
    JOIN_TIMEOUT = 6

    AMPERSANT = "@"
    SEMICOLON_STRING = ":"
    SCHEMA_STRING = "://"

    @staticmethod
    def hashify_cap_id(cap_id_str):
        return int(hashlib.shake_128(cap_id_str.encode(errors='ignore')).hexdigest(4), 16)

    @staticmethod
    def make_camera_name(cap_id, camera_number):
        return "CAM:" + str(camera_number)

    @staticmethod
    def make_number_from_url(cap_id):
        if BaseCaptureWrapper.AMPERSANT in cap_id:
            cap_id = cap_id.split(BaseCaptureWrapper.AMPERSANT, 1)[1]
        return BaseCaptureWrapper.hashify_cap_id(cap_id)

    def __repr__(self):
        return self.__class__.__name__ + "_" + str(self.cap_id)

    def __init__(self, parent, cap_id):
        self.parent = parent
        self.cap_id = cap_id
        self.logger = logging.getLogger(str(self))
        self.logger.setLevel(logging.DEBUG) # debug message propagated to a parent would be filtered by its log level
        if self.get_auth_from_urls_file(cap_id):
            self.stop_flag = False
            self.operational = True
        else:
            self.operational = False
            self.stop_flag = True
            self.logger.warning('No auth record, probably the printer line was removed. Stopping ' + self.cap_id)
        self.capture = None
        self.last_frame = None
        self.fails = 0
        self.active = False
        self.camera_number = self.make_number_from_cap_id(cap_id)
        self.camera_name = self.make_camera_name(cap_id, self.camera_number)
        # self.logger = logging.getLogger(str(self))
        self.frame = None
        self.frame_lock = threading.Lock()
        self.frame_loop_method = getattr(self, 'frame_loop', None)
        self.frame_consumed = False
        self.loop = None
        self.log_id = f' {self.camera_name} {self.camera_number} {self.cap_id}'
        self.logger.info('Created' + self.log_id)

    def get_auth_from_urls_file(self, cap_id):
        return True

    def create_capture(self):
        raise NotImplementedError

    def release_capture(self):
        raise NotImplementedError

    def activate(self):
        self.stop_flag = False
        self.logger.info('Activating' + self.log_id)
        if self.frame_loop_method:
            self.start_frame_loop()
        else:
            self.capture = self.create_capture()

    def deactivate(self):
        self.stop_flag = True
        self.logger.info('Deactivating' + self.log_id)
        if self.loop:
            if self.loop.is_alive():
                self.loop.join(self.JOIN_TIMEOUT)
                self.logger.info('Stopped' + self.log_id)
        elif self.capture:
            try:
                self.release_capture()
                self.logger.info('Released' + self.log_id)
            except:
                pass
            self.capture = None
        self.deactivation_cleanup()

    def deactivation_cleanup(self):
        self.operational = False
        with self.frame_lock:
            self.frame = None
        self.active = False
        if not self.stop_flag:
            self.logger.warning('Frame loop crash ' + self.log_id)

    def start_frame_loop(self):
        if self.loop and self.loop.is_alive():
            if self.stop_flag:
                self.loop.join(self.JOIN_TIMEOUT)
            else:
                self.logger.error(f'Cant start a frame loop(already running)' + self.log_id)
                self.active = False
                return
        self.stop_flag = False
        self.fails = 0
        self.loop = threading.Thread(target=self.frame_loop_method)
        self.loop.start()

    def register_error(self, *args, **kwargs):
        if self.parent:
            self.parent.register_error(*args, **kwargs)
        else:
            print(args, kwargs)

    def set_active(self, active):
        if active != self.active:
            if active:
                self.activate()
            else:
                self.deactivate()
            self.active = active

    def get_frame(self):
        with self.frame_lock:
            self.frame_consumed = True
            if self.frame:
                return self.frame
            else:
                return b""

    # def get_frame(self):
    #     with self.frame_lock:
    #         if not self.frame:
    #             if self.fails < self.FAILS_BEFORE_REINIT:
    #                 self.fails += 1
    #             else:
    #                 self.operational = False

    def get_number(self):
        return self.camera_number

    def get_name(self):
        return self.camera_name

    def make_number_from_cap_id(self, cap_id):
        if self.parent and self.parent.old_numeration:
            return self.parent.get_next_free_cam_number(cap_id)
        if isinstance(cap_id, int):
            return cap_id + 1
        if self.SCHEMA_STRING in cap_id:
            return self.make_number_from_url(cap_id)
        return self.hashify_cap_id(cap_id)

    def is_operational(self):
        if self.loop:
            return self.loop.is_alive()
        return self.operational

    def join(self):
        if self.loop and self.loop.is_alive():
            try:
                self.loop.join(self.JOIN_TIMEOUT)
            except:
                pass

    def close(self):
        self.stop_flag = True


class BaseCamDetector:

    CAPTURE_WRAPPER_CLASS = None
    STATIC = False
    KEEP_LIVE_CAPS = False
    CAP_UNDETECTABLE_WHEN_USED = False

    def __init__(self, parent):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.setLevel(logging.DEBUG)
        self.parent = parent
        self.inited = False
        self.capture_ids = []

    def __repr__(self):
        return self.__class__.__name__

    def get_capture_ids(self):
        return self.capture_ids


class BaseCamera:

    MIN_SEC_PER_FRAME = 1.0
    CAPTURES_LIST_UPDATE_PAUSE = 6
    CAPTURES_LIST_UPDATE_STEPS = 20
    SUBPROC_STOP_TIMEOUT = 12
    MAX_INCREMENTAL_CAMERA_NUMBER = 100
    DUMMY_FRAME = 'S'
    OUTPUT_FILE_PATH = ''
    #OUTPUT_FILE_PATH = '/tmp/3dprinteros_cam_frame.jpeg'

    MAX_SUBPROCESS_CASCADES = 20

    DETECTORS = []
    DYNAMIC_DETECTION = not config.get_settings().get('detect_once')
    DEFAULT_CAPTURE_WRAPPER_CLASS = None

    @staticmethod
    def process_cap_list(str_cap_ids_list):
        cap_ids = []
        for cap_id in str_cap_ids_list:
            try:
                cap_id = int(cap_id)
            except:
                cap_id = cap_id.strip('"')
            cap_ids.append(cap_id)
        return cap_ids
    
    @staticmethod
    def parse_args():
        parser = argparse.ArgumentParser(prog="3DPrinterOS network camera")
        parser.add_argument('token', nargs='?', default='')
        parser.add_argument('hostid', nargs='?', default='')
        parser.add_argument('--offline', action='store_true', default=False)
        parser.add_argument('-o', '--number_offset', type=int, default=0)
        parser.add_argument('-w', '--whitelist', type=str, action='extend', nargs="+", default=[], 
                            help='Captures whitelist')
        parser.add_argument('-b', '--blacklist', type=str, action='extend', nargs="+", default=[], 
                            help='Captures blacklist')
        parser.add_argument('-f', '--forcelist', type=str, action='extend', nargs="+", default=[], 
                            help='Captures to force open')
        parser.add_argument('-n', '--nonexclusive', action='store_true', default=False,
                            help='Makes force capture list non exclusive, by enabling capture autodetection')
        parser.add_argument('-a', '--alwaysactive', action='store_true', default=False,
                            help="Force capture to be always open(don't use in production if you don't want a ban!)")
        parser.add_argument('-l', '--logger', action='store_true', default=False,
                            help="Force capture to be always open(don't use in production if you don't want a ban!)")
        parser.add_argument('-k', '--nologger', action='store_true', default=False,
                            help="Force capture to be always open(don't use in production if you don't want a ban!)")
        args = parser.parse_args()
        return args

    def __init__(self):
        self.stop_flag = False
        self.command_line_args = self.parse_args()
        # need to be here for parent.offline_mode in user_login
        self.offline_mode = self.command_line_args.offline or config.get_settings().get('offline_mode', False) 
        self.capture_whitelist = self.process_cap_list(self.command_line_args.whitelist)
        self.capture_blacklist = self.process_cap_list(self.command_line_args.blacklist)
        self.forced_captures_list = self.process_cap_list(self.command_line_args.forcelist)
        self.old_numeration_offset = self.command_line_args.number_offset
        self.nonexclusive_forced_captures = self.command_line_args.nonexclusive
        self.alwaysactive = self.command_line_args.alwaysactive
        self.init()

    @log.log_exception
    def init(self):
        self.enable_logging = config.get_settings()["camera"]["logging"]
        if self.command_line_args.logger:
            self.enable_logging = True
        if self.command_line_args.nologger:
            self.enable_logging = False
        if self.enable_logging:
            if not self.old_numeration_offset:
                name = log.CAMERA_LOG_FILE
            else:
                name = log.CAMERA_LOG_FILE.replace(log.CAMERA_BASE, log.CAMERA_BASE + str(self.old_numeration_offset))
            self.logger = log.create_logger(None, log_file_name=name)
        else:
            self.logger = logging.getLogger()
            self.logger.addHandler(logging.StreamHandler())
        self.logger.info(self.__class__.__name__ + 
            f" started as pid {os.getpid()}. Cmd args: {self.command_line_args.__dict__}")
        self.logger.debug('Debug logging is on')
        signal.signal(signal.SIGINT, self.intercept_signal)
        signal.signal(signal.SIGTERM, self.intercept_signal)
        self.send_as_imagejpeg = config.get_settings()["camera"]["binary_jpeg"]
        self.old_numeration = config.get_settings()["camera"]["old_numeration"]
        self.max_captures_per_process = config.get_settings()["camera"]["cams_per_process"]
        if not self.max_captures_per_process:
            self.max_captures_per_process = 5
        self.allow_subprocess = config.get_settings()["camera"]["allow_subprocess"]
        self.lock = threading.RLock()
        self.captures = {}
        self.subprocessed_cams = [] # a subcamera list, but currently there can be only one subprocess
        self.detectors = []
        self.update_captures_dict_thread = None
        for detector_class in self.DETECTORS:
            self.detectors.append(detector_class(self)) 
        self.load_token()
        if config.get_settings()["camera"]["http_output"].get('enabled') and HTTPMJEPServer:
            self.camera_server = HTTPMJEPServer(self)
            self.camera_server.start()
        else:
            self.camera_server = None
        self.dynamic_detection = self.DYNAMIC_DETECTION
        self.update_captures_dict()
        self.check_old_cam_numbers_migration()
        if self.dynamic_detection:
            self.update_captures_dict_thread = threading.Thread(target=self.captures_dict_update_loop)
            self.update_captures_dict_thread.start()

    def intercept_signal(self, signal_code, frame):
        self.logger.info(f"SIGINT or SIGTERM received. Closing {self.__class__.__name__}...")
        self.stop_flag = True

    def get_capture_class_by_cap_id(self, cap_id):
        raise NotImplementedError('Forcing captures not supported in this class')

    def check_old_cam_numbers_migration(self):
        if config.get_settings()["camera"]["migrate_numeration"] and self.old_numeration:
            self.old_numeration = False
            migration_dict = {}
            for capture in self.captures.values():
                old_number = capture.get_number()
                new_number = capture.make_number_from_cap_id(capture.cap_id)
                migration_dict[old_number] = new_number
            resp_json = self.http_client.change_camera_number(self.token, migration_dict)
            if resp_json and isinstance(resp_json, dict) and resp_json.get('success'):
                self.logger.info('Successful camera number migration: ' + str())
                settings = config.get_settings()
                settings["camera"]["migrate_numeration"] = False
                settings["camera"]["old_numeration"] = False
                config.Config.instance().save_settings(settings)
                for capture in self.captures.values():
                    capture.close()
                for capture in self.captures.values():
                    capture.join()
                self.captures = {}
            else:
                self.logger.info('Camera number migration fail: ' + str(resp_json))

    def run_subrocess(self, cap_id):
        # currently each camera can run only one subcamera, but that can cascade eternally
        if not self.subprocessed_cams:
            with self.lock:
                existing_captures = list(self.captures.keys())
            new_subproccess_blacklist = sorted(set(
                ('"' + str(cap).strip('"') + '"' for cap in existing_captures + self.capture_blacklist)))
            start_command = [sys.executable, sys.argv[0], self.token, self.host_id, \
                    "-o", str(len(new_subproccess_blacklist)), "-b", *new_subproccess_blacklist]
            camera_popen_kwargs = {}
            if platforms.get_platform() == 'win':
                camera_popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW # pyright: ignore #noqa
            try:
                process = subprocess.Popen(start_command, **camera_popen_kwargs)
            except Exception as e:
                self.logger.error(f'Could not launch a subcamera due to error: {e}\t\
                        Args: {start_command}\tKeyword args: {camera_popen_kwargs}')
            else:
                self.subprocessed_cams.append({"process": process, "state": "running"})
                for capture in existing_captures:
                    if capture not in self.capture_whitelist:
                        self.capture_whitelist.append(existing_captures)
                self.logger.info(f"Subcamera spawned {sys.argv[0]} {process.pid}")

    def add_captures_from_forced_list(self):
        if self.forced_captures_list:
            for cap_id in self.forced_captures_list:
                if not cap_id in self.captures:
                    self.logger.info('Camera forced: ' + str(cap_id))
                    capture_class = self.get_capture_class_by_cap_id(cap_id)
                    if not self.allow_subprocess or len(self.captures) < self.max_captures_per_process: 
                        self.captures[cap_id] = capture_class(self, cap_id)
                    elif self.old_numeration_offset / self.max_captures_per_process > self.MAX_SUBPROCESS_CASCADES:
                        self.captures[cap_id] = capture_class(self, cap_id)
                    else:
                        self.run_subrocess(cap_id)
            if not self.nonexclusive_forced_captures:
                if self.dynamic_detection:
                    self.dynamic_detection = False
                return True
        return False

    def update_captures_dict(self):
        if not self.add_captures_from_forced_list():
            for detector in self.detectors:
                if not detector.STATIC or not detector.inited:
                    cap_ids = detector.get_capture_ids()
                    with self.lock:
                        caps_to_remove = []
                        for cap_id, capture in self.captures.items():
                            if capture.__class__ == detector.CAPTURE_WRAPPER_CLASS and cap_id not in cap_ids:
                                if not detector.KEEP_LIVE_CAPS or not capture.is_operational():
                                    try:
                                        capture.close()
                                    except:
                                        pass
                                    self.logger.info('Camera lost: ' + str(cap_id))
                                    caps_to_remove.append(cap_id)
                        for cap_id in caps_to_remove:
                            del self.captures[cap_id]
                        for cap_id in cap_ids:
                            if cap_id not in self.captures \
                                and (not self.capture_whitelist or cap_id in self.capture_whitelist) \
                                and cap_id not in self.capture_blacklist:
                                self.logger.info('Camera found: ' + str(cap_id))
                                if not self.allow_subprocess or len(self.captures) < self.max_captures_per_process: 
                                    self.captures[cap_id] = detector.CAPTURE_WRAPPER_CLASS(self, cap_id)
                                elif self.old_numeration_offset / self.max_captures_per_process > self.MAX_SUBPROCESS_CASCADES:
                                    self.captures[cap_id] = detector.CAPTURE_WRAPPER_CLASS(self, cap_id)
                                else:
                                    self.run_subrocess(cap_id)

    @log.log_exception
    def captures_dict_update_loop(self):
        sleep = self.CAPTURES_LIST_UPDATE_PAUSE/self.CAPTURES_LIST_UPDATE_STEPS
        steps = self.CAPTURES_LIST_UPDATE_STEPS
        while not self.stop_flag:
            steps -= 1
            time.sleep(sleep)
            if not steps:
                # self.logger.debug("Caps before:" + str(self.captures.keys()))
                try:
                    self.update_captures_dict()
                except Exception as e:
                    self.logger.exception('Exception while detecting captures:' + str(e))
                # self.logger.debug("Caps after:" + str(self.captures.keys()))
                steps = self.CAPTURES_LIST_UPDATE_STEPS

    def load_token(self):
        if self.command_line_args.token:
            self.token = self.command_line_args.token
            self.host_id = self.command_line_args.hostid
        else:
            self.token = None
            self.host_id = None
        if config.get_settings()['protocol']['user_login']:
            self.http_client = http_client.HTTPClient(self, True, self.logger.level)
            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, True, self.logger.level)
            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...")
                else:
                    self.token = auth_tokens[-1][1] #TODO add ability to get proper auth_token for each usb_info
                if 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...")
            #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 self.host_id:
            #we need to use MAC from client to ensure that it's not changed on camera restart
            self.http_client.host_id = self.host_id
        else:
            self.host_id = self.http_client.get_host_id()

    def register_error(self, code: int, message: str, job_fail: bool = False, info: bool = False, disconnect: bool = False) -> None:
        self.logger.warning(f"Error N{code} fail={job_fail} info={info} disconnect={disconnect} {message}")

    def get_next_free_cam_number(self, cap_id):
        with self.lock:
            all_numbers = list(range(1+self.old_numeration_offset, self.MAX_INCREMENTAL_CAMERA_NUMBER))
            for existing_cap_id, capture in self.captures.items():
                # at creation time capture isn't in the self.captures yet, but there could another same id capture that is about to close
                if cap_id == existing_cap_id and not capture.is_operational() and capture.cloud_camera_number:
                    return capture.get_number()
                all_numbers.remove(capture.get_number())
            return min(all_numbers)

    def send_frame(self, frame_bytes, cam_number, cam_name):
        if not frame_bytes:  #TODO remove this by makeing all get_frame methods to return only bytes
            frame_bytes = b''
        params = self.token, cam_number, cam_name
        if self.send_as_imagejpeg:
            target_url_path, params = self.http_client.pack(self.http_client.CAMERA_IMAGEJPEG, *params)
            headers = { "Content-Type": "image/jpeg",
                        "Content-Length": len(frame_bytes),
                        "Camera-Properties": params }
            answer = self.http_client.send(target_url_path, frame_bytes or self.DUMMY_FRAME, headers)
        else:
            answer = self.http_client.pack_and_send( \
                    self.http_client.CAMERA, *params, base64.b64encode(frame_bytes).decode() or self.DUMMY_FRAME)
        # if self.enable_logging and self.debug:
        #     self.logger.debug("Frame: %dB", len(frame_bytes))
        if self.command_line_args.alwaysactive:
            return True
        try:
            return bool(int(answer.get('state', 0))) # for string "0" and other junk # pyright: ignore # noqa
        except:
            if not self.stop_flag:
                self.logger.warning('Invalid answer from server: ' + str(answer))
        return False

    @log.log_exception
    def run(self):
        while not self.stop_flag:
            loop_start_time = time.monotonic()
            # we can't use for under lock that is too long in a locked state
            with self.lock:
                current_captures_keys = list(self.captures.keys())
            for cap_id in current_captures_keys:
                try:
                    capture = self.captures[cap_id]
                except KeyError:
                    continue
                frame = capture.get_frame()
                if self.offline_mode:
                    active = False 
                else:
                    active = self.send_frame(frame, capture.get_number(), capture.get_name())
                if self.camera_server:
                    locally_viewed = self.camera_server.is_viewed(cap_id)
                    active |= locally_viewed
                    if locally_viewed and not self.camera_server.SELF_GET_FRAMES:
                        self.camera_server.put_frame(cap_id, frame)
                    else:
                        self.camera_server.put_frame(cap_id, None)
                with self.lock:
                    #prevents activation of removed capture
                    if not active or cap_id in self.captures:
                        capture.set_active(active)
            sleep_time = self.MIN_SEC_PER_FRAME - time.monotonic() + loop_start_time 
            if sleep_time > 0:
                time.sleep(sleep_time)
        self.close()

    def close(self):
        if self.update_captures_dict_thread and self.update_captures_dict_thread.is_alive():
            try:
                self.update_captures_dict_thread.join(self.CAPTURES_LIST_UPDATE_PAUSE)
            except RuntimeError:
                pass
        for sbproc_dict in self.subprocessed_cams:
            try:
                sbproc_dict['process'].terminate()
            except (OSError, AttributeError):
                pass
        for cap_id, capture in self.captures.items():
            capture.close()
        for cap_id, capture in self.captures.items():
            self.logger.info("Joining camera capture: " + str(cap_id))
            capture.join()
            self.logger.info("...done")
        time.sleep(0.1)
        if getattr(self, "camera_server", None):
            self.camera_server.stop() # pyright: ignore #noqa 
        all_stopped = False
        counter = self.SUBPROC_STOP_TIMEOUT
        while counter and not all_stopped:
            all_stopped = True
            for sbproc_dict in self.subprocessed_cams:
                if sbproc_dict['state'] == 'stopped':
                    try:
                        time.sleep(0.1)
                        if sbproc_dict['process'].poll() is None:
                            all_stopped = False
                        else:
                            sbproc_dict['state'] = 'stopped'
                    except (OSError, AttributeError):
                        sbproc_dict['state'] = 'stopped'
                counter -= 1
            time.sleep(1)
        if self.http_client:
            self.http_client.close()
        self.logger.info(f"{self.__class__.__name__}... closed")
