/*
 * 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.onosproject.p4tutorial.mytunnel;

import com.google.common.collect.Lists;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.onlab.packet.IpAddress;
import org.onosproject.core.ApplicationId;
import org.onosproject.core.CoreService;
import org.onosproject.net.DeviceId;
import org.onosproject.net.Host;
import org.onosproject.net.Link;
import org.onosproject.net.Path;
import org.onosproject.net.PortNumber;
import org.onosproject.net.flow.DefaultFlowRule;
import org.onosproject.net.flow.DefaultTrafficSelector;
import org.onosproject.net.flow.DefaultTrafficTreatment;
import org.onosproject.net.flow.FlowRule;
import org.onosproject.net.flow.FlowRuleService;
import org.onosproject.net.flow.criteria.PiCriterion;
import org.onosproject.net.host.HostEvent;
import org.onosproject.net.host.HostListener;
import org.onosproject.net.host.HostService;
import org.onosproject.net.pi.model.PiActionId;
import org.onosproject.net.pi.model.PiActionParamId;
import org.onosproject.net.pi.model.PiMatchFieldId;
import org.onosproject.net.pi.model.PiTableId;
import org.onosproject.net.pi.runtime.PiAction;
import org.onosproject.net.pi.runtime.PiActionParam;
import org.onosproject.net.topology.Topology;
import org.onosproject.net.topology.TopologyService;
import org.slf4j.Logger;

import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;

import static org.slf4j.LoggerFactory.getLogger;

/**
 * MyTunnel application which provides forwarding between each pair of hosts via
 * MyTunnel protocol as defined in mytunnel.p4.
 * <p>
 * The app works by listening for host events. Each time a new host is
 * discovered, it provisions a tunnel between that host and all the others.
 */
@Component(immediate = true)
public class MyTunnelApp {

    private static final String APP_NAME = "org.onosproject.p4tutorial.mytunnel";

    // Default priority used for flow rules installed by this app.
    private static final int FLOW_RULE_PRIORITY = 100;

    private final HostListener hostListener = new InternalHostListener();
    private ApplicationId appId;
    private AtomicInteger nextTunnelId = new AtomicInteger();

    private static final Logger log = getLogger(MyTunnelApp.class);

    //--------------------------------------------------------------------------
    // ONOS core services needed by this application.
    //--------------------------------------------------------------------------

