/*
 * Copyright 2017-present Open Networking Laboratory
 *
 * 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.
 */

package org.opencord.ce.local.bigswitch;

import com.google.common.base.Strings;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import org.apache.felix.scr.annotations.Activate;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Deactivate;
import org.apache.felix.scr.annotations.Modified;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Reference;
import org.apache.felix.scr.annotations.ReferenceCardinality;
import org.apache.felix.scr.annotations.Service;
import org.onosproject.cfg.ComponentConfigService;
import org.onosproject.core.ApplicationId;
import org.onosproject.core.CoreService;
import org.onosproject.event.AbstractListenerManager;
import org.onosproject.net.AnnotationKeys;
import org.onosproject.net.ConnectPoint;
import org.onosproject.net.DefaultAnnotations;
import org.onosproject.net.Device;
import org.onosproject.net.Port;
import org.onosproject.net.PortNumber;
import org.onosproject.net.config.ConfigFactory;
import org.onosproject.net.config.NetworkConfigEvent;
import org.onosproject.net.config.NetworkConfigListener;
import org.onosproject.net.config.NetworkConfigRegistry;
import org.onosproject.net.config.NetworkConfigService;
import org.onosproject.net.device.DefaultPortDescription;
import org.onosproject.net.device.DeviceEvent;
import org.onosproject.net.device.DeviceListener;
import org.onosproject.net.device.DeviceService;
import org.onosproject.net.device.PortDescription;
import org.onosproject.net.domain.DomainId;
import org.onosproject.store.serializers.KryoNamespaces;
import org.onosproject.store.service.AtomicCounter;
import org.onosproject.store.service.ConsistentMap;
import org.onosproject.store.service.Serializer;
import org.onosproject.store.service.StorageService;
import org.onosproject.store.service.Versioned;
import org.opencord.ce.api.models.CarrierEthernetNetworkInterface;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;

import java.util.Dictionary;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

import static org.onlab.util.Tools.get;
import static org.onosproject.net.config.basics.SubjectFactories.APP_SUBJECT_FACTORY;
import static org.opencord.ce.api.services.channel.Symbols.MEF_PORT_TYPE;
import static org.opencord.ce.local.bigswitch.MefPortsConfig.INTERLINK_ID;
import static org.opencord.ce.local.bigswitch.MefPortsConfig.MEF_PORTS;
import static org.opencord.ce.local.bigswitch.MefPortsConfig.MefPortConfig;
import static org.slf4j.LoggerFactory.getLogger;

/**
 * Listens for edge and port changes in the underlying data path and
 * exposes a big switch abstraction.
 */
