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

import org.onlab.packet.ARP;
import org.onlab.packet.DeserializationException;
import org.onlab.packet.EthType;
import org.onlab.packet.Ethernet;
import org.onlab.packet.Ip4Address;
import org.onlab.packet.IPv4;
import org.onlab.packet.MacAddress;
import org.onlab.packet.RADIUS;
import org.onlab.packet.TpPort;
import org.onlab.packet.UDP;

import org.onosproject.core.ApplicationId;
import org.onosproject.mastership.MastershipEvent;
import org.onosproject.mastership.MastershipListener;
import org.onosproject.mastership.MastershipService;
import org.onosproject.net.ConnectPoint;
import org.onosproject.net.device.DeviceEvent;
import org.onosproject.net.device.DeviceListener;
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.packet.DefaultOutboundPacket;
import org.onosproject.net.packet.InboundPacket;
import org.onosproject.net.packet.OutboundPacket;
import org.onosproject.net.packet.PacketContext;
import org.onosproject.net.packet.PacketService;

import org.opencord.sadis.BaseInformationService;
import org.opencord.sadis.SubscriberAndDeviceInformation;

import org.slf4j.Logger;

import com.google.common.collect.Maps;

import static org.onosproject.net.packet.PacketPriority.CONTROL;
import static org.slf4j.LoggerFactory.getLogger;

import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.Set;

/**
 * Handles communication with the RADIUS server through ports
 * of the SDN switches.
 */
public class PortBasedRadiusCommunicator implements RadiusCommunicator {

    // for verbose output
    private final Logger log = getLogger(getClass());

    // our unique identifier
    private ApplicationId appId;

    // to receive Packet-in events that we'll respond to
    PacketService packetService;

    DeviceService deviceService;

    MastershipService mastershipService;

    BaseInformationService<SubscriberAndDeviceInformation> subsService;

    // to store local mapping of IP Address and Serial No of Device
    private Map<Ip4Address, String> ipToSnMap;

    // connect points to the RADIUS server
    Set<ConnectPoint> radiusConnectPoints;

    // Parsed RADIUS server addresses
    protected InetAddress radiusIpAddress;

    // RADIUS server TCP port number
    protected short radiusServerPort;

    protected String radiusMacAddress;

    // NAS IP address
    protected InetAddress nasIpAddress;

    protected String nasMacAddress;

    // RADIUS server Vlan ID
    private short radiusVlanID;

    // RADIUS p-bit
    private byte radiusPBit;

    PacketCustomizer pktCustomizer;
    AaaManager aaaManager;

    ConnectPoint radiusServerConnectPoint = null;

    InnerMastershipListener changeListener = new InnerMastershipListener();
    InnerDeviceListener deviceListener = new InnerDeviceListener();

    PortBasedRadiusCommunicator(ApplicationId appId, PacketService pktService,
                                MastershipService masService, DeviceService devService,
                                BaseInformationService<SubscriberAndDeviceInformation> subsService,
                                PacketCustomizer pktCustomizer, AaaManager aaaManager) {
        this.appId = appId;
        this.packetService = pktService;
        this.mastershipService = masService;
        this.deviceService = devService;
        this.subsService = subsService;
        this.pktCustomizer = pktCustomizer;
        this.aaaManager = aaaManager;

        ipToSnMap = Maps.newConcurrentMap();
        mastershipService.addListener(changeListener);
        deviceService.addListener(deviceListener);

        log.info("Created PortBased");
    }

    private void initializeLocalState() {
        synchronized (this) {
            radiusServerConnectPoint = null;
            if (radiusConnectPoints != null) {
                // find a connect point through a device for which we are master
                for (ConnectPoint cp: radiusConnectPoints) {
                    if (mastershipService.isLocalMaster(cp.deviceId())) {
                        if (deviceService.isAvailable(cp.deviceId())) {
                            radiusServerConnectPoint = cp;
                        }
                        break;
                    }
                }
            }

            log.info("RADIUS connectPoint in initializeLocalState is {}", radiusServerConnectPoint);

            if (radiusServerConnectPoint == null) {
                log.error("Master of none, can't send radius Message to server");
            }
        }
    }

    @Override
    public void initializeLocalState(AaaConfig newCfg) {
        if (newCfg.nasIp() != null) {
            nasIpAddress = newCfg.nasIp();
        }
        if (newCfg.radiusIp() != null) {
            radiusIpAddress = newCfg.radiusIp();
        }
        if (newCfg.radiusMac() != null) {
            radiusMacAddress = newCfg.radiusMac();
        }
        if (newCfg.nasMac() != null) {
            nasMacAddress = newCfg.nasMac();
        }

        radiusServerPort = newCfg.radiusServerUdpPort();
        radiusVlanID = newCfg.radiusServerVlanId();
        radiusPBit = newCfg.radiusServerPBit();

        radiusConnectPoints = newCfg.radiusServerConnectPoints();

        initializeLocalState();
    }

