# Copyright 2022-2023 ETSI TeraFlowSDN - TFS OSG (https://tfs.etsi.org/)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json, logging, requests
from typing import Dict, List, Optional, Tuple, Union
from common.proto.context_pb2 import (
    ConfigRule, Connection, Device, DeviceList, EndPointId, Link, LinkList, Service, ServiceStatusEnum,
    ServiceTypeEnum)
from common.proto.pathcomp_pb2 import PathCompReply, PathCompRequest
from common.tools.object_factory.ConfigRule import json_config_rule_set
from pathcomp.frontend.Config import BACKEND_URL
from pathcomp.frontend.service.algorithms.tools.ConstantsMappings import DEVICE_LAYER_TO_SERVICE_TYPE, DeviceLayerEnum
from .tools.EroPathToHops import eropath_to_hops
from .tools.ComposeRequest import compose_device, compose_link, compose_service
from .tools.ComputeSubServices import (
    convert_explicit_path_hops_to_connections, convert_explicit_path_hops_to_plain_connection)

class _Algorithm:
    def __init__(self, algorithm_id : str, sync_paths : bool, class_name=__name__) -> None:
        # algorithm_id: algorithm to be executed
        # sync_paths: if multiple services are included in the request, tunes how to prevent contention. If true,
        #             services are computed one after the other and resources assigned to service i, are considered as
        #             used when computing services i+1..n; otherwise, resources are never marked as used during the
        #             path computation.

        self.logger = logging.getLogger(class_name)
        self.algorithm_id = algorithm_id
        self.sync_paths = sync_paths

        self.device_list : List[Dict] = list()
        self.device_dict : Dict[str, Tuple[Dict, Device]] = dict()
        self.device_name_mapping : Dict[str, str] = dict()
        self.endpoint_dict : Dict[str, Dict[str, Tuple[Dict, EndPointId]]] = dict()
        self.endpoint_name_mapping : Dict[Tuple[str, str], str] = dict()
        self.link_list : List[Dict] = list()
        self.link_dict : Dict[str, Tuple[Dict, Link]] = dict()
        self.endpoint_to_link_dict : Dict[Tuple[str, str], Tuple[Dict, Link]] = dict()
        self.service_list : List[Dict] = list()
        self.service_dict : Dict[Tuple[str, str], Tuple[Dict, Service]] = dict()

    def add_devices(self, grpc_devices : Union[List[Device], DeviceList]) -> None:
        if isinstance(grpc_devices, DeviceList): grpc_devices = grpc_devices.devices
        for grpc_device in grpc_devices:
            json_device = compose_device(grpc_device)
            self.device_list.append(json_device)

            device_uuid = json_device['device_Id']
            self.device_dict[device_uuid] = (json_device, grpc_device)

            _device_uuid = grpc_device.device_id.device_uuid.uuid
            _device_name = grpc_device.name
            self.device_name_mapping[_device_name] = _device_uuid

            device_endpoint_dict : Dict[str, Tuple[Dict, EndPointId]] = dict()
            for json_endpoint,grpc_endpoint in zip(json_device['device_endpoints'], grpc_device.device_endpoints):
                endpoint_uuid = json_endpoint['endpoint_id']['endpoint_uuid']
                endpoint_tuple = (json_endpoint['endpoint_id'], grpc_endpoint.endpoint_id)
                device_endpoint_dict[endpoint_uuid] = endpoint_tuple

                _endpoint_uuid = grpc_endpoint.endpoint_id.endpoint_uuid.uuid
                _endpoint_name = grpc_endpoint.name
                self.endpoint_name_mapping[(_device_uuid, _endpoint_name)] = _endpoint_uuid
                self.endpoint_name_mapping[(_device_name, _endpoint_name)] = _endpoint_uuid

            self.endpoint_dict[device_uuid] = device_endpoint_dict

    def add_links(self, grpc_links : Union[List[Link], LinkList]) -> None:
        if isinstance(grpc_links, LinkList): grpc_links = grpc_links.links
        for grpc_link in grpc_links:
            json_link = compose_link(grpc_link)
            if len(json_link['link_endpoint_ids']) != 2: continue
            self.link_list.append(json_link)

            link_uuid = json_link['link_Id']
            self.link_dict[link_uuid] = (json_link, grpc_link)

            for link_endpoint_id in json_link['link_endpoint_ids']:
                link_endpoint_id = link_endpoint_id['endpoint_id']
                device_uuid = link_endpoint_id['device_id']
                endpoint_uuid = link_endpoint_id['endpoint_uuid']
                endpoint_key = (device_uuid, endpoint_uuid)
                link_tuple = (json_link, grpc_link)
                self.endpoint_to_link_dict[endpoint_key] = link_tuple

    def add_service_requests(self, request : PathCompRequest) -> None:
        for grpc_service in request.services:
            json_service = compose_service(grpc_service)
            self.service_list.append(json_service)
            service_id = json_service['serviceId']
            service_key = (service_id['contextId'], service_id['service_uuid'])
            service_tuple = (json_service, grpc_service)
            self.service_dict[service_key] = service_tuple

    def execute(self, dump_request_filename : Optional[str] = None, dump_reply_filename : Optional[str] = None) -> None:
        request = {'serviceList': self.service_list, 'deviceList': self.device_list, 'linkList': self.link_list}

        self.logger.debug('[execute] request={:s}'.format(json.dumps(request, sort_keys=True, indent=4)))
        if dump_request_filename is not None:
            with open(dump_request_filename, 'w', encoding='UTF-8') as f:
                f.write(json.dumps(request, sort_keys=True, indent=4))

        self.logger.debug('[execute] BACKEND_URL: {:s}'.format(str(BACKEND_URL)))
        reply = requests.post(BACKEND_URL, json=request)
        self.status_code = reply.status_code
        self.raw_reply = reply.content.decode('UTF-8')

        self.logger.debug('[execute] status_code={:s} reply={:s}'.format(str(reply.status_code), str(self.raw_reply)))
        if dump_reply_filename is not None:
            with open(dump_reply_filename, 'w', encoding='UTF-8') as f:
                f.write('status_code={:s} reply={:s}'.format(str(self.status_code), str(self.raw_reply)))

        if reply.status_code not in {requests.codes.ok}: # pylint: disable=no-member
            raise Exception('Backend error({:s}) for request({:s})'.format(
                str(self.raw_reply), json.dumps(request, sort_keys=True)))
        
        self.json_reply = reply.json()

    def add_connection_to_reply(
        self, reply : PathCompReply, connection_uuid : str, service : Service, path_hops : List[Dict]
    ) -> Connection:
        connection = reply.connections.add()

        connection.connection_id.connection_uuid.uuid = connection_uuid
        connection.service_id.CopyFrom(service.service_id)

        for path_hop in path_hops:
            device_uuid = path_hop['device']

            ingress_endpoint_uuid = path_hop['ingress_ep']
            endpoint_id = connection.path_hops_endpoint_ids.add()
            endpoint_id.CopyFrom(self.endpoint_dict[device_uuid][ingress_endpoint_uuid][1])

            egress_endpoint_uuid = path_hop['egress_ep']
            endpoint_id = connection.path_hops_endpoint_ids.add()
            endpoint_id.CopyFrom(self.endpoint_dict[device_uuid][egress_endpoint_uuid][1])

        return connection

    def add_service_to_reply(
        self, reply : PathCompReply, context_uuid : str, service_uuid : str,
        device_layer : Optional[DeviceLayerEnum] = None, path_hops : List[Dict] = [],
        config_rules : List = []
    ) -> Service:
        # TODO: implement support for multi-point services
        # Control deactivated to enable disjoint paths with multiple redundant endpoints on each side
        #service_endpoint_ids = service.service_endpoint_ids
        #if len(service_endpoint_ids) != 2: raise NotImplementedError('Service must have 2 endpoints')

        service_key = (context_uuid, service_uuid)
        tuple_service = self.service_dict.get(service_key)
        if tuple_service is not None:
            service = reply.services.add()
            service.CopyFrom(tuple_service[1])
        else:
            service = reply.services.add()
            service.service_id.context_id.context_uuid.uuid = context_uuid
            service.service_id.service_uuid.uuid = service_uuid

            if device_layer is not None:
                service_type = DEVICE_LAYER_TO_SERVICE_TYPE.get(device_layer.value)
                if service_type is None:
                    MSG = 'Unable to map DeviceLayer({:s}) to ServiceType'
                    raise Exception(MSG.format(str(device_layer)))
                service.service_type = service_type

                if service_type == ServiceTypeEnum.SERVICETYPE_TAPI_CONNECTIVITY_SERVICE:
                    json_tapi_settings = {
                        'capacity_value'  : 50.0,
                        'capacity_unit'   : 'GHz',
                        'layer_proto_name': 'PHOTONIC_MEDIA',
                        'layer_proto_qual': 'tapi-photonic-media:PHOTONIC_LAYER_QUALIFIER_NMC',
                        'direction'       : 'UNIDIRECTIONAL',
                    }
                    config_rule = ConfigRule(**json_config_rule_set('/settings', json_tapi_settings))
                    service.service_config.config_rules.append(config_rule)
                else:
                    service.service_config.config_rules.extend(config_rules)

            service.service_status.service_status = ServiceStatusEnum.SERVICESTATUS_PLANNED

            if path_hops is not None and len(path_hops) > 0:
                ingress_endpoint_id = service.service_endpoint_ids.add()
                ingress_endpoint_id.device_id.device_uuid.uuid = path_hops[0]['device']
                ingress_endpoint_id.endpoint_uuid.uuid = path_hops[0]['ingress_ep']

                egress_endpoint_id = service.service_endpoint_ids.add()
                egress_endpoint_id.device_id.device_uuid.uuid = path_hops[-1]['device']
                egress_endpoint_id.endpoint_uuid.uuid = path_hops[-1]['egress_ep']

        return service

    def get_reply(self) -> PathCompReply:
        response_list = self.json_reply.get('response-list', [])
        reply = PathCompReply()
        grpc_services : Dict[Tuple[str, str], Service] = {}
        grpc_connections : Dict[str, Connection] = {}
        for response in response_list:
            service_id = response['serviceId']
            context_uuid = service_id['contextId']
            service_uuid = service_id['service_uuid']
            service_key = (context_uuid, service_uuid)
            upper_service = self.add_service_to_reply(reply, context_uuid, service_uuid)
            grpc_services[service_key] = upper_service

            no_path_issue = response.get('noPath', {}).get('issue')
            if no_path_issue is not None:
                # no path found: leave connection with no endpoints
                # no_path_issue == 1 => no path due to a constraint
                continue

            for service_path_ero in response['path']:
                path_hops = eropath_to_hops(service_path_ero['devices'], self.endpoint_to_link_dict)
                try:
                    connections = convert_explicit_path_hops_to_connections(path_hops, self.device_dict, service_uuid)
                except: # pylint: disable=bare-except
                    # if not able to extrapolate sub-services and sub-connections,
                    # assume single service and single connection
                    connections = convert_explicit_path_hops_to_plain_connection(path_hops, service_uuid)

                for connection in connections:
                    connection_uuid,device_layer,path_hops,_ = connection
                    service_key = (context_uuid, connection_uuid)
                    grpc_service = grpc_services.get(service_key)
                    if grpc_service is None:
                        config_rules = upper_service.service_config.config_rules
                        grpc_service = self.add_service_to_reply(
                            reply, context_uuid, connection_uuid, device_layer=device_layer, path_hops=path_hops,
                            config_rules=config_rules)
                        grpc_services[service_key] = grpc_service

                for connection in connections:
                    connection_uuid,device_layer,path_hops,dependencies = connection

                    service_key = (context_uuid, connection_uuid)
                    grpc_service = grpc_services.get(service_key)
                    if grpc_service is None: raise Exception('Service({:s}) not found'.format(str(service_key)))
                        
                    grpc_connection = grpc_connections.get(connection_uuid)
                    if grpc_connection is not None: continue
                    grpc_connection = self.add_connection_to_reply(reply, connection_uuid, grpc_service, path_hops)
                    grpc_connections[connection_uuid] = grpc_connection

                    for service_uuid in dependencies:
                        sub_service_key = (context_uuid, service_uuid)
                        grpc_sub_service = grpc_services.get(sub_service_key)
                        if grpc_sub_service is None:
                            raise Exception('Service({:s}) not found'.format(str(sub_service_key)))
                        grpc_sub_service_id = grpc_connection.sub_service_ids.add()
                        grpc_sub_service_id.CopyFrom(grpc_sub_service.service_id)

                # ... "path-capacity": {"total-size": {"value": 200, "unit": 0}},
                # ... "path-latency": {"fixed-latency-characteristic": "10.000000"},
                # ... "path-cost": {"cost-name": "", "cost-value": "5.000000", "cost-algorithm": "0.000000"},
                #path_capacity = service_path['path-capacity']['total-size']
                #path_capacity_value = path_capacity['value']
                #path_capacity_unit = CapacityUnit(path_capacity['unit'])
                #path_latency = service_path['path-latency']['fixed-latency-characteristic']
                #path_cost = service_path['path-cost']
                #path_cost_name = path_cost['cost-name']
                #path_cost_value = path_cost['cost-value']
                #path_cost_algorithm = path_cost['cost-algorithm']

        return reply
