/*
 * Copyright 2019-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.bng.packets;

import com.google.common.base.Objects;
import com.google.common.collect.ImmutableMap;
import org.onlab.packet.BasePacket;
import org.onlab.packet.Data;
import org.onlab.packet.Deserializer;
import org.onlab.packet.Ethernet;
import org.onlab.packet.IPacket;
import org.onlab.packet.IPv4;
import org.onlab.packet.IPv6;

import java.nio.ByteBuffer;
import java.util.LinkedList;
import java.util.List;

import static com.google.common.base.MoreObjects.toStringHelper;
import static org.onlab.packet.PacketUtils.checkHeaderLength;
import static org.onlab.packet.PacketUtils.checkInput;

/**
 * Implements PPPoE packet format.
 */
public class Pppoe extends BasePacket {

    public static final short TYPE_PPPOED = (short) 0x8863;
    public static final short TYPE_PPPOES = (short) 0x8864;

    static final ImmutableMap<Short, Deserializer<? extends IPacket>> PROTOCOL_DESERIALIZER_MAP =
            ImmutableMap.<Short, Deserializer<? extends IPacket>>builder()
                    //FIXME: write the correct parser for LCP, PAP, and CHAP
                    .put(PppProtocolType.LCP.code(), GenericPpp.deserializer())
                    .put(PppProtocolType.PAP.code(), GenericPpp.deserializer())
                    .put(PppProtocolType.CHAP.code(), GenericPpp.deserializer())
                    .put(PppProtocolType.IPCP.code(), Ipcp.deserializer())
                    .put(PppProtocolType.IPv4.code(), IPv4.deserializer())
                    .put(PppProtocolType.IPv6.code(), IPv6.deserializer())
                    .build();
    // Account of PPPoE standard header
    static final int HEADER_LENGTH = 6;
    // Fields part of PPPoE
    private byte version; // 4bit
    private byte typeId;  // 4bit
    private PppoeType packetType; // 8bit => code in PPPoE header
    private short sessionId; // 16bit
    private short payloadLength; // 16bit
    // FIXME: use PPPProtocol enum type
    private short pppProtocol = 0;
    //TODO: TLV TAGs (ref. to RFC2516)
    private PppoeTlvTag acName = null;
    private PppoeTlvTag serviceName = null;
    private List<PppoeTlvTag> optionalTagTlvList;

    public Pppoe() {
        super();
        optionalTagTlvList = new LinkedList<>();
    }

    /**
     * Get the packet type.
     *
     * @return the packet type
     */
    public PppoeType getPacketType() {
        return packetType;
    }

    /**
     * Set the Packet Type.
     *
     * @param type The packet type to set
     * @return this
     */
    public Pppoe setPacketType(PppoeType type) {
        this.packetType = type;
        return this;
    }

    /**
     * Get the session ID.
     *
     * @return the session ID
     */
    public short getSessionId() {
        return this.sessionId;
    }

    /**
     * Set the session ID.
     *
     * @param sessionId the session ID to set
     * @return this
     */
    public Pppoe setSessionId(short sessionId) {
        this.sessionId = sessionId;
        return this;
    }

    /**
     * Get the Point-to-Point Protocol.
     *
     * @return the Point-to-Point Protocol
     */
    public short getPppProtocol() {
        return this.pppProtocol;
    }

    /**
     * Get the AC-Name.
     *
     * @return the AC-Name
     */
    public PppoeTlvTag getAcName() {
        return this.acName;
    }

    /**
     * Set the AC-Name.
     *
     * @param acname AC-Name to set
     * @return this
     */
    public Pppoe setAcName(final PppoeTlvTag acname) {
        this.acName = acname;
        return this;
    }

    /**
     * Get the PPPoE version field.
     *
     * @return The version field
     */
    public byte getVersion() {
        return this.version;
    }

    /**
     * Set the version field.
     *
     * @param version version to set
     * @return this
     */
    public Pppoe setVersion(byte version) {
        this.version = (byte) (version & 0xF);
        return this;
    }