    @Override
    public void clearLocalState() {
        mastershipService.removeListener(changeListener);
        deviceService.removeListener(deviceListener);
    }

    @Override
    public void deactivate() {
        mastershipService.removeListener(changeListener);
        deviceService.removeListener(deviceListener);
    }

    @Override
    public void requestIntercepts() {
        TrafficSelector.Builder selectorArpServer = DefaultTrafficSelector.builder()
                .matchEthType(Ethernet.TYPE_ARP);
        packetService.requestPackets(selectorArpServer.build(), CONTROL, appId);

        TrafficSelector.Builder selectorServer = DefaultTrafficSelector.builder()
                .matchEthType(Ethernet.TYPE_IPV4)
                .matchIPProtocol(IPv4.PROTOCOL_UDP)
                .matchUdpSrc(TpPort.tpPort(radiusServerPort));
        packetService.requestPackets(selectorServer.build(), CONTROL, appId);

        TrafficSelector.Builder selector = DefaultTrafficSelector.builder();
               selector.matchEthType(EthType.EtherType.EAPOL.ethType().toShort());
               packetService.requestPackets(selector.build(), CONTROL, appId);
    }

    @Override
    public void withdrawIntercepts() {
        TrafficSelector.Builder selectorArpServer = DefaultTrafficSelector.builder()
                .matchEthType(Ethernet.TYPE_ARP);
        packetService.cancelPackets(selectorArpServer.build(), CONTROL, appId);

        TrafficSelector.Builder selectorServer = DefaultTrafficSelector.builder()
                .matchEthType(Ethernet.TYPE_IPV4)
                .matchIPProtocol(IPv4.PROTOCOL_UDP)
                .matchUdpSrc(TpPort.tpPort(radiusServerPort));
        packetService.cancelPackets(selectorServer.build(), CONTROL, appId);

        TrafficSelector.Builder selector = DefaultTrafficSelector.builder();
        selector.matchEthType(EthType.EtherType.EAPOL.ethType().toShort());
        packetService.cancelPackets(selector.build(), CONTROL, appId);
    }

    @Override
    public void sendRadiusPacket(RADIUS radiusPacket, InboundPacket inPkt) {
        // create the packet
        Ethernet ethReply = new Ethernet();
        ethReply.setSourceMACAddress(nasMacAddress);
        ethReply.setDestinationMACAddress(radiusMacAddress);
        ethReply.setEtherType(Ethernet.TYPE_IPV4);
        ethReply.setVlanID(radiusVlanID);
        ethReply.setPriorityCode(radiusPBit);

        IPv4 ipv4Packet = new IPv4();
        ipv4Packet.setTtl((byte) 64);
        ipv4Packet.setSourceAddress(Ip4Address.
                valueOf(nasIpAddress).toInt());
        ipv4Packet.setDestinationAddress(Ip4Address.
                valueOf(radiusIpAddress).toInt());

        UDP udpPacket = new UDP();
        udpPacket.setSourcePort(radiusServerPort);
        udpPacket.setDestinationPort(radiusServerPort);

        udpPacket.setPayload(radiusPacket);
        ipv4Packet.setPayload(udpPacket);
        ethReply.setPayload(ipv4Packet);

        // store the IP address and SN of the device, later to be used
        // for ARP responses
        String serialNo = deviceService.getDevice(inPkt.
                receivedFrom().deviceId()).serialNumber();

        SubscriberAndDeviceInformation deviceInfo = subsService.get(serialNo);

        if (deviceInfo == null) {
            log.warn("No Device found with SN {}", serialNo);
            return;
        }
        ipToSnMap.put(deviceInfo.ipAddress(), serialNo);

        // send the message out
        sendFromRadiusServerPort(pktCustomizer.
                customizeEthernetIPHeaders(ethReply, inPkt));
    }

    /**
     * Sends packet to the RADIUS server using one of the switch ports.
     *
     * @param packet Ethernet packet to be sent
     */
    private void sendFromRadiusServerPort(Ethernet packet) {
        if (radiusServerConnectPoint != null) {
            log.trace("AAA Manager sending Ethernet packet = {}", packet);
            TrafficTreatment t = DefaultTrafficTreatment.builder()
                    .setOutput(radiusServerConnectPoint.port()).build();
            OutboundPacket o = new DefaultOutboundPacket(
                    radiusServerConnectPoint.deviceId(), t, ByteBuffer.wrap(packet.serialize()));
            packetService.emit(o);
        } else {
            log.error("Unable to send RADIUS packet, connectPoint is null");
        }
    }

    @Override
    public void handlePacketFromServer(PacketContext context) {
        // Extract the original Ethernet frame from the packet information
        InboundPacket pkt = context.inPacket();
        Ethernet ethPkt = pkt.parsed();
        if (ethPkt == null) {
            return;
        }

        // identify if incoming packet
        switch (EthType.EtherType.lookup(ethPkt.getEtherType())) {
            case ARP:
                handleArpPacketFromServer(context);
                break;
            case IPV4:
                handleIPv4PacketFromServer(context);
                break;
            default:
                log.debug("Skipping Ethernet packet type {}",
                        EthType.EtherType.lookup(ethPkt.getEtherType()));
        }
    }

