/*
 * Copyright (C) 2021 The Android Open Source Project
 *
 * 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 com.android.server.connectivity.mdns;

import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;

import android.annotation.Nullable;
import android.os.SystemClock;
import android.text.TextUtils;

import androidx.annotation.VisibleForTesting;

import com.android.net.module.util.DnsUtils;

import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;

/**
 * Abstract base class for mDNS records. Stores the header fields and provides methods for reading
 * the record from and writing it to a packet.
 */
public abstract class MdnsRecord {
    public static final int TYPE_A = 0x0001;
    public static final int TYPE_AAAA = 0x001C;
    public static final int TYPE_PTR = 0x000C;
    public static final int TYPE_SRV = 0x0021;
    public static final int TYPE_TXT = 0x0010;
    public static final int TYPE_KEY = 0x0019;
    public static final int TYPE_NSEC = 0x002f;
    public static final int TYPE_ANY = 0x00ff;

    private static final int FLAG_CACHE_FLUSH = 0x8000;

    public static final long RECEIPT_TIME_NOT_SENT = 0L;
    public static final int CLASS_ANY = 0x00ff;
    /** Max label length as per RFC 1034/1035 */
    public static final int MAX_LABEL_LENGTH = 63;

    /** Status indicating that the record is current. */
    public static final int STATUS_OK = 0;
    /** Status indicating that the record has expired (TTL reached 0). */
    public static final int STATUS_EXPIRED = 1;
    /** Status indicating that the record should be refreshed (Less than half of TTL remains.) */
    public static final int STATUS_NEEDS_REFRESH = 2;

    protected final String[] name;
    private final int type;
    private final int cls;
    private final long receiptTimeMillis;
    private final long ttlMillis;
    private Object key;

    /**
     * Constructs a new record with the given name and type.
     *
     * @param reader The reader to read the record from.
     * @param isQuestion Whether the record was included in the questions part of the message.
     * @throws IOException If an error occurs while reading the packet.
     */
    protected MdnsRecord(String[] name, int type, MdnsPacketReader reader, boolean isQuestion)
            throws IOException {
        this.name = name;
        this.type = type;
        cls = reader.readUInt16();
        receiptTimeMillis = SystemClock.elapsedRealtime();

        if (isQuestion) {
            // Questions do not have TTL or data
            ttlMillis = 0L;
        } else {
            ttlMillis = SECONDS.toMillis(reader.readUInt32());
            int dataLength = reader.readUInt16();

            reader.setLimit(dataLength);
            readData(reader);
            reader.clearLimit();
        }
    }

    /**
     * Constructs a new record with the given name and type.
     *
     * @param reader The reader to read the record from.
     * @throws IOException If an error occurs while reading the packet.
     */
    // call to readData(com.android.server.connectivity.mdns.MdnsPacketReader) not allowed on given
    // receiver.
    @SuppressWarnings("nullness:method.invocation.invalid")
    protected MdnsRecord(String[] name, int type, MdnsPacketReader reader) throws IOException {
        this(name, type, reader, false);
    }

    /**
     * Constructs a new record with the given properties.
     */
    protected MdnsRecord(String[] name, int type, int cls, long receiptTimeMillis,
            boolean cacheFlush, long ttlMillis) {
        this.name = name;
        this.type = type;
        this.cls = cls | (cacheFlush ? FLAG_CACHE_FLUSH : 0);
        this.receiptTimeMillis = receiptTimeMillis;
        this.ttlMillis = ttlMillis;
    }

    /**
     * Converts an array of labels into their dot-separated string representation. This method
     * should
     * be used for logging purposes only.
     */
    public static String labelsToString(String[] labels) {
        if (labels == null) {
            return null;
        }
        return TextUtils.join(".", labels);
    }

    /** Tests if |list1| is a suffix of |list2|. */
    public static boolean labelsAreSuffix(String[] list1, String[] list2) {
        int offset = list2.length - list1.length;

        if (offset < 1) {
            return false;
        }

        for (int i = 0; i < list1.length; ++i) {
            if (!DnsUtils.equalsIgnoreDnsCase(list1[i], list2[i + offset])) {
                return false;
            }
        }

        return true;
    }

    /** Returns the record's receipt (creation) time. */
    public final long getReceiptTime() {
        return receiptTimeMillis;
    }

    /** Returns the record's name. */
    public String[] getName() {
        return name;
    }

    /** Returns the record's original TTL, in milliseconds. */
    public final long getTtl() {
        return ttlMillis;
    }

    /** Returns the record's type. */
    public final int getType() {
        return type;
    }

