package net.i2p.data;

/*
 * free (adj.): unencumbered; not under the control of others
 * Written by jrandom in 2003 and released into the public domain 
 * with no warranty of any kind, either expressed or implied.  
 * It probably won't make your computer catch on fire, or eat 
 * your children, but it might.  Use at your own risk.
 *
 */

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;

import net.i2p.I2PAppContext;
import net.i2p.crypto.DSAEngine;
import net.i2p.util.Clock;
import net.i2p.util.Log;
import net.i2p.util.RandomSource;

/**
 * Defines the set of leases a destination currently has.
 *
 * Support encryption and decryption with a supplied key.
 * Only the gateways and tunnel IDs in the individual
 * leases are encrypted.
 *
 * WARNING:
 * Encryption is poorly designed and probably insecure.
 * Not recommended.
 *
 * Encrypted leases are not indicated as such.
 * The only way to tell a lease is encrypted is to
 * determine that the listed gateways do not exist.
 * Routers wishing to decrypt a leaseset must have the
 * desthash and key in their keyring.
 * This is required for the local router as well, since
 * the encryption is done on the client side of I2CP, the
 * router must decrypt it back again for local usage
 * (but not for transmission to the floodfills)
 *
 * Decrypted leases are only available through the getLease()
 * method, so that storage and network transmission via
 * writeBytes() will output the original encrypted
 * leases and the original leaseset signature.
 *
 * Revocation (zero leases) isn't used anywhere. In addition:
 *  - A revoked leaseset has an EarliestLeaseDate of -1, so it will
 *    never be stored successfully.
 *  - Revocation of an encrypted leaseset will explode.
 *  - So having an included signature at all is pointless?
 *
 *
 * @author jrandom
 */
public class LeaseSet extends DatabaseEntry {
    private Destination _destination;
    private PublicKey _encryptionKey;
    private SigningPublicKey _signingKey;
    // Keep leases in the order received, or else signature verification will fail!
    private final List<Lease> _leases;
    private boolean _receivedAsPublished;
    private boolean _receivedAsReply;
    // Store these since isCurrent() and getEarliestLeaseDate() are called frequently
    private long _firstExpiration;
    private long _lastExpiration;
    private List<Lease> _decryptedLeases;
    private boolean _decrypted;
    private boolean _checked;
    // cached byte version
    private volatile byte _byteified[];

    /**
     *  Unlimited before 0.6.3;
     *  6 as of 0.6.3;
     *  Increased in version 0.9.
     *
     *  Leasesets larger than 6 should be used with caution,
     *  as each lease adds 44 bytes, and routers older than version 0.9
     *  will not be able to connect as they will throw an exception in
     *  readBytes(). Also, the churn will be quite rapid, leading to
     *  frequent netdb stores and transmission on existing connections.
     *
     *  However we increase it now in case some hugely popular eepsite arrives.
     *  Strategies elsewhere in the router to efficiently handle
     *  large leasesets are TBD.
     */
    public static final int MAX_LEASES = 16;
    private static final int OLD_MAX_LEASES = 6;

    public LeaseSet() {
        _leases = new ArrayList<Lease>(2);
        _firstExpiration = Long.MAX_VALUE;
    }

    /**
     * Same as getEarliestLeaseDate()
     */
    public long getDate() {
        return getEarliestLeaseDate();
    }

    public KeysAndCert getKeysAndCert() {
        return _destination;
    }

    public int getType() {
        return KEY_TYPE_LEASESET;
    }

    public Destination getDestination() {
        return _destination;
    }

    /**
     * @throws IllegalStateException if already signed
     */
    public void setDestination(Destination dest) {
        if (_signature != null)
            throw new IllegalStateException();
        _destination = dest;
    }

    public PublicKey getEncryptionKey() {
        return _encryptionKey;
    }

