# Copyright 2021-2023 H2020 TeraFlow (https://www.teraflow-h2020.eu/)
#
# 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 grpc, logging
from typing import Any, Dict, List, Optional, Tuple
from common.orm.Database import Database
from common.orm.HighLevel import get_object, get_related_objects
from common.orm.backend.Tools import key_to_str
from common.proto.context_pb2 import (
    ConfigRule, Connection, ConnectionId, Constraint, EndPointId, Service, ServiceId, ServiceStatusEnum)
from common.rpc_method_wrapper.ServiceExceptions import (
    InvalidArgumentException, NotFoundException, OperationFailedException)
from context.client.ContextClient import ContextClient
from device.client.DeviceClient import DeviceClient
from .database.ConfigModel import (
    ConfigModel, ConfigRuleModel, ORM_ConfigActionEnum, get_config_rules, grpc_config_rules_to_raw)
from .database.ConstraintModel import ConstraintModel, ConstraintsModel, get_constraints, grpc_constraints_to_raw
from .database.DatabaseDeviceTools import sync_device_from_context
from .database.DatabaseServiceTools import (
    delete_service_from_context, sync_service_from_context, sync_service_to_context, update_service_in_local_database)
from .database.DeviceModel import DeviceModel, DriverModel
from .database.EndPointModel import EndPointModel, grpc_endpointids_to_raw
from .database.RelationModels import ServiceEndPointModel
from .database.ServiceModel import ServiceModel
from .service_handler_api._ServiceHandler import _ServiceHandler
from .service_handler_api.FilterFields import FilterFieldEnum
from .service_handler_api.ServiceHandlerFactory import ServiceHandlerFactory
from .service_handler_api.Tools import (
    check_errors_deleteconfig, check_errors_deleteconstraint, check_errors_deleteendpoint, check_errors_setconfig,
    check_errors_setconstraint, check_errors_setendpoint)

LOGGER = logging.getLogger(__name__)

def get_connection(context_client : ContextClient, connection_id : ConnectionId) -> Optional[Connection]:
    try:
        connection : Connection = context_client.GetConnection(connection_id)
        return connection
    except grpc.RpcError as e:
        if e.code() != grpc.StatusCode.NOT_FOUND: raise # pylint: disable=no-member
        return None

def get_service(context_client : ContextClient, service_id : ServiceId) -> Optional[Service]:
    try:
        service : Service = context_client.GetService(service_id)
        return service
    except grpc.RpcError as e:
        if e.code() != grpc.StatusCode.NOT_FOUND: raise # pylint: disable=no-member
        return None

def sync_devices_from_context(
        context_client : ContextClient, database : Database, db_service : Optional[ServiceModel],
        service_endpoint_ids : List[EndPointId]
    ) -> Dict[str, DeviceModel]:

    required_device_uuids = set()
    if db_service is not None:
        db_endpoints = get_related_objects(db_service, ServiceEndPointModel, 'endpoint_fk')
        for db_endpoint in db_endpoints:
            db_device = get_object(database, DeviceModel, db_endpoint.device_fk, raise_if_not_found=False)
            required_device_uuids.add(db_device.device_uuid)

    for endpoint_id in service_endpoint_ids:
        required_device_uuids.add(endpoint_id.device_id.device_uuid.uuid)

    db_devices = {}
    devices_not_found = set()
    for device_uuid in required_device_uuids:
        sync_device_from_context(device_uuid, context_client, database)
        db_device = get_object(database, DeviceModel, device_uuid, raise_if_not_found=False)
        if db_device is None:
            devices_not_found.add(device_uuid)
        else:
            db_devices[device_uuid] = db_device

    if len(devices_not_found) > 0:
        extra_details = ['Devices({:s}) cannot be retrieved from Context'.format(str(devices_not_found))]
        raise NotFoundException('Device', '...', extra_details=extra_details)

    return db_devices