    /**
     * Get the PPPoE type ID.
     *
     * @return The type ID
     */
    public short getTypeId() {
        return this.typeId;
    }

    /**
     * Set the type ID.
     *
     * @param typeId Type ID to set
     * @return this
     */
    public Pppoe setTypeId(byte typeId) {
        this.typeId = (byte) (typeId & 0xF);
        return this;
    }

    /**
     * Get the PPPoE payload length header field.
     *
     * @return The payload length
     */
    public short getPayloadLength() {
        return this.payloadLength;
    }

    /**
     * Set the payload length.
     *
     * @param payloadLength the payload length to set.
     * @return this
     */
    public Pppoe setPayloadLength(short payloadLength) {
        this.payloadLength = payloadLength;
        return this;
    }

    @Override
    public byte[] serialize() {
        byte[] payloadData = null;
        int payloadLength = 0;
        if (this.payload != null) {
            this.payload.setParent(this);
            payloadData = this.payload.serialize();
            payloadLength = payloadData.length + HEADER_LENGTH +
                    (this.packetType == PppoeType.SESSION ? 2 : 0);
        }
        // PayloadLength account for PPP header field
        int realLength = Math.max(this.payloadLength + HEADER_LENGTH, payloadLength);
        final byte[] data = new byte[realLength];
        final ByteBuffer bb = ByteBuffer.wrap(data);
        bb.put((byte) (this.version << 4 | this.typeId & 0xf));
        bb.put(this.packetType.code);
        bb.putShort(this.sessionId);
        bb.putShort(this.payloadLength);
        if (this.packetType != PppoeType.SESSION) {
            // Only session packet have PPP header
            bb.putShort(this.pppProtocol);
        } else {
            // Only NON session packet have options
            // TLV Tags
            if (acName != null) {
                bb.put(acName.serialize());
            }
            if (serviceName != null) {
                bb.put(serviceName.serialize());
            }
            if (this.optionalTagTlvList != null) {
                for (final PppoeTlvTag tlv : this.optionalTagTlvList) {
                    bb.put(tlv.serialize());
                }
            }
        }
        if (payloadData != null) {
            bb.put(payloadData);
        }
        return data;
    }

    /**
     * Deserializer function for PPPoE packets.
     *
     * @return deserializer function
     */
    public static Deserializer<Pppoe> deserializer() {
        return (data, offset, length) -> {
            checkInput(data, offset, length, HEADER_LENGTH);

            Pppoe pppoe = new Pppoe();
            final ByteBuffer bb = ByteBuffer.wrap(data, offset, length);
            byte versionAndType = bb.get();
            pppoe.version = (byte) (versionAndType >> 4 & 0xF);
            pppoe.typeId = (byte) (versionAndType & 0xF);
            byte code = bb.get();
            pppoe.sessionId = bb.getShort();
            pppoe.payloadLength = bb.getShort();
            pppoe.packetType = PppoeType.lookup(code);
            // Check if the PPPoE packet is a SESSION packet
            if (pppoe.packetType == PppoeType.SESSION) {
                // Parse inner protocols
                pppoe.pppProtocol = bb.getShort();
                Deserializer<? extends IPacket> deserializer;
                if (Pppoe.PROTOCOL_DESERIALIZER_MAP.containsKey(pppoe.pppProtocol)) {
                    deserializer = PROTOCOL_DESERIALIZER_MAP.get(pppoe.pppProtocol);
                } else {
                    deserializer = Data.deserializer();
                }
                int remainingLength = bb.limit() - bb.position();
                int bytesToRead = Math.min(pppoe.payloadLength - 2, remainingLength);
                if (bytesToRead > 0) {
                    pppoe.payload = deserializer.deserialize(data, bb.position(), bytesToRead);
                    pppoe.payload.setParent(pppoe);
                }
            } else {
                // PPPoE Packet is of Discovery type
                // Parse TLV PPPoED Tags
                int currentIndex = HEADER_LENGTH;
                PppoeTlvTag tlv;
                do {
                    // Each new TLV PPPoE TAG must be a minimum of 4 bytes
                    // (containing the type and length fields).
                    if (length - currentIndex < 4) {
                        // Probably the packet was zero-padded to reach the Ethernet minimum length.
                        // Let's skip and accept the packet
                        // FIXME: is there a "smarter" way to identify a padded packet?
                        break;
                    }
                    currentIndex += 4;
                    checkHeaderLength(length, currentIndex);
                    tlv = new PppoeTlvTag().deserialize(bb);
                    // if there was a failure to deserialize stop processing TLVs
                    if (tlv == null) {
                        break;
                    }
                    switch (tlv.getTagType()) {
                        case PppoeTlvTag.PPPOED_TAGTYPE_EOL:
                            // end delimiter
                            break;
                        case PppoeTlvTag.PPPOED_TAGTYPE_SERVICENAME:
                            // Service Name
                            pppoe.serviceName = tlv;
                            break;
                        case PppoeTlvTag.PPPOED_TAGTYPE_ACNAME:
                            // AC-Name
                            pppoe.acName = tlv;
                            break;
                        default:
                            pppoe.optionalTagTlvList.add(tlv);
                            break;
                    }
                    currentIndex += tlv.getLength();
                } while (tlv.getTagType() != 0 && currentIndex < length);
            }
            return pppoe;
        };
    }