    /**
     * @throws IllegalStateException if already signed
     */
    public void setEncryptionKey(PublicKey encryptionKey) {
        if (_signature != null)
            throw new IllegalStateException();
        _encryptionKey = encryptionKey;
    }

    /**
     *  The revocation key.
     *  @deprecated unused
     */
    @Deprecated
    public SigningPublicKey getSigningKey() {
        return _signingKey;
    }

    /**
     *  The revocation key. Unused.
     *  Must be the same type as the Destination's SigningPublicKey.
     *  @throws IllegalArgumentException if different type
     */
    public void setSigningKey(SigningPublicKey key) {
        if (key != null && _destination != null &&
            key.getType() != _destination.getSigningPublicKey().getType())
            throw new IllegalArgumentException("Signing key type mismatch");
        _signingKey = key;
    }
    
    /**
     * If true, we received this LeaseSet by a remote peer publishing it to
     * us, rather than by searching for it ourselves or locally creating it.
     * Default false.
     */
    public boolean getReceivedAsPublished() { return _receivedAsPublished; }

    /** Default false */
    public void setReceivedAsPublished(boolean received) { _receivedAsPublished = received; }

    /**
     * If true, we received this LeaseSet by searching for it
     * Default false.
     * @since 0.7.14
     */
    public boolean getReceivedAsReply() { return _receivedAsReply; }

    /** set to true @since 0.7.14 */
    public void setReceivedAsReply() { _receivedAsReply = true; }

    /**
     * @throws IllegalStateException if already signed
     */
    public void addLease(Lease lease) {
        if (lease == null) throw new IllegalArgumentException("erm, null lease");
        if (lease.getGateway() == null) throw new IllegalArgumentException("erm, lease has no gateway");
        if (lease.getTunnelId() == null) throw new IllegalArgumentException("erm, lease has no tunnel");
        if (_signature != null)
            throw new IllegalStateException();
        if (_leases.size() >= MAX_LEASES)
            throw new IllegalArgumentException("Too many leases - max is " + MAX_LEASES);
        _leases.add(lease);
        long expire = lease.getEndDate().getTime();
        if (expire < _firstExpiration)
            _firstExpiration = expire;
        if (expire > _lastExpiration)
            _lastExpiration = expire;
    }

    /**
     *  @return 0-16
     *  A LeaseSet with no leases is revoked.
     */
    public int getLeaseCount() {
        if (isEncrypted())
            return _leases.size() - 1;
        else
            return _leases.size();
    }

    public Lease getLease(int index) {
        if (isEncrypted())
            return _decryptedLeases.get(index);
        else
            return _leases.get(index);
    }

    /**
     * Retrieve the end date of the earliest lease included in this leaseSet.
     * This is the date that should be used in comparisons for leaseSet age - to
     * determine which LeaseSet was published more recently (later earliestLeaseSetDate
     * means it was published later)
     *
     * @return earliest end date of any lease in the set, or -1 if there are no leases
     */
    public long getEarliestLeaseDate() {
        if (_leases.isEmpty())
            return -1;
        return _firstExpiration;
    }

    /**
     * Retrieve the end date of the latest lease included in this leaseSet.
     * This is the date used in isCurrent().
     *
     * @return latest end date of any lease in the set, or 0 if there are no leases
     * @since 0.9.7
     */
    public long getLatestLeaseDate() {
        return _lastExpiration;
    }

    /**
     * Verify that the signature matches the lease set's destination's signing public key.
     * OR the included revocation key.
     *
     * @return true only if the signature matches
     */
    @Override
    public boolean verifySignature() {
        if (super.verifySignature())
            return true;

        // Revocation unused (see above)
        boolean signedByRevoker = DSAEngine.getInstance().verifySignature(_signature, getBytes(), _signingKey);
        return signedByRevoker;
    }

