/*
 * Copyright 2016-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.cordvtn.impl;

import com.google.common.base.Strings;
import com.google.common.collect.Maps;
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.onlab.packet.ARP;
import org.onlab.packet.EthType;
import org.onlab.packet.Ethernet;
import org.onlab.packet.Ip4Address;
import org.onlab.packet.IpAddress;
import org.onlab.packet.MacAddress;
import org.onlab.util.Tools;
import org.onosproject.cfg.ComponentConfigService;
import org.onosproject.core.ApplicationId;
import org.onosproject.core.CoreService;
import org.onosproject.net.DeviceId;
import org.onosproject.net.Host;
import org.onosproject.net.HostId;
import org.onosproject.net.Port;
import org.onosproject.net.PortNumber;
import org.onosproject.net.config.NetworkConfigEvent;
import org.onosproject.net.config.NetworkConfigListener;
import org.onosproject.net.config.NetworkConfigService;
import org.onosproject.net.device.DeviceService;
import org.onosproject.net.flow.DefaultTrafficSelector;
import org.onosproject.net.flow.DefaultTrafficTreatment;
import org.onosproject.net.flow.TrafficSelector;
import org.onosproject.net.flow.TrafficTreatment;
import org.onosproject.net.host.HostService;
import org.onosproject.net.packet.DefaultOutboundPacket;
import org.onosproject.net.packet.PacketContext;
import org.onosproject.net.packet.PacketPriority;
import org.onosproject.net.packet.PacketProcessor;
import org.onosproject.net.packet.PacketService;
import org.opencord.cordvtn.api.Constants;
import org.opencord.cordvtn.api.CordVtnConfig;
import org.opencord.cordvtn.api.core.Instance;
import org.opencord.cordvtn.api.core.ServiceNetworkEvent;
import org.opencord.cordvtn.api.core.ServiceNetworkListener;
import org.opencord.cordvtn.api.core.ServiceNetworkService;
import org.opencord.cordvtn.api.net.ServiceNetwork;
import org.opencord.cordvtn.api.node.CordVtnNode;
import org.opencord.cordvtn.api.node.CordVtnNodeService;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;

import java.nio.ByteBuffer;
import java.util.Dictionary;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.onosproject.net.AnnotationKeys.PORT_NAME;
import static org.opencord.cordvtn.api.Constants.DEFAULT_GATEWAY_MAC_STR;
import static org.opencord.cordvtn.api.net.ServiceNetwork.NetworkType.*;
import static org.slf4j.LoggerFactory.getLogger;

/**
 * Handles ARP requests for virtual network service IPs.
 */
@Component(immediate = true)
public class CordVtnArpProxy {
    protected final Logger log = getLogger(getClass());

    private static final String PRIVATE_GATEWAY_MAC = "privateGatewayMac";

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected PacketService packetService;

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

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected HostService hostService;

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

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

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

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected CordVtnNodeService nodeService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
    protected ServiceNetworkService snetService;

    @Property(name = PRIVATE_GATEWAY_MAC, value = DEFAULT_GATEWAY_MAC_STR,
            label = "Fake MAC address for virtual network gateway")
    private String privateGatewayMacStr = DEFAULT_GATEWAY_MAC_STR;
    private MacAddress privateGatewayMac = MacAddress.valueOf(privateGatewayMacStr);

    private final PacketProcessor packetProcessor = new InternalPacketProcessor();
    private final Map<IpAddress, MacAddress> gateways = Maps.newConcurrentMap();

    private NetworkConfigListener configListener = new InternalConfigListener();
    private ServiceNetworkListener snetListener = new InternalServiceNetworkListener();
    private ApplicationId appId;

    @Activate
    protected void activate() {
        appId = coreService.registerApplication(Constants.CORDVTN_APP_ID);
        compConfigService.registerProperties(getClass());

        netConfigService.addListener(configListener);
        readPublicGateways();
        snetService.addListener(snetListener);
        readPrivateGateways();

        packetService.addProcessor(packetProcessor, PacketProcessor.director(0));
        requestPacket();

        log.info("Started");
    }