    /** Return the record's class. */
    public final int getRecordClass() {
        return cls & ~FLAG_CACHE_FLUSH;
    }

    /** Return whether the cache flush flag is set. */
    public final boolean getCacheFlush() {
        return (cls & FLAG_CACHE_FLUSH) != 0;
    }

    /**
     * For questions, returns whether a unicast reply was requested.
     *
     * In practice this is identical to {@link #getCacheFlush()}, as the "cache flush" flag in
     * replies is the same as "unicast reply requested" in questions.
     */
    public final boolean isUnicastReplyRequested() {
        return (cls & MdnsConstants.QCLASS_UNICAST) != 0;
    }

    /**
     * Returns the record's remaining TTL.
     *
     * If the record was not sent yet (receipt time {@link #RECEIPT_TIME_NOT_SENT}), this is the
     * original TTL of the record.
     * @param now The current system time.
     * @return The remaning TTL, in milliseconds.
     */
    public long getRemainingTTL(final long now) {
        if (receiptTimeMillis == RECEIPT_TIME_NOT_SENT) {
            return ttlMillis;
        }

        long age = now - receiptTimeMillis;
        if (age > ttlMillis) {
            return 0;
        }

        return ttlMillis - age;
    }

    /**
     * Reads the record's payload from a packet.
     *
     * @param reader The reader to use.
     * @throws IOException If an I/O error occurs.
     */
    protected abstract void readData(MdnsPacketReader reader) throws IOException;

    /**
     * Write the first fields of the record, which are common fields for questions and answers.
     *
     * @param writer The writer to use.
     */
    public final void writeHeaderFields(MdnsPacketWriter writer) throws IOException {
        writer.writeLabels(name);
        writer.writeUInt16(type);
        writer.writeUInt16(cls);
    }

    /**
     * Writes the record to a packet.
     *
     * @param writer The writer to use.
     * @param now    The current system time. This is used when writing the updated TTL.
     */
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public final void write(MdnsPacketWriter writer, long now) throws IOException {
        writeHeaderFields(writer);

        writer.writeUInt32(MILLISECONDS.toSeconds(getRemainingTTL(now)));

        int dataLengthPos = writer.getWritePosition();
        writer.writeUInt16(0); // data length
        int dataPos = writer.getWritePosition();

        writeData(writer);

        // Calculate amount of data written, and overwrite the data field earlier in the packet.
        int endPos = writer.getWritePosition();
        int dataLength = endPos - dataPos;
        writer.rewind(dataLengthPos);
        writer.writeUInt16(dataLength);
        writer.unrewind();
    }

    /**
     * Writes the record's payload to a packet.
     *
     * @param writer The writer to use.
     * @throws IOException If an I/O error occurs.
     */
    protected abstract void writeData(MdnsPacketWriter writer) throws IOException;

    /** Gets the status of the record. */
    public int getStatus(final long now) {
        if (receiptTimeMillis == RECEIPT_TIME_NOT_SENT) {
            return STATUS_OK;
        }
        final long age = now - receiptTimeMillis;
        if (age > ttlMillis) {
            return STATUS_EXPIRED;
        }
        if (age > (ttlMillis / 2)) {
            return STATUS_NEEDS_REFRESH;
        }
        return STATUS_OK;
    }

    @Override
    public boolean equals(@Nullable Object other) {
        if (!(other instanceof MdnsRecord)) {
            return false;
        }

        MdnsRecord otherRecord = (MdnsRecord) other;

        return DnsUtils.equalsDnsLabelIgnoreDnsCase(name, otherRecord.name) && (type
                == otherRecord.type);
    }

    @Override
    public int hashCode() {
        return Objects.hash(Arrays.hashCode(DnsUtils.toDnsLabelsUpperCase(name)), type);
    }

    /**
     * Returns an opaque object that uniquely identifies this record through a combination of its
     * type
     * and name. Suitable for use as a key in caches.
     */
    public final Object getKey() {
        if (key == null) {
            key = new Key(type, name);
        }
        return key;
    }

    private static final class Key {
        private final int recordType;
        private final String[] recordName;

        public Key(int recordType, String[] recordName) {
            this.recordType = recordType;
            this.recordName = DnsUtils.toDnsLabelsUpperCase(recordName);
        }

        @Override
        public boolean equals(@Nullable Object other) {
            if (this == other) {
                return true;
            }
            if (!(other instanceof Key)) {
                return false;
            }

            Key otherKey = (Key) other;

            return (recordType == otherKey.recordType) && Arrays.equals(recordName,
                    otherKey.recordName);
        }

        @Override
        public int hashCode() {
            return (recordType * 31) + Arrays.hashCode(recordName);
        }
    }
}