    /**
     * Verify that the signature matches the lease set's destination's signing public key.
     * OR the specified revocation key.
     *
     * @deprecated revocation unused
     * @return true only if the signature matches
     */
    @Deprecated
    public boolean verifySignature(SigningPublicKey signingKey) {
        if (super.verifySignature())
            return true;

        // Revocation unused (see above)
        boolean signedByRevoker = DSAEngine.getInstance().verifySignature(_signature, getBytes(), signingKey);
        return signedByRevoker;
    }

    /**
     * Determine whether ANY lease is currently valid, at least within a given
     * fudge factor 
     *
     * @param fudge milliseconds fudge factor to allow between the current time
     * @return true if there are current leases, false otherwise
     */
    public boolean isCurrent(long fudge) {
        long now = Clock.getInstance().now();
        return _lastExpiration > now - fudge;
    }

    protected byte[] getBytes() {
        if (_byteified != null) return _byteified;
        if ((_destination == null) || (_encryptionKey == null) || (_signingKey == null))
            return null;
        int len = _destination.size()
                + PublicKey.KEYSIZE_BYTES // encryptionKey
                + _signingKey.length() // signingKey
                + 1
                + _leases.size() * 44; // leases
        ByteArrayOutputStream out = new ByteArrayOutputStream(len);
        try {
            _destination.writeBytes(out);
            _encryptionKey.writeBytes(out);
            _signingKey.writeBytes(out);
            out.write((byte) _leases.size());
            for (Lease lease : _leases)
                lease.writeBytes(out);
        } catch (IOException ioe) {
            return null;
        } catch (DataFormatException dfe) {
            return null;
        }
        byte rv[] = out.toByteArray();
        // if we are floodfill and this was published to us
        if (_receivedAsPublished)
            _byteified = rv;
        return rv;
    }
    
    /**
     *  This does NOT validate the signature
     *
     *  @throws IllegalStateException if called more than once or Destination already set
     */
    public void readBytes(InputStream in) throws DataFormatException, IOException {
        if (_destination != null)
            throw new IllegalStateException();
        _destination = Destination.create(in);
        _encryptionKey = PublicKey.create(in);
        // revocation signing key must be same type as the destination signing key
        _signingKey = new SigningPublicKey(_destination.getSigningPublicKey().getType());
        _signingKey.readBytes(in);
        int numLeases = (int) DataHelper.readLong(in, 1);
        if (numLeases > MAX_LEASES)
            throw new DataFormatException("Too many leases - max is " + MAX_LEASES);
        //_version = DataHelper.readLong(in, 4);
        for (int i = 0; i < numLeases; i++) {
            Lease lease = new Lease();
            lease.readBytes(in);
            addLease(lease);
        }
        // signature must be same type as the destination signing key
        _signature = new Signature(_destination.getSigningPublicKey().getType());
        _signature.readBytes(in);
    }
    
    /**
     *  This does NOT validate the signature
     */
    public void writeBytes(OutputStream out) throws DataFormatException, IOException {
        if ((_destination == null) || (_encryptionKey == null) || (_signingKey == null)
            || (_signature == null)) throw new DataFormatException("Not enough data to write out a LeaseSet");

        _destination.writeBytes(out);
        _encryptionKey.writeBytes(out);
        _signingKey.writeBytes(out);
        out.write((byte) _leases.size());
        for (Lease lease : _leases)
            lease.writeBytes(out);
        _signature.writeBytes(out);
    }
    
    /**
     *  Number of bytes, NOT including signature
     */
    public int size() {
        return _destination.size()
             + PublicKey.KEYSIZE_BYTES // encryptionKey
             + _signingKey.length() // signingKey
             + 1 // number of leases
             + _leases.size() * (Hash.HASH_LENGTH + 4 + 8);
    }
    