@Component(immediate = true)
@Service
public class BigSwitchManager
        extends AbstractListenerManager<BigSwitchEvent, BigSwitchListener>
        implements BigSwitchService {
    private static final Logger log = getLogger(BigSwitchManager.class);
    private static final String APP_NAME = "org.opencord.ce.local.bigswitch";

    public static final String DEFAULT_DOMAIN_ID = "local-domain";

    private static final String PORT_MAP = "ecord-port-map";
    private static final String PORT_COUNTER = "ecord-port-counter";
    private static final Serializer SERIALIZER = Serializer.using(KryoNamespaces.API);
    private ApplicationId appId;
    private final NetworkConfigListener configListener = new InternalConfigListener();

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected ComponentConfigService componentConfigService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected CoreService coreService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected NetworkConfigRegistry configRegistry;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected DeviceService deviceService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected StorageService storageService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected NetworkConfigService networkConfigService;

    private static final String DOMAIN_ID = "domainId";
    @Property(name = DOMAIN_ID, value = DEFAULT_DOMAIN_ID, label = "Domain ID where this ONOS is running")
    private String domainId = DEFAULT_DOMAIN_ID;

    private final ExecutorService executorService =
            Executors.newSingleThreadExecutor();

    // MEF ports given by configuration
    private static List<MefPortConfig> mefPortConfigs;

    /**
     * Holds the physical port to virtual port number mapping.
     */
    private ConsistentMap<ConnectPoint, Long> portMap;

    // Counter for virtual port numbers
    private AtomicCounter portCounter;

    // TODO listen to portMap event and populate Cache to properly deal with
    // TODO distributed deployment.
    /**
     * History of physical port to virtual port number mapping.
     * Intended to avoid virtual port number explosion.
     */
    private LoadingCache<ConnectPoint, Long> p2vMap;

    // TODO: Add other listeners once we decide what an edge really is
    private DeviceListener deviceListener = new InternalDeviceListener();

    private final ConfigFactory<ApplicationId, MefPortsConfig> configFactory =
            new ConfigFactory<ApplicationId, MefPortsConfig>(APP_SUBJECT_FACTORY,
                    MefPortsConfig.class, MEF_PORTS, true) {
        @Override
        public MefPortsConfig createConfig() {
            return new MefPortsConfig();
        }
    };

    @Activate
    public void activate() {
        appId = coreService.registerApplication(APP_NAME);
        componentConfigService.registerProperties(getClass());
        configRegistry.registerConfigFactory(configFactory);
        networkConfigService.addListener(configListener);
        portMap = storageService.<ConnectPoint, Long>consistentMapBuilder()
                .withName(PORT_MAP)
                .withSerializer(SERIALIZER)
                .build();
        portCounter = storageService.atomicCounterBuilder()
                .withName(PORT_COUNTER)
                .withMeteringDisabled()
                .build()
                .asAtomicCounter();
        p2vMap = CacheBuilder.newBuilder()
                .build(CacheLoader.from(portCounter::getAndIncrement));

        eventDispatcher.addSink(BigSwitchEvent.class, listenerRegistry);
        portCounter.compareAndSet(0, 1);
        deviceService.addListener(deviceListener);
        log.info("Started");
    }

    @Deactivate
    public void deactivate() {
        networkConfigService.removeListener(configListener);
        configRegistry.unregisterConfigFactory(configFactory);
        deviceService.removeListener(deviceListener);
        componentConfigService.unregisterProperties(getClass(), false);
        log.info("Stopped");
    }

    @Modified
    public void modified(ComponentContext context) {
        Dictionary<?, ?> properties = context != null ? context.getProperties() : new Properties();

        String d = get(properties, DOMAIN_ID);
        if (!Strings.isNullOrEmpty(d)) {
            // TODO: signal new domain id to global
            domainId = d;
        }

        log.info("Domain ID set to {}", domainId());
    }

    @Override
    public List<PortDescription> getPorts() {
        return portMap.keySet().stream()
                .map(this::toVirtualPortDescription)
                .filter(Objects::nonNull)
                .collect(Collectors.toList());
    }

    @Override
    public PortNumber getPort(ConnectPoint port) {
        // FIXME: error-check and seriously think about a better method definition.
        Versioned<Long> portNo = portMap.get(port);
        if (Versioned.valueOrNull(portNo) != null) {
            return PortNumber.portNumber(portNo.value());
        } else {
            return null;
        }
    }

    @Override
    public Optional<ConnectPoint> connectPointFromVirtPort(PortNumber portNumber) {
        return portMap.asJavaMap().entrySet()
                .stream()
                .filter(entry -> Objects.equals(entry.getValue(), portNumber.toLong()))
                .map(Map.Entry::getKey)
                .findFirst();
    }

    @Override
    public DomainId domainId() {
        return DomainId.domainId(domainId);
    }

    /**
     * Convert connect point to port description.
     *
     * @param cp connect point of physical port
     * @return port description of virtual big switch port
     */
    private PortDescription toVirtualPortDescription(ConnectPoint cp) {
        Port p = deviceService.getPort(cp.deviceId(), cp.port());
        if (p == null) {
            return null;
        }
        // copy annotation
        DefaultAnnotations.Builder annot = DefaultAnnotations.builder();
        p.annotations().keys()
                .forEach(k -> annot.set(k, p.annotations().value(k)));
        annot.set(DOMAIN_ID, domainId);
        // add annotation about underlying physical connect-point
        Device device = deviceService.getDevice(cp.deviceId());
        if (device.annotations().keys()
                .contains(AnnotationKeys.LONGITUDE) && device.annotations().keys()
                .contains(AnnotationKeys.LATITUDE)) {
            annot.set(AnnotationKeys.LONGITUDE,
                    device.annotations().value(AnnotationKeys.LONGITUDE));
            annot.set(AnnotationKeys.LATITUDE, device.annotations().value(AnnotationKeys.LATITUDE));
        }

        // this is not really efficient...
        Optional<MefPortConfig> mefPortConfig =
                mefPortConfigs.stream().filter(item -> item.cp().equals(cp)).findFirst();
        if (mefPortConfig.isPresent()) {
            annot.set(MEF_PORT_TYPE, mefPortConfig.get().mefType().toString());
            if (!mefPortConfig.get().interlinkId().id().equals("internal")) {
                annot.set(INTERLINK_ID, mefPortConfig.get().interlinkId().id());
            }
        } else {
            annot.set(MEF_PORT_TYPE, CarrierEthernetNetworkInterface.Type.GENERIC.toString());
        }

        Long vPortNo = Versioned.valueOrNull(portMap.get(cp));
        if (vPortNo == null) {
            return null;
        }
        PortNumber portNumber = PortNumber.portNumber(vPortNo);

        return new DefaultPortDescription(portNumber, p.isEnabled(), p.type(),
                p.portSpeed(), annot.build());
    }

    private void buildPorts() {
        /*
        edgePortService.getEdgePoints()
                .forEach(cp -> {
                    log.info("DEBUG: {}", cp.toString());
                    if (isPortRelevant(cp)) {
                        portMap.put(cp, getVirtualPortNumber(cp));
                    }
                });
                */
        deviceService.getDevices().forEach(device ->
            deviceService.getPorts(device.id()).forEach(port -> {
                        ConnectPoint cp = new ConnectPoint(device.id(), port.number());
                        if (isPortRelevant(cp)) {
                            portMap.put(cp, getVirtualPortNumber(cp));
                        }
                    }
            ));
    }

    private Long getVirtualPortNumber(ConnectPoint cp) {
        return p2vMap.getUnchecked(cp);
    }

    private void readConfig() {
        mefPortConfigs = configRegistry.getConfig(appId, MefPortsConfig.class).mefPortConfigs();

        if (mefPortConfigs != null) {
            buildPorts();
            log.info("Notifying Bigswitch presence..");
            if (portMap.size() > 0) {
                post(new BigSwitchEvent(BigSwitchEvent.Type.DEVICE_CREATED, null, getPorts()));
            }
        }
    }

    private class InternalDeviceListener implements DeviceListener {
        @Override
        public boolean isRelevant(DeviceEvent event) {
            // Only listen for real devices
            return !event.subject().type().equals(Device.Type.VIRTUAL) &&
                    (event.type().equals(DeviceEvent.Type.PORT_ADDED) ||
                    event.type().equals(DeviceEvent.Type.PORT_UPDATED) ||
                    event.type().equals(DeviceEvent.Type.PORT_REMOVED));
        }

        @Override
        public void event(DeviceEvent event) {
            log.debug("Device event {} {} {}", event.subject(), event.port(), event.type());
            ConnectPoint cp = new ConnectPoint(event.subject().id(), event.port().number());
            if (!isPortRelevant(cp)) {
                return;
            }
            BigSwitchEvent.Type bigSwitchEvent = null;

            switch (event.type()) {
                // Ignore most of these for now
                case DEVICE_ADDED:
                case DEVICE_AVAILABILITY_CHANGED:
                case DEVICE_REMOVED:
                case DEVICE_SUSPENDED:
                case DEVICE_UPDATED:
                case PORT_ADDED:
                    portMap.put(cp, getVirtualPortNumber(cp));
                    bigSwitchEvent = BigSwitchEvent.Type.PORT_ADDED;
                    break;
                case PORT_REMOVED:
                    portMap.remove(cp, getVirtualPortNumber(cp));
                    bigSwitchEvent = BigSwitchEvent.Type.PORT_REMOVED;
                    break;
                case PORT_STATS_UPDATED:
                    break;
                case PORT_UPDATED:
                    bigSwitchEvent = BigSwitchEvent.Type.PORT_UPDATED;
                    // Update if state of existing edge changed
                    break;
                default:
                    break;

            }
            if (bigSwitchEvent != null && portMap.containsKey(cp) && mefPortConfigs != null) {
                PortDescription descr = toVirtualPortDescription(cp);
                if (descr != null) {
                    post(new BigSwitchEvent(bigSwitchEvent,
                            descr, getPorts()));
                }
            }
        }
    }

    private class InternalConfigListener implements NetworkConfigListener {

        @Override
        public void event(NetworkConfigEvent event) {
            if (!event.configClass().equals(MefPortsConfig.class)) {
                return;
            }
            switch (event.type()) {
                case CONFIG_ADDED:
                    log.info("Network configuration added");
                    executorService.execute(BigSwitchManager.this::readConfig);
                    break;
                case CONFIG_UPDATED:
                    log.info("Network configuration updated");
                    executorService.execute(BigSwitchManager.this::readConfig);
                    break;
                case CONFIG_REMOVED:
                    // TODO
                    break;
                default:
                        break;
            }
        }
    }

    private boolean isPortRelevant(ConnectPoint cp) {
        if (mefPortConfigs == null) {
            return false;
        }
        for (MefPortConfig portConfig : mefPortConfigs) {
            if (portConfig.cp().equals(cp)) {
                return true;
            }
        }
        return false;
    }
}