    /**
     * Handles ARP packets from RADIUS server.
     *
     * @param context Context for the packet
     */
    private void handleArpPacketFromServer(PacketContext context) {
        // Extract the original Ethernet frame from the packet information
        InboundPacket pkt = context.inPacket();
        Ethernet ethPkt = pkt.parsed();
        if (ethPkt == null) {
            return;
        }

        ARP arpPacket = (ARP) ethPkt.getPayload();

        Ip4Address targetAddress = Ip4Address.valueOf(arpPacket.
                getTargetProtocolAddress());

        String serialNo = ipToSnMap.get(targetAddress);
        if (serialNo == null) {
            log.info("No mapping found for ARP reply, target address {}",
                    targetAddress);
            return;
        }
        MacAddress senderMac = subsService.get(serialNo).hardwareIdentifier();
        if (senderMac == null) {
            log.warn("ARP resolution, MAC address not found for SN {}", serialNo);
            return;
        }

        ARP arpReply = (ARP) arpPacket.clone();
        arpReply.setOpCode(ARP.OP_REPLY);
        arpReply.setTargetProtocolAddress(arpPacket.getSenderProtocolAddress());
        arpReply.setTargetHardwareAddress(arpPacket.getSenderHardwareAddress());
        arpReply.setSenderProtocolAddress(arpPacket.getTargetProtocolAddress());
        arpReply.setSenderHardwareAddress(senderMac.toBytes());

        log.debug("AAA Manager: Query for ARP of IP : {}", arpPacket.getTargetProtocolAddress());

        // Ethernet Frame.
        Ethernet ethReply = new Ethernet();
        ethReply.setSourceMACAddress(senderMac);
        ethReply.setDestinationMACAddress(ethPkt.getSourceMAC());
        ethReply.setEtherType(Ethernet.TYPE_ARP);
        ethReply.setVlanID(radiusVlanID);
        ethReply.setPriorityCode(ethPkt.getPriorityCode());

        ethReply.setPayload(arpReply);
        sendFromRadiusServerPort(ethReply);
    }

    /**
     * Handles IP packets from RADIUS server.
     *
     * @param context Context for the packet
     */
    private void handleIPv4PacketFromServer(PacketContext context) {
        // Extract the original Ethernet frame from the packet information
        InboundPacket pkt = context.inPacket();
        Ethernet ethPkt = pkt.parsed();
        if (ethPkt == null) {
            return;
        }

        IPv4 ipv4Packet = (IPv4) ethPkt.getPayload();

        if (ipv4Packet.getProtocol() == IPv4.PROTOCOL_UDP) {
            UDP udpPacket = (UDP) ipv4Packet.getPayload();

            if (udpPacket.getSourcePort() == radiusServerPort) {
                //This packet is RADIUS packet from the server.
                RADIUS radiusMsg;
                try {
                    radiusMsg =
                            RADIUS.deserializer()
                                    .deserialize(udpPacket.serialize(),
                                            8,
                                            udpPacket.getLength() - 8);
                    try {
                        aaaManager.handleRadiusPacket(radiusMsg);
                    }  catch (StateMachineException sme) {
                        log.error("Illegal state machine operation", sme);
                    }
                } catch (DeserializationException dex) {
                    log.error("Cannot deserialize packet", dex);
                }
            }
        }
    }

    /**
     * Handles Mastership changes for the devices which connect
     * to the RADIUS server.
     */
    private class InnerMastershipListener implements MastershipListener {
        @Override
        public void event(MastershipEvent event) {
            if (radiusServerConnectPoint != null &&
                    radiusServerConnectPoint.deviceId().
                            equals(event.subject())) {
                log.trace("Mastership Event recevived for {}", event.subject());
                // mastership of the device for our connect point has changed
                // reselect
                initializeLocalState();
            }
        }
    }

    /**
     * Handles Device status change for the devices which connect
     * to the RADIUS server.
     */
    private class InnerDeviceListener implements DeviceListener {
        @Override
        public void event(DeviceEvent event) {
            log.trace("Device Event recevived for {} event {}", event.subject(), event.type());
            if (radiusServerConnectPoint == null) {
                switch (event.type()) {
                    case DEVICE_ADDED:
                    case DEVICE_AVAILABILITY_CHANGED:
                        // some device is available check if we can get one
                        initializeLocalState();
                        break;
                    default:
                        break;
                }
                return;
            }
            if (radiusServerConnectPoint.deviceId().
                    equals(event.subject().id())) {
                switch (event.type()) {
                    case DEVICE_AVAILABILITY_CHANGED:
                    case DEVICE_REMOVED:
                    case DEVICE_SUSPENDED:
                        // state of our device has changed, check if we need
                        // to re-select
                        initializeLocalState();
                        break;
                    default:
                        break;
                }
            }
        }
    }
}