    @Override
    public boolean equals(Object object) {
        if (object == this) return true;
        if ((object == null) || !(object instanceof LeaseSet)) return false;
        LeaseSet ls = (LeaseSet) object;
        return
               DataHelper.eq(_signature, ls.getSignature())
               && DataHelper.eq(_leases, ls._leases)
               && DataHelper.eq(getEncryptionKey(), ls.getEncryptionKey())
               && DataHelper.eq(_signingKey, ls.getSigningKey())
               && DataHelper.eq(_destination, ls.getDestination());

    }
    
    /** the destination has enough randomness in it to use it by itself for speed */
    @Override
    public int hashCode() {
        if (_destination == null)
            return 0;
        return _destination.hashCode();
    }
    
    @Override
    public String toString() {
        StringBuilder buf = new StringBuilder(128);
        buf.append("[LeaseSet: ");
        buf.append("\n\tDestination: ").append(_destination);
        buf.append("\n\tEncryptionKey: ").append(_encryptionKey);
        buf.append("\n\tSigningKey: ").append(_signingKey);
        //buf.append("\n\tVersion: ").append(getVersion());
        buf.append("\n\tSignature: ").append(_signature);
        buf.append("\n\tLeases: #").append(getLeaseCount());
        for (int i = 0; i < getLeaseCount(); i++)
            buf.append("\n\t\t").append(getLease(i));
        buf.append("]");
        return buf.toString();
    }

    private static final int DATA_LEN = Hash.HASH_LENGTH + 4;
    private static final int IV_LEN = 16;

    /**
     *  Encrypt the gateway and tunnel ID of each lease, leaving the expire dates unchanged.
     *  This adds an extra dummy lease, because AES data must be padded to 16 bytes.
     *  The fact that it is encrypted is not stored anywhere.
     *  Must be called after all the leases are in place, but before sign().
     */
    public void encrypt(SessionKey key) {
        //if (_log.shouldLog(Log.WARN))
        //    _log.warn("encrypting lease: " + _destination.calculateHash());
        try {
            encryp(key);
        } catch (DataFormatException dfe) {
            Log log = I2PAppContext.getGlobalContext().logManager().getLog(LeaseSet.class);
            log.error("Error encrypting lease: " + _destination.calculateHash(), dfe);
        } catch (IOException ioe) {
            Log log = I2PAppContext.getGlobalContext().logManager().getLog(LeaseSet.class);
            log.error("Error encrypting lease: " + _destination.calculateHash(), ioe);
        }
    }

    /**
     *  - Put the {Gateway Hash, TunnelID} pairs for all the leases in a buffer
     *  - Pad with random data to a multiple of 16 bytes
     *  - Use the first part of the dest's public key as an IV
     *  - Encrypt
     *  - Pad with random data to a multiple of 36 bytes
     *  - Add an extra lease
     *  - Replace the Hash and TunnelID in each Lease
     */
    private void encryp(SessionKey key) throws DataFormatException, IOException {
        int size = _leases.size();
        if (size < 1 || size > MAX_LEASES-1)
            throw new IllegalArgumentException("Bad number of leases for encryption");
        int datalen = ((DATA_LEN * size / 16) + 1) * 16;
        ByteArrayOutputStream baos = new ByteArrayOutputStream(datalen);
        for (int i = 0; i < size; i++) {
            _leases.get(i).getGateway().writeBytes(baos);
            _leases.get(i).getTunnelId().writeBytes(baos);
        }
        // pad out to multiple of 16 with random data before encryption
        int padlen = datalen - (DATA_LEN * size);
        byte[] pad = new byte[padlen];
        RandomSource.getInstance().nextBytes(pad);
        baos.write(pad);
        byte[] iv = new byte[IV_LEN];
        System.arraycopy(_destination.getPublicKey().getData(), 0, iv, 0, IV_LEN);
        byte[] enc = new byte[DATA_LEN * (size + 1)];
        I2PAppContext.getGlobalContext().aes().encrypt(baos.toByteArray(), 0, enc, 0, key, iv, datalen);
        // pad out to multiple of 36 with random data after encryption
        // (even for 4 leases, where 36*4 is a multiple of 16, we add another, just to be consistent)
        padlen = enc.length - datalen;
        RandomSource.getInstance().nextBytes(enc, datalen, padlen);
        // add the padded lease...
        Lease padLease = new Lease();
        padLease.setEndDate(_leases.get(0).getEndDate());
        _leases.add(padLease);
        // ...and replace all the gateways and tunnel ids
        ByteArrayInputStream bais = new ByteArrayInputStream(enc);
        for (int i = 0; i < size+1; i++) {
            Hash h = new Hash();
            h.readBytes(bais);
            _leases.get(i).setGateway(h);
            TunnelId t = new TunnelId();
            t.readBytes(bais);
            _leases.get(i).setTunnelId(t);
        }
    }