    @Override
    public String toString() {
        return toStringHelper(getClass())
                .add("version", Byte.toString(version))
                .add("typeId", Byte.toString(typeId))
                .add("code", Byte.toString(packetType.code))
                .add("sessionId", Short.toString(sessionId))
                .add("payloadLength", Short.toString(payloadLength))
                .toString();
    }

    @Override
    public int hashCode() {
        return 31 * super.hashCode() +
                Objects.hashCode(version, typeId, packetType,
                                 sessionId, payloadLength, pppProtocol,
                                 acName, serviceName, optionalTagTlvList);
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj == null || getClass() != obj.getClass()) {
            return false;
        }
        if (!super.equals(obj)) {
            return false;
        }
        final Pppoe other = (Pppoe) obj;
        return Objects.equal(this.version, other.version)
                && Objects.equal(this.typeId, other.typeId)
                && Objects.equal(this.packetType, other.packetType)
                && Objects.equal(this.sessionId, other.sessionId)
                && Objects.equal(this.payloadLength, other.payloadLength)
                && Objects.equal(this.pppProtocol, other.pppProtocol)
                && Objects.equal(this.acName, other.acName)
                && Objects.equal(this.serviceName, other.serviceName)
                && Objects.equal(this.optionalTagTlvList, other.optionalTagTlvList);
    }

    /**
     * PPPoE Discovery types.
     */
    public enum PppoeType {
        PADI(0x9, "padi"),
        PADO(0x7, "pado"),
        PADR(0x19, "padr"),
        PADS(0x65, "pads"),
        PADT(0xa7, "padt"),
        SESSION(0x0, "session"),
        UNKNOWN(0xFF, "unknown");

        private final byte code;
        private final String type;

        /**
         * Constructs new PPPoE Discovery types.
         *
         * @param code The PPPoED type.
         * @param type Textual representation of the PPPoED type.
         */
        PppoeType(int code, String type) {
            this.code = (byte) (code & 0xFF);
            this.type = type;
        }

        public static PppoeType lookup(byte code) {
            for (PppoeType type : PppoeType.values()) {
                if (code == type.code()) {
                    return type;
                }
            }
            return UNKNOWN;
        }

        public byte code() {
            return code;
        }

        public String type() {
            return type;
        }
    }

    /**
     * Checks if the passed Ethernet packet is PPPoE Service.
     *
     * @param eth Packet to check
     * @return True if the packet contains PPPoE Service header
     */
    public static boolean isPPPoES(Ethernet eth) {
        return eth.getEtherType() == TYPE_PPPOES;
    }

    /**
     * Checks if the passed Ethernet packet is PPPoE Discovery.
     *
     * @param eth Packet to check
     * @return True if the packet contains PPPoE Discovery header
     */
    public static boolean isPPPoED(Ethernet eth) {
        return eth.getEtherType() == TYPE_PPPOED;
    }
}