def classify_config_rules(
    db_service : ServiceModel, service_config_rules : List[ConfigRule],
    resources_to_set: List[Tuple[str, Any]], resources_to_delete : List[Tuple[str, Any]]):

    context_config_rules = get_config_rules(db_service.database, db_service.pk, 'running')
    context_config_rules = {config_rule[1]: config_rule[2] for config_rule in context_config_rules}
    #LOGGER.info('[classify_config_rules] context_config_rules = {:s}'.format(str(context_config_rules)))

    request_config_rules = grpc_config_rules_to_raw(service_config_rules)
    #LOGGER.info('[classify_config_rules] request_config_rules = {:s}'.format(str(request_config_rules)))

    for config_rule in request_config_rules:
        action, key, value = config_rule
        if action == ORM_ConfigActionEnum.SET:
            if (key not in context_config_rules) or (context_config_rules[key] != value):
                resources_to_set.append((key, value))
        elif action == ORM_ConfigActionEnum.DELETE:
            if key in context_config_rules:
                resources_to_delete.append((key, value))
        else:
            raise InvalidArgumentException('config_rule.action', str(action), extra_details=str(request_config_rules))

    #LOGGER.info('[classify_config_rules] resources_to_set = {:s}'.format(str(resources_to_set)))
    #LOGGER.info('[classify_config_rules] resources_to_delete = {:s}'.format(str(resources_to_delete)))

def classify_constraints(
    db_service : ServiceModel, service_constraints : List[Constraint],
    constraints_to_set: List[Tuple[str, str]], constraints_to_delete : List[Tuple[str, str]]):

    context_constraints = get_constraints(db_service.database, db_service.pk, 'running')
    context_constraints = {constraint[0]: constraint[1] for constraint in context_constraints}
    #LOGGER.info('[classify_constraints] context_constraints = {:s}'.format(str(context_constraints)))

    request_constraints = grpc_constraints_to_raw(service_constraints)
    #LOGGER.info('[classify_constraints] request_constraints = {:s}'.format(str(request_constraints)))

    for constraint in request_constraints:
        constraint_type, constraint_value = constraint
        if constraint_type in context_constraints:
            if context_constraints[constraint_type] != constraint_value:
                constraints_to_set.append(constraint)
        else:
            constraints_to_set.append(constraint)
        context_constraints.pop(constraint_type, None)

    for constraint in context_constraints:
        constraints_to_delete.append(constraint)

    #LOGGER.info('[classify_constraints] constraints_to_set = {:s}'.format(str(constraints_to_set)))
    #LOGGER.info('[classify_constraints] constraints_to_delete = {:s}'.format(str(constraints_to_delete)))

def get_service_endpointids(db_service : ServiceModel) -> List[Tuple[str, str, Optional[str]]]:
    db_endpoints : List[EndPointModel] = get_related_objects(db_service, ServiceEndPointModel, 'endpoint_fk')
    endpoint_ids = [db_endpoint.dump_id() for db_endpoint in db_endpoints]
    return [
        (endpoint_id['device_id']['device_uuid']['uuid'], endpoint_id['endpoint_uuid']['uuid'],
            endpoint_id.get('topology_id', {}).get('topology_uuid', {}).get('uuid', None))
        for endpoint_id in endpoint_ids
    ]

def classify_endpointids(
    db_service : ServiceModel, service_endpoint_ids : List[EndPointId],
    endpointids_to_set: List[Tuple[str, str, Optional[str]]],
    endpointids_to_delete : List[Tuple[str, str, Optional[str]]]):

    context_endpoint_ids = get_service_endpointids(db_service)
    #LOGGER.info('[classify_endpointids] context_endpoint_ids = {:s}'.format(str(context_endpoint_ids)))
    context_endpoint_ids = set(context_endpoint_ids)
    #LOGGER.info('[classify_endpointids] context_endpoint_ids = {:s}'.format(str(context_endpoint_ids)))

    request_endpoint_ids = grpc_endpointids_to_raw(service_endpoint_ids)
    #LOGGER.info('[classify_endpointids] request_endpoint_ids = {:s}'.format(str(request_endpoint_ids)))

    if len(service_endpoint_ids) != 2: return
    for endpoint_id in request_endpoint_ids:
        #if endpoint_id not in context_endpoint_ids:
        #    endpointids_to_set.append(endpoint_id)
        #context_endpoint_ids.discard(endpoint_id)
        endpointids_to_set.append(endpoint_id)

    #for endpoint_id in context_endpoint_ids:
    #    endpointids_to_delete.append(endpoint_id)

    #LOGGER.info('[classify_endpointids] endpointids_to_set = {:s}'.format(str(endpointids_to_set)))
    #LOGGER.info('[classify_endpointids] endpointids_to_delete = {:s}'.format(str(endpointids_to_delete)))