    /**
     *  Decrypt the leases, except for the last one which is partially padding.
     *  Store the new decrypted leases in a backing store,
     *  and keep the original leases so that verify() still works and the
     *  encrypted leaseset can be sent on to others (via writeBytes())
     */
    private void decrypt(SessionKey key) throws DataFormatException, IOException {
        //if (_log.shouldLog(Log.WARN))
        //    _log.warn("decrypting lease: " + _destination.calculateHash());
        int size = _leases.size();
        if (size < 2)
            throw new DataFormatException("Bad number of leases for decryption");
        int datalen = DATA_LEN * size;
        ByteArrayOutputStream baos = new ByteArrayOutputStream(datalen);
        for (int i = 0; i < size; i++) {
            _leases.get(i).getGateway().writeBytes(baos);
            _leases.get(i).getTunnelId().writeBytes(baos);
        }
        byte[] iv = new byte[IV_LEN];
        System.arraycopy(_destination.getPublicKey().getData(), 0, iv, 0, IV_LEN);
        int enclen = ((DATA_LEN * (size - 1) / 16) + 1) * 16;
        byte[] enc = new byte[enclen];
        System.arraycopy(baos.toByteArray(), 0, enc, 0, enclen);
        byte[] dec = new byte[enclen];
        I2PAppContext.getGlobalContext().aes().decrypt(enc, 0, dec, 0, key, iv, enclen);
        ByteArrayInputStream bais = new ByteArrayInputStream(dec);
        _decryptedLeases = new ArrayList<Lease>(size - 1);
        for (int i = 0; i < size-1; i++) {
            Lease l = new Lease();
            Hash h = new Hash();
            h.readBytes(bais);
            l.setGateway(h);
            TunnelId t = new TunnelId();
            t.readBytes(bais);
            l.setTunnelId(t);
            l.setEndDate(_leases.get(i).getEndDate());
            _decryptedLeases.add(l);
        }
    }

    /**
     * @return true if it was encrypted, and we decrypted it successfully.
     * Decrypts on first call.
     */
    private synchronized boolean isEncrypted() {
        if (_decrypted)
           return true;
        // If the encryption key is not set yet, it can't have been encrypted yet.
        // Router-side I2CP sets the destination (but not the encryption key)
        // on an unsigned LS which is pending signature (and possibly encryption)
        // by the client, and we don't want to attempt 'decryption' on it.
        if (_checked || _encryptionKey == null || _destination == null)
           return false;
        SessionKey key = I2PAppContext.getGlobalContext().keyRing().get(_destination.calculateHash());
        if (key != null) {
            try {
                decrypt(key);
                _decrypted = true;
            } catch (DataFormatException dfe) {
                Log log = I2PAppContext.getGlobalContext().logManager().getLog(LeaseSet.class);
                log.error("Error decrypting lease: " + _destination.calculateHash(), dfe);
            } catch (IOException ioe) {
                Log log = I2PAppContext.getGlobalContext().logManager().getLog(LeaseSet.class);
                log.error("Error decrypting lease: " + _destination.calculateHash(), ioe);
            }
        }
        _checked = true;
        return _decrypted;
    }
}
