import java.nio.charset.StandardCharsets;
import java.util.Objects;

import org.bouncycastle.crypto.fpe.FPEFF1Engine;
import org.bouncycastle.crypto.params.FPEParameters;
import org.bouncycastle.crypto.params.KeyParameter;

public final class RedeemCodeFf1 {
    private static final char[] ALPHABET =
        "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();

    private static final int RADIX = 36;
    private static final int LENGTH = 6;

    public static final long DOMAIN_SIZE = 2_176_782_336L;

    private final byte[] aesKey;
    private final byte[] tweak;

    public RedeemCodeFf1(byte[] aesKey, String namespace) {
        Objects.requireNonNull(aesKey, "aesKey");
        Objects.requireNonNull(namespace, "namespace");

        if (aesKey.length != 16
                && aesKey.length != 24
                && aesKey.length != 32) {
            throw new IllegalArgumentException("invalid AES key length");
        }
        if (namespace.isEmpty()) {
            throw new IllegalArgumentException("namespace must not be empty");
        }

        this.aesKey = aesKey.clone();
        this.tweak = namespace.getBytes(StandardCharsets.UTF_8);
    }

    public String encode(long id) {
        requireInDomain(id);

        byte[] input = toDigits(id);
        byte[] output = new byte[LENGTH];

        FPEFF1Engine ff1 = new FPEFF1Engine();
        ff1.init(true, new FPEParameters(
            new KeyParameter(aesKey), RADIX, tweak));
        ff1.processBlock(input, 0, input.length, output, 0);

        return toCode(output);
    }

    public long decode(String code) {
        byte[] input = parseAsciiCode(code);
        byte[] output = new byte[LENGTH];

        FPEFF1Engine ff1 = new FPEFF1Engine();
        ff1.init(false, new FPEParameters(
            new KeyParameter(aesKey), RADIX, tweak));
        ff1.processBlock(input, 0, input.length, output, 0);

        return fromDigits(output);
    }

    private static byte[] toDigits(long value) {
        byte[] digits = new byte[LENGTH];

        for (int i = LENGTH - 1; i >= 0; i--) {
            digits[i] = (byte) (value % RADIX);
            value /= RADIX;
        }
        return digits;
    }

    private static long fromDigits(byte[] digits) {
        long value = 0;

        for (byte digit : digits) {
            value = value * RADIX + (digit & 0xff);
        }
        return value;
    }

    private static String toCode(byte[] digits) {
        char[] chars = new char[LENGTH];

        for (int i = 0; i < LENGTH; i++) {
            chars[i] = ALPHABET[digits[i] & 0xff];
        }
        return new String(chars);
    }

    private static byte[] parseAsciiCode(String code) {
        if (code == null || code.length() != LENGTH) {
            throw new IllegalArgumentException(
                "code must be exactly 6 ASCII characters");
        }

        byte[] digits = new byte[LENGTH];

        for (int i = 0; i < LENGTH; i++) {
            char c = code.charAt(i);
            int digit;

            if (c >= '0' && c <= '9') {
                digit = c - '0';
            } else if (c >= 'A' && c <= 'Z') {
                digit = c - 'A' + 10;
            } else {
                throw new IllegalArgumentException(
                    "code must contain only ASCII 0-9 and A-Z");
            }

            digits[i] = (byte) digit;
        }
        return digits;
    }

    private static void requireInDomain(long id) {
        if (id < 0 || id >= DOMAIN_SIZE) {
            throw new IllegalArgumentException("id outside domain");
        }
    }
}