    @Deactivate
    protected void deactivate() {
        packetService.removeProcessor(packetProcessor);
        snetService.removeListener(snetListener);
        netConfigService.removeListener(configListener);
        compConfigService.unregisterProperties(getClass(), false);

        log.info("Stopped");
    }

    @Modified
    protected void modified(ComponentContext context) {
        Dictionary<?, ?> properties = context.getProperties();
        String updatedMac;

        updatedMac = Tools.get(properties, PRIVATE_GATEWAY_MAC);
        if (!Strings.isNullOrEmpty(updatedMac) &&
                !updatedMac.equals(privateGatewayMacStr)) {
            privateGatewayMacStr = updatedMac;
            privateGatewayMac = MacAddress.valueOf(privateGatewayMacStr);
        }

        log.info("Modified");
    }

    /**
     * Requests ARP packet.
     */
    private void requestPacket() {
        TrafficSelector selector = DefaultTrafficSelector.builder()
                .matchEthType(EthType.EtherType.ARP.ethType().toShort())
                .build();

        packetService.requestPackets(
                selector,
                PacketPriority.CONTROL,
                appId,
                Optional.empty());
    }

    /**
     * Cancels ARP packet.
     */
    private void cancelPacket() {
        TrafficSelector selector = DefaultTrafficSelector.builder()
                .matchEthType(EthType.EtherType.ARP.ethType().toShort())
                .build();

        packetService.cancelPackets(
                selector,
                PacketPriority.CONTROL,
                appId,
                Optional.empty());
    }

    private void readPrivateGateways() {
        snetService.serviceNetworks().stream()
                .filter(net -> net.type() == PRIVATE || net.type() == VSG)
                .filter(net -> net.serviceIp() != null)
                .forEach(net -> addGateway(net.serviceIp(), privateGatewayMac));
    }

    /**
     * Adds a given gateway IP and MAC address to this ARP proxy.
     *
     * @param gatewayIp gateway ip address
     * @param gatewayMac gateway mac address
     */
    private void addGateway(IpAddress gatewayIp, MacAddress gatewayMac) {
        checkNotNull(gatewayIp, "Gateway IP address cannot be null");
        checkArgument(gatewayMac != null && gatewayMac != MacAddress.NONE,
                      "Gateway MAC address cannot be null or NONE");

        MacAddress existing = gateways.get(gatewayIp);
        if (existing != null && !existing.equals(privateGatewayMac) &&
                gatewayMac.equals(privateGatewayMac)) {
            // this is public gateway IP and MAC configured via netcfg
            // don't update with private gateway MAC
            return;
        }
        gateways.put(gatewayIp, gatewayMac);
        log.debug("Added ARP proxy entry IP:{} MAC:{}", gatewayIp, gatewayMac);
    }

    /**
     * Removes a given service IP address from this ARP proxy.
     *
     * @param gatewayIp gateway ip address
     */
    private void removeGateway(IpAddress gatewayIp) {
        checkNotNull(gatewayIp);
        MacAddress existing = gateways.get(gatewayIp);
        if (existing == null) {
            return;
        }
        if (!existing.equals(privateGatewayMac)) {
            // this is public gateway IP and MAC configured via netcfg
            // do nothing
            return;
        }
        gateways.remove(gatewayIp);
        log.debug("Removed ARP proxy entry for IP:{} MAC: {}", gatewayIp, existing);
    }

    /**
     * Emits ARP reply with fake MAC address for a given ARP request.
     * It only handles requests for the registered gateway IPs and host IPs.
     *
     * @param context packet context
     * @param ethPacket ethernet packet
     */
    private void processArpRequest(PacketContext context, Ethernet ethPacket) {
        ARP arpPacket = (ARP) ethPacket.getPayload();
        Ip4Address targetIp = Ip4Address.valueOf(arpPacket.getTargetProtocolAddress());

        MacAddress gatewayMac = gateways.get(targetIp);
        MacAddress replyMac = gatewayMac != null ? gatewayMac :
                getMacFromHostService(targetIp);

        if (replyMac.equals(MacAddress.NONE)) {
            log.trace("Failed to find MAC for {}", targetIp);
            forwardManagementArpRequest(context, ethPacket);
            return;
        }

        Ethernet ethReply = ARP.buildArpReply(
                targetIp,
                replyMac,
                ethPacket);

        TrafficTreatment treatment = DefaultTrafficTreatment.builder()
                .setOutput(context.inPacket().receivedFrom().port())
                .build();

        packetService.emit(new DefaultOutboundPacket(
                context.inPacket().receivedFrom().deviceId(),
                treatment,
                ByteBuffer.wrap(ethReply.serialize())));

        context.block();
    }