    @Reference(cardinality = ReferenceCardinality.MANDATORY)
    private FlowRuleService flowRuleService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY)
    private CoreService coreService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY)
    private TopologyService topologyService;

    @Reference(cardinality = ReferenceCardinality.MANDATORY)
    private HostService hostService;

    //--------------------------------------------------------------------------
    //--------------------------------------------------------------------------

    @Activate
    public void activate() {
        // Register app and event listeners.
        log.info("Starting...");
        appId = coreService.registerApplication(APP_NAME);
        hostService.addListener(hostListener);
        log.info("STARTED", appId.id());
    }

    @Deactivate
    public void deactivate() {
        // Remove listeners and clean-up flow rules.
        log.info("Stopping...");
        hostService.removeListener(hostListener);
        flowRuleService.removeFlowRulesById(appId);
        log.info("STOPPED");
    }

    /**
     * Provisions a tunnel between the given source and destination host with
     * the given tunnel ID. The tunnel is established using a randomly picked
     * shortest path based on the given topology snapshot.
     *
     * @param tunId   tunnel ID
     * @param srcHost tunnel source host
     * @param dstHost tunnel destination host
     * @param topo    topology snapshot
     */
    private void provisionTunnel(int tunId, Host srcHost, Host dstHost, Topology topo) {

        // Get all shortest paths between switches connected to source and
        // destination hosts.
        DeviceId srcSwitch = srcHost.location().deviceId();
        DeviceId dstSwitch = dstHost.location().deviceId();

        List<Link> pathLinks;
        if (srcSwitch.equals(dstSwitch)) {
            // Source and dest hosts are connected to the same switch.
            pathLinks = Collections.emptyList();
        } else {
            // Compute shortest path.
            Set<Path> allPaths = topologyService.getPaths(topo, srcSwitch, dstSwitch);
            if (allPaths.size() == 0) {
                log.warn("No paths between {} and {}", srcHost.id(), dstHost.id());
                return;
            }
            // If many shortest paths are available, pick a random one.
            pathLinks = pickRandomPath(allPaths).links();
        }

        // Tunnel ingress rules.
        for (IpAddress dstIpAddr : dstHost.ipAddresses()) {
            // In ONOS discovered hosts can have multiple IP addresses.
            // Insert tunnel ingress rule for each IP address.
            // Next switches will forward based only on tunnel ID.
            insertTunnelIngressRule(srcSwitch, dstIpAddr, tunId);
        }

        // Insert tunnel transit rules on all switches in the path, excluded the
        // last one.
        for (Link link : pathLinks) {
            DeviceId sw = link.src().deviceId();
            PortNumber port = link.src().port();
            insertTunnelForwardRule(sw, port, tunId, false);
        }

        // Tunnel egress rule.
        PortNumber egressSwitchPort = dstHost.location().port();
        insertTunnelForwardRule(dstSwitch, egressSwitchPort, tunId, true);

        log.info("** Completed provisioning of tunnel {} (srcHost={} dstHost={})",
                 tunId, srcHost.id(), dstHost.id());
    }

    /**
     * Generates and insert a flow rule to perform the tunnel INGRESS function
     * for the given switch, destination IP address and tunnel ID.
     *
     * @param switchId  switch ID
     * @param dstIpAddr IP address to forward inside the tunnel
     * @param tunId     tunnel ID
     */
    private void insertTunnelIngressRule(DeviceId switchId,
                                         IpAddress dstIpAddr,
                                         int tunId) {


        PiTableId tunnelIngressTableId = PiTableId.of("c_ingress.t_tunnel_ingress");

        // Longest prefix match on IPv4 dest address.
        PiMatchFieldId ipDestMatchFieldId = PiMatchFieldId.of("hdr.ipv4.dst_addr");
        PiCriterion match = PiCriterion.builder()
                .matchLpm(ipDestMatchFieldId, dstIpAddr.toOctets(), 32)
                .build();

        PiActionParam tunIdParam = new PiActionParam(PiActionParamId.of("tun_id"), tunId);

        PiActionId ingressActionId = PiActionId.of("c_ingress.my_tunnel_ingress");
        PiAction action = PiAction.builder()
                .withId(ingressActionId)
                .withParameter(tunIdParam)
                .build();

        log.info("Inserting INGRESS rule on switch {}: table={}, match={}, action={}",
                 switchId, tunnelIngressTableId, match, action);

        insertPiFlowRule(switchId, tunnelIngressTableId, match, action);
    }

    /**
     * Generates and insert a flow rule to perform the tunnel FORWARD/EGRESS
     * function for the given switch, output port address and tunnel ID.
     *
     * @param switchId switch ID
     * @param outPort  output port where to forward tunneled packets
     * @param tunId    tunnel ID
     * @param isEgress if true, perform tunnel egress action, otherwise forward
     *                 packet as is to port
     */
    private void insertTunnelForwardRule(DeviceId switchId,
                                         PortNumber outPort,
                                         int tunId,
                                         boolean isEgress) {

        PiTableId tunnelForwardTableId = PiTableId.of("c_ingress.t_tunnel_fwd");

        // Exact match on tun_id
        PiMatchFieldId tunIdMatchFieldId = PiMatchFieldId.of("hdr.my_tunnel.tun_id");
        PiCriterion match = PiCriterion.builder()
                .matchExact(tunIdMatchFieldId, tunId)
                .build();

        // Action depend on isEgress parameter.
        // if true, perform tunnel egress action on the given outPort, otherwise
        // simply forward packet as is (set_out_port action).
        PiActionParamId portParamId = PiActionParamId.of("port");
        PiActionParam portParam = new PiActionParam(portParamId, (short) outPort.toLong());

        final PiAction action;
        if (isEgress) {
            // Tunnel egress action.
            // Remove MyTunnel header and forward to outPort.
            PiActionId egressActionId = PiActionId.of("c_ingress.my_tunnel_egress");
            action = PiAction.builder()
                    .withId(egressActionId)
                    .withParameter(portParam)
                    .build();
        } else {
            // Tunnel transit action.
            // Forward the packet as is to outPort.
            /*
             * TODO EXERCISE: create action object for the transit case.
             * Look at the t_tunnel_fwd table in the P4 program. Which of the 3
             * actions can be used to simply set the output port? Get the full
             * action name from the P4Info file, and use that when creating the
             * PiActionId object. When creating the PiAction object, remember to
             * add all action parameters as defined in the P4 program.
             *
             * Hint: the code will be similar to the case when isEgress is true.
             */
            action = null; // Replace null with your solution.
        }

        log.info("Inserting {} rule on switch {}: table={}, match={}, action={}",
                 isEgress ? "EGRESS" : "TRANSIT",
                 switchId, tunnelForwardTableId, match, action);

        insertPiFlowRule(switchId, tunnelForwardTableId, match, action);
    }

    /**
     * Inserts a flow rule in the system that using a PI criterion and action.
     *
     * @param switchId    switch ID
     * @param tableId     table ID
     * @param piCriterion PI criterion
     * @param piAction    PI action
     */
    private void insertPiFlowRule(DeviceId switchId, PiTableId tableId,
                                  PiCriterion piCriterion, PiAction piAction) {
        FlowRule rule = DefaultFlowRule.builder()
                .forDevice(switchId)
                .forTable(tableId)
                .fromApp(appId)
                .withPriority(FLOW_RULE_PRIORITY)
                .makePermanent()
                .withSelector(DefaultTrafficSelector.builder()
                                      .matchPi(piCriterion).build())
                .withTreatment(DefaultTrafficTreatment.builder()
                                       .piTableAction(piAction).build())
                .build();
        flowRuleService.applyFlowRules(rule);
    }

    private int getNewTunnelId() {
        return nextTunnelId.incrementAndGet();
    }

    private Path pickRandomPath(Set<Path> paths) {
        int item = new Random().nextInt(paths.size());
        List<Path> pathList = Lists.newArrayList(paths);
        return pathList.get(item);
    }

    /**
     * A listener of host events that provisions two tunnels for each pair of
     * hosts when a new host is discovered.
     */
    private class InternalHostListener implements HostListener {

        @Override
        public void event(HostEvent event) {
            if (event.type() != HostEvent.Type.HOST_ADDED) {
                // Ignore other host events.
                return;
            }
            synchronized (this) {
                // Synchronizing here is an overkill, but safer for demo purposes.
                Host host = event.subject();
                Topology topo = topologyService.currentTopology();
                for (Host otherHost : hostService.getHosts()) {
                    if (!host.equals(otherHost)) {
                        provisionTunnel(getNewTunnelId(), host, otherHost, topo);
                        provisionTunnel(getNewTunnelId(), otherHost, host, topo);
                    }
                }
            }
        }
    }
}
