package ru.yandex.wmconsole.viewer.mac;


import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang.StringUtils;
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
import org.joda.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import ru.yandex.misc.codec.ByteTransformer;

/**
 * MAC version 1.0:
 * <pre>
 *   dateRoundMinutes = 5
 *   time = yearMonthDayHourMinute round by dateRoundMinutes
 *   key = predefinedSecret + time
 *
 *   mac = HMAC(message, key)
 *   secret = version + issueMillis + mac
 * </pre>
 * @author aherman
 */
public class MacService {
    private static final Logger log = LoggerFactory.getLogger(MacService.class);

    private static final int VERSION_01 = 1;
    public static final String VERSION_01_HMAC_NAME = "HmacSHA256";
    public static final String VERSION_01_SECRET_SEED = "367795da86805b1629d54fbfadd3fbdf";
    public static final int VERSION_01_MAXIMUM_MAC_LENGTH = 1 + 1 + 16 + 1 + 64;

    private final byte[] SEED;

    private int dateRoundMinutes = 5;

    private final ThreadLocal<Hasher> hashers = new ThreadLocal<Hasher>() {
        @Override
        protected Hasher initialValue() {
            return new Hasher();
        }
    };

    public MacService() {
        try {
            SEED = Hex.decodeHex(VERSION_01_SECRET_SEED.toCharArray());
        } catch (DecoderException e) {
            throw new IllegalStateException("Unable to initialize MAC", e);
        }
    }

    public String createMac(byte[] message) throws InvalidKeyException {
        DateTime date = DateTime.now();
        return createMacVersion01(message, date);
    }

    String createMacVersion01(byte[] message, DateTime date) throws InvalidKeyException {
        date = date.withZone(DateTimeZone.UTC);
        StringBuilder sb = new StringBuilder(VERSION_01_MAXIMUM_MAC_LENGTH);
        sb.append(VERSION_01)
                .append('.')
                .append(Long.toHexString(date.getMillis()))
                .append('.')
                .append(Hex.encodeHex(computeMacVersion01(message, date)));
        return sb.toString();
    }

    private byte[] computeMacVersion01(byte[] message, DateTime date) throws InvalidKeyException {
        return hashers.get().createMac(message, convert(date));
    }

    public boolean validateMac(byte[] message, String mac) throws InvalidKeyException {
        DateTime now = DateTime.now();
        return checkMacVerion01(message, mac, now);
    }

    boolean checkMacVerion01(byte[] message, String mac, DateTime date) throws InvalidKeyException {
        if (StringUtils.isEmpty(mac) || mac.length() > VERSION_01_MAXIMUM_MAC_LENGTH) {
            return false;
        }

        int versionEndPosition = mac.indexOf('.');
        if (versionEndPosition == -1) {
            log.warn("Strange MAC: " + mac);
            return false;
        }

        int dateEndPosition;

        date = date.withZone(DateTimeZone.UTC);
        try {
            int version = Integer.parseInt(mac.substring(0, versionEndPosition));
            if (VERSION_01 != version) {
                log.warn("Wrong MAC version: " + mac);
                return false;
            }

            dateEndPosition = mac.indexOf('.', versionEndPosition + 1);
            if (dateEndPosition == -1) {
                log.warn("Strange MAC: " + mac);
                return false;
            }

            long dateMillis = Long.parseLong(mac.substring(versionEndPosition + 1, dateEndPosition), 16);
            DateTime macIssueDate = new DateTime(dateMillis, DateTimeZone.UTC);
            if (macIssueDate.isAfter(date)) {
                log.warn("MAC from future: issued=" + macIssueDate + ", checkDate=" + date);
            }
            if (date.minusMinutes(dateRoundMinutes * 3).isAfter(macIssueDate)) {
                log.warn("MAC too old: issued=" + macIssueDate + ", checkDate=" + date);
            }
        } catch (NumberFormatException e) {
            log.warn("Strange MAC: " + mac);
            return false;
        }

        String macHashString = mac.substring(dateEndPosition + 1);
        byte[] macHash;
        try {
            macHash = Hex.decodeHex(macHashString.toCharArray());
        } catch (DecoderException e) {
            log.warn("Strange MAC: " + mac);
            return false;
        }

        byte[] expectedMacHash = computeMacVersion01(message, date);
        if (Arrays.equals(expectedMacHash, macHash)) {
            return true;
        }

        // Try to check previous period
        date = date.minusMinutes(dateRoundMinutes);
        expectedMacHash = computeMacVersion01(message, date);

        return Arrays.equals(expectedMacHash, macHash);
    }

    private Instant convert(DateTime dateTime) {
        int minutestOfHour = dateTime.getMinuteOfHour();
        minutestOfHour = minutestOfHour - (minutestOfHour % dateRoundMinutes);
        return dateTime.withTime(dateTime.getHourOfDay(), minutestOfHour, 0, 0).toInstant();
    }

    private class Hasher {
        private final Mac mac;

        private Hasher() {
            try {
                mac = Mac.getInstance(VERSION_01_HMAC_NAME);
            } catch (NoSuchAlgorithmException e) {
                throw new IllegalStateException("Unable to initialize MAC", e);
            }
        }

        public byte[] createMac(byte[] message, Instant instant) throws InvalidKeyException {
            byte[] keyBytes = new byte[SEED.length + 8];
            ByteTransformer.putLong(instant.getMillis(), keyBytes, 0);
            System.arraycopy(SEED, 0, keyBytes, 8, SEED.length);
            SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, VERSION_01_HMAC_NAME);
            mac.init(secretKeySpec);
            return mac.doFinal(message);
        }
    }

    public void setDateRoundMinutes(int dateRoundMinutes) {
        this.dateRoundMinutes = dateRoundMinutes;
    }
}