def get_service_handler_class(
    service_handler_factory : ServiceHandlerFactory, db_service : ServiceModel, db_devices : Dict[str, DeviceModel]
    ) -> Optional[_ServiceHandler]:

    str_service_key = db_service.pk
    database = db_service.database

    # Assume all devices involved in the service must support at least one driver in common
    device_drivers = None
    for _,db_device in db_devices.items():
        db_driver_pks = db_device.references(DriverModel)
        db_driver_names = [DriverModel(database, pk).driver.value for pk,_ in db_driver_pks]
        if device_drivers is None:
            device_drivers = set(db_driver_names)
        else:
            device_drivers.intersection_update(db_driver_names)

    filter_fields = {
        FilterFieldEnum.SERVICE_TYPE.value  : db_service.service_type.value,    # must be supported
        FilterFieldEnum.DEVICE_DRIVER.value : device_drivers,                   # at least one must be supported
    }

    msg = 'Selecting service handler for service({:s}) with filter_fields({:s})...'
    LOGGER.info(msg.format(str(str_service_key), str(filter_fields)))
    service_handler_class = service_handler_factory.get_service_handler_class(**filter_fields)
    msg = 'ServiceHandler({:s}) selected for service({:s}) with filter_fields({:s})...'
    LOGGER.info(msg.format(str(service_handler_class.__name__), str(str_service_key), str(filter_fields)))
    return service_handler_class

def update_service(
        database : Database, context_client : ContextClient, device_client : DeviceClient,
        service_handler_factory : ServiceHandlerFactory, service : Service, connection : Connection
    ) -> ServiceModel:

    service_id = service.service_id
    service_uuid = service_id.service_uuid.uuid
    service_context_uuid = service_id.context_id.context_uuid.uuid
    str_service_key = key_to_str([service_context_uuid, service_uuid])

    # Sync before updating service to ensure we have devices, endpoints, constraints, and config rules to be
    # set/deleted before actuallymodifying them in the local in-memory database.

    sync_service_from_context(service_context_uuid, service_uuid, context_client, database)
    db_service = get_object(database, ServiceModel, str_service_key, raise_if_not_found=False)
    db_devices = sync_devices_from_context(context_client, database, db_service, service.service_endpoint_ids)

    if db_service is None: db_service,_ = update_service_in_local_database(database, service)
    LOGGER.info('[update_service] db_service = {:s}'.format(str(db_service.dump(
        include_endpoint_ids=True, include_constraints=True, include_config_rules=True))))

    resources_to_set    : List[Tuple[str, Any]] = [] # resource_key, resource_value
    resources_to_delete : List[Tuple[str, Any]] = [] # resource_key, resource_value
    classify_config_rules(db_service, service.service_config.config_rules, resources_to_set, resources_to_delete)

    constraints_to_set    : List[Tuple[str, str]] = [] # constraint_type, constraint_value
    constraints_to_delete : List[Tuple[str, str]] = [] # constraint_type, constraint_value
    classify_constraints(db_service, service.service_constraints, constraints_to_set, constraints_to_delete)

    endpointids_to_set    : List[Tuple[str, str, Optional[str]]] = [] # device_uuid, endpoint_uuid, topology_uuid
    endpointids_to_delete : List[Tuple[str, str, Optional[str]]] = [] # device_uuid, endpoint_uuid, topology_uuid
    classify_endpointids(db_service, service.service_endpoint_ids, endpointids_to_set, endpointids_to_delete)

    service_handler_class = get_service_handler_class(service_handler_factory, db_service, db_devices)
    service_handler_settings = {}
    service_handler : _ServiceHandler = service_handler_class(
        db_service, database, context_client, device_client, **service_handler_settings)

    errors = []

    if len(errors) == 0:
        results_deleteendpoint = service_handler.DeleteEndpoint(endpointids_to_delete)
        errors.extend(check_errors_deleteendpoint(endpointids_to_delete, results_deleteendpoint))

    if len(errors) == 0:
        results_deleteconstraint = service_handler.DeleteConstraint(constraints_to_delete)
        errors.extend(check_errors_deleteconstraint(constraints_to_delete, results_deleteconstraint))

    if len(errors) == 0:
        results_deleteconfig = service_handler.DeleteConfig(resources_to_delete)
        errors.extend(check_errors_deleteconfig(resources_to_delete, results_deleteconfig))

    if len(errors) == 0:
        results_setconfig = service_handler.SetConfig(resources_to_set)
        errors.extend(check_errors_setconfig(resources_to_set, results_setconfig))

    if len(errors) == 0:
        results_setconstraint = service_handler.SetConstraint(constraints_to_set)
        errors.extend(check_errors_setconstraint(constraints_to_set, results_setconstraint))

    if len(errors) == 0:
        results_setendpoint = service_handler.SetEndpoint(endpointids_to_set)
        errors.extend(check_errors_setendpoint(endpointids_to_set, results_setendpoint))

    if len(errors) > 0:
        raise OperationFailedException('UpdateService', extra_details=errors)

    LOGGER.info('[update_service] len(service.service_endpoint_ids) = {:d}'.format(len(service.service_endpoint_ids)))
    if len(service.service_endpoint_ids) >= 2:
        service.service_status.service_status = ServiceStatusEnum.SERVICESTATUS_ACTIVE

    db_service,_ = update_service_in_local_database(database, service)
    LOGGER.info('[update_service] db_service = {:s}'.format(str(db_service.dump(
        include_endpoint_ids=True, include_constraints=True, include_config_rules=True))))

    sync_service_to_context(db_service, context_client)
    context_client.SetConnection(connection)
    return db_service