    private void processArpReply(PacketContext context, Ethernet ethPacket) {
        ARP arpPacket = (ARP) ethPacket.getPayload();
        Ip4Address targetIp = Ip4Address.valueOf(arpPacket.getTargetProtocolAddress());

        DeviceId deviceId = context.inPacket().receivedFrom().deviceId();
        Host host = hostService.getHostsByIp(targetIp).stream()
                .filter(h -> h.location().deviceId().equals(deviceId))
                .findFirst()
                .orElse(null);

        if (host == null) {
            // do nothing for the unknown ARP reply
            log.trace("No host found for {} in {}", targetIp, deviceId);
            context.block();
            return;
        }

        TrafficTreatment treatment = DefaultTrafficTreatment.builder()
                .setOutput(host.location().port())
                .build();

        packetService.emit(new DefaultOutboundPacket(
                deviceId,
                treatment,
                ByteBuffer.wrap(ethPacket.serialize())));

        context.block();
    }

    private void forwardManagementArpRequest(PacketContext context, Ethernet ethPacket) {
        DeviceId deviceId = context.inPacket().receivedFrom().deviceId();
        PortNumber hostMgmtPort = hostMgmtPort(deviceId);
        Host host = hostService.getHost(HostId.hostId(ethPacket.getSourceMAC()));

        if (host == null ||
                Instance.of(host).netType() != MANAGEMENT_HOST ||
                hostMgmtPort == null) {
            context.block();
            return;
        }

        TrafficTreatment treatment = DefaultTrafficTreatment.builder()
                .setOutput(hostMgmtPort)
                .build();

        packetService.emit(new DefaultOutboundPacket(
                context.inPacket().receivedFrom().deviceId(),
                treatment,
                ByteBuffer.wrap(ethPacket.serialize())));

        context.block();
    }

    private PortNumber hostMgmtPort(DeviceId deviceId) {
        CordVtnNode node = nodeService.node(deviceId);
        if (node == null || node.hostManagementInterface() == null) {
            return null;
        }
        Optional<Port> port = deviceService.getPorts(deviceId).stream()
                .filter(p -> p.annotations().value(PORT_NAME)
                        .equals(node.hostManagementInterface()) &&
                        p.isEnabled())
                .findAny();
        return port.isPresent() ? port.get().number() : null;
    }

    /**
     * Emits gratuitous ARP when a gateway mac address has been changed.
     *
     * @param gatewayIp gateway ip address to update MAC
     * @param instances set of instances to send gratuitous ARP packet
     */
    private void sendGratuitousArp(IpAddress gatewayIp, Set<Instance> instances) {
        MacAddress gatewayMac = gateways.get(gatewayIp);
        if (gatewayMac == null) {
            log.debug("Gateway {} is not registered to ARP proxy", gatewayIp);
            return;
        }

        Ethernet ethArp = buildGratuitousArp(gatewayIp.getIp4Address(), gatewayMac);
        instances.forEach(instance -> {
            TrafficTreatment treatment = DefaultTrafficTreatment.builder()
                    .setOutput(instance.portNumber())
                    .build();

            packetService.emit(new DefaultOutboundPacket(
                    instance.deviceId(),
                    treatment,
                    ByteBuffer.wrap(ethArp.serialize())));
        });
    }

