# 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 time
import json
import socket
import logging
import threading

import base_detector
import base_scanner
import config

class BroadcastListener(threading.Thread):

    LISTEN_TIMEOUT = 3
    READ_RETRIES = 3
    READ_LENGTH = 1024

    def __init__(self, port):
        self.logger = logging.getLogger(self.__class__.__name__)
        self.socket = self.create_socket(port)
        self.received = {}
        threading.Thread.__init__(self, daemon=True)

    def create_socket(self, port):
        self.logger.debug("Creating listen socket for port %d" % port)
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind(('', port))
            sock.settimeout(self.LISTEN_TIMEOUT)
        except socket.error:
            self.logger.debug("Socket init error. Port:" + str(port), exc_info=True)
        except ValueError:
            self.logger.debug("Not valid port number:" + str(port), exc_info=True)
        else:
            self.logger.debug("...done")
            return sock

    def run(self):
        if not self.socket:
            self.logger.debug("Attempt to receive from not initialized socket!")
        else:
            read_retries_left = self.READ_RETRIES
            while read_retries_left:
                try:
                    data, addr = self.socket.recvfrom(self.READ_LENGTH)
                    if data and addr:
                        read_retries_left = self.READ_RETRIES
                        if addr not in self.received:
                            self.logger.info(f'Detector got a broadcast response from {addr}')
                            self.received[addr] = data
                        elif self.received[addr] != data: #protection against printer's hiccups
                            self.received[addr] += data
                            self.logger.info(f'Concatenating a broadcast response from {addr}')
                except socket.error:
                    read_retries_left -= 1


class BroadcastPrinterDetector(base_scanner.Scanner):

    RETRIES = 3
    RETRY_TIMEOUT = 0.4
    DEFAULT_PROFILE_ALIAS = 'MB5thMini' # we need a profile to get fields and port from. they are the same all all MB5g
    CAN_DETECT_SNR = True
    CAN_DETECT_VID_PID = True

    def __init__(self, parent, profile, conn=None):
        if not profile:
            for profile in config.get_profiles():
                if profile.get('network_detect', {}).get('scanner') == 'broadcast':
                    break
        super().__init__(parent, profile, conn)
        network_profile = profile['network_detect']
        self.listen_port = network_profile.get('listen_port', None)
        self.broadcast_port = network_profile.get('broadcast_port', None)
        self.target_port = network_profile.get('target_port', None)
        message = network_profile.get('message', '')
        if network_profile.get('json_message'):
            message = json.dumps(message)
        self.message = message
        self.already_know_ip = None
        self.non_default_port = None
        self.already_detected = []
        self.discovered_printers = []

    def detect(self, already_know_ip=None, non_default_port=None):
        self.already_know_ip = already_know_ip
        self.non_default_port = non_default_port 
        if self.broadcast_port and self.listen_port and self.target_port:
            self.listener = BroadcastListener(self.listen_port)
            self.listener.start()
            self.broadcast()
            self.listener.join()
        else:        
            error = "Error in config file. Section network_detect of profile: " + str(self.profile)
            self.logger.error(error)

    def create_broadcasting_socket(self):
        self.logger.debug("Creating broadcast socket on port %d" % self.broadcast_port)
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.settimeout(self.listener.LISTEN_TIMEOUT-1)
            sock.bind(('', self.broadcast_port))
        except socket.error:
            self.logger.debug("Socket init error. Port: %d" % self.broadcast_port, exc_info=True)
        except ValueError:
            self.logger.debug("Not valid port number: %d" % self.broadcast_port, exc_info=True)
        else:
            return sock

    def broadcast(self):
        self.logger.debug("Sending broadcast to port %d" % self.target_port)
        bc_socket = self.create_broadcasting_socket()
        if bc_socket:
            attempt = 1
            while attempt < self.RETRIES + 1:
                try:
                    bc_socket.sendto(self.message.encode('utf-8'), ('255.255.255.255', self.target_port))
                except socket.error:
                    self.logger.debug("Error sending broadcast", exc_info=True)
                    #self.logger.debug("Timeout on port:" + str(self.listen_port))
                attempt += 1
                time.sleep(self.RETRY_TIMEOUT)
                for printer_addr, recv_data in self.listener.received.items():
                    self.process_response(recv_data, printer_addr)
            self.logger.debug("Done listening to port " + str(self.listen_port))
            bc_socket.close()

    def process_response(self, response, addr):
        if addr not in self.already_detected:
            self.already_detected.append(addr)
            printer = {'IP': addr[0], 'port': addr[1]}
            if self.profile['network_detect']['json_response']:
                try:
                    response = json.loads(response)
                except (TypeError, ValueError):
                    self.logger.debug('Response from printer should be valid json. Its malformed or not json')
                else:
                    try:
                        ip_field = self.profile['network_detect']['IP_field']
                        iserial_field = self.profile['network_detect']['SNR_field']
                        vid_field = self.profile['network_detect']['VID_field']
                        pid_field = self.profile['network_detect']['PID_field']
                        printer['IP'] = response[ip_field]
                        if addr[0] != printer['IP']:
                            self.logger.warning("Detected printer IP didn't match with IP field in response")
                        printer['SNR'] = response[iserial_field]
                        printer['VID'] = base_detector.BaseDetector.format_vid_or_pid(response[vid_field])
                        printer['PID'] = base_detector.BaseDetector.format_vid_or_pid(response[pid_field])
                    except:
                        pass
            self.discovered_printers.append(printer)

    def close(self):
        pass # no need to close anything here, but method is still should exist until Awaitable implementation