def delete_service(
        database : Database, context_client : ContextClient, device_client : DeviceClient,
        service_handler_factory : ServiceHandlerFactory, service_id : ServiceId, connection : Connection
    ) -> None:

    context_client.RemoveConnection(connection.connection_id)

    service_uuid = service_id.service_uuid.uuid
    service_context_uuid = service_id.context_id.context_uuid.uuid
    str_service_key = key_to_str([service_context_uuid, service_uuid])

    # Sync before updating service to ensure we have devices, endpoints, constraints, and config rules to be
    # set/deleted before actuallymodifying them in the local in-memory database.

    sync_service_from_context(service_context_uuid, service_uuid, context_client, database)
    db_service : ServiceModel = get_object(database, ServiceModel, str_service_key, raise_if_not_found=False)
    if db_service is None: return
    LOGGER.info('[delete_service] db_service = {:s}'.format(str(db_service.dump(
        include_endpoint_ids=True, include_constraints=True, include_config_rules=True))))

    db_devices = sync_devices_from_context(context_client, database, db_service, [])

    resources_to_delete : List[Tuple[str, str]] = [     # resource_key, resource_value
        (config_rule[1], config_rule[2])
        for config_rule in get_config_rules(db_service.database, db_service.pk, 'running')
    ]

    constraints_to_delete : List[Tuple[str, str]] = [   # constraint_type, constraint_value
        (constraint[0], constraint[1])
        for constraint in get_constraints(db_service.database, db_service.pk, 'running')
    ]

    # device_uuid, endpoint_uuid, topology_uuid
    endpointids_to_delete : List[Tuple[str, str, Optional[str]]] = list(set(get_service_endpointids(db_service)))

    service_handler_class = get_service_handler_class(service_handler_factory, db_service, db_devices)
    service_handler_settings = {}
    service_handler : _ServiceHandler = service_handler_class(
        db_service, database, context_client, device_client, **service_handler_settings)

    errors = []

    if len(errors) == 0:
        results_deleteendpoint = service_handler.DeleteEndpoint(endpointids_to_delete)
        errors.extend(check_errors_deleteendpoint(endpointids_to_delete, results_deleteendpoint))

    if len(errors) == 0:
        results_deleteconstraint = service_handler.DeleteConstraint(constraints_to_delete)
        errors.extend(check_errors_deleteconstraint(constraints_to_delete, results_deleteconstraint))

    if len(errors) == 0:
        results_deleteconfig = service_handler.DeleteConfig(resources_to_delete)
        errors.extend(check_errors_deleteconfig(resources_to_delete, results_deleteconfig))

    if len(errors) > 0:
        raise OperationFailedException('DeleteService', extra_details=errors)

    delete_service_from_context(db_service, context_client)

    for db_service_endpoint_pk,_ in db_service.references(ServiceEndPointModel):
        ServiceEndPointModel(database, db_service_endpoint_pk).delete()

    db_running_config = ConfigModel(database, db_service.service_config_fk)
    for db_config_rule_pk,_ in db_running_config.references(ConfigRuleModel):
        ConfigRuleModel(database, db_config_rule_pk).delete()

    db_running_constraints = ConstraintsModel(database, db_service.service_constraints_fk)
    for db_constraint_pk,_ in db_running_constraints.references(ConstraintModel):
        ConstraintModel(database, db_constraint_pk).delete()

    db_service.delete()
    db_running_config.delete()
    db_running_constraints.delete()