    /**
     * Builds gratuitous ARP packet with a given IP and MAC address.
     *
     * @param ip ip address for TPA and SPA
     * @param mac new mac address
     * @return ethernet packet
     */
    private Ethernet buildGratuitousArp(IpAddress ip, MacAddress mac) {
        Ethernet eth = new Ethernet();

        eth.setEtherType(Ethernet.TYPE_ARP);
        eth.setSourceMACAddress(mac);
        eth.setDestinationMACAddress(MacAddress.BROADCAST);

        ARP arp = new ARP();
        arp.setOpCode(ARP.OP_REQUEST);
        arp.setHardwareType(ARP.HW_TYPE_ETHERNET);
        arp.setHardwareAddressLength((byte) Ethernet.DATALAYER_ADDRESS_LENGTH);
        arp.setProtocolType(ARP.PROTO_TYPE_IP);
        arp.setProtocolAddressLength((byte) Ip4Address.BYTE_LENGTH);

        arp.setSenderHardwareAddress(mac.toBytes());
        arp.setTargetHardwareAddress(MacAddress.BROADCAST.toBytes());
        arp.setSenderProtocolAddress(ip.getIp4Address().toOctets());
        arp.setTargetProtocolAddress(ip.getIp4Address().toOctets());

        eth.setPayload(arp);
        return eth;
    }

    /**
     * Returns MAC address of a host with a given target IP address by asking to
     * host service. It does not support overlapping IP.
     *
     * @param targetIp target ip
     * @return mac address, or NONE mac address if it fails to find the mac
     */
    private MacAddress getMacFromHostService(IpAddress targetIp) {
        checkNotNull(targetIp);

        Host host = hostService.getHostsByIp(targetIp)
                .stream()
                .findFirst()
                .orElse(null);

        if (host != null) {
            log.trace("Found MAC from host service for {}", targetIp);
            return host.mac();
        } else {
            return MacAddress.NONE;
        }
    }

    private class InternalPacketProcessor implements PacketProcessor {

        @Override
        public void process(PacketContext context) {
            if (context.isHandled()) {
                return;
            }
            Ethernet ethPacket = context.inPacket().parsed();
            if (ethPacket == null || ethPacket.getEtherType() != Ethernet.TYPE_ARP) {
                return;
            }

            ARP arpPacket = (ARP) ethPacket.getPayload();
            switch (arpPacket.getOpCode()) {
                case ARP.OP_REQUEST:
                    processArpRequest(context, ethPacket);
                    break;
                case ARP.OP_REPLY:
                    processArpReply(context, ethPacket);
                    break;
                default:
                    break;
            }
        }
    }

    private class InternalServiceNetworkListener implements ServiceNetworkListener {

        @Override
        public boolean isRelevant(ServiceNetworkEvent event) {
            ServiceNetwork snet = event.subject();
            return snet.serviceIp() != null;
        }

        @Override
        public void event(ServiceNetworkEvent event) {
            ServiceNetwork snet = event.subject();
            switch (event.type()) {
                case SERVICE_NETWORK_CREATED:
                case SERVICE_NETWORK_UPDATED:
                    addGateway(snet.serviceIp(), privateGatewayMac);
                    break;
                case SERVICE_NETWORK_REMOVED:
                    removeGateway(snet.serviceIp());
                    break;
                case SERVICE_PORT_CREATED:
                case SERVICE_PORT_UPDATED:
                case SERVICE_PORT_REMOVED:
                default:
                    // do nothing for the other events
                    break;
            }
        }
    }

    private void readPublicGateways() {
        CordVtnConfig config = netConfigService.getConfig(appId, CordVtnConfig.class);
        if (config == null) {
            log.warn("No configuration found");
            return;
        }

        config.publicGateways().entrySet().forEach(entry -> {
            addGateway(entry.getKey(), entry.getValue());
        });
        // TODO send gratuitous arp in case the MAC is changed
    }

    private class InternalConfigListener implements NetworkConfigListener {

        @Override
        public boolean isRelevant(NetworkConfigEvent event) {
            return event.configClass().equals(CordVtnConfig.class);
        }

        @Override
        public void event(NetworkConfigEvent event) {

            switch (event.type()) {
                case CONFIG_ADDED:
                case CONFIG_UPDATED:
                    readPublicGateways();
                    break;
                default:
                    break;
            }
        }
    }
}
