/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.kafka.message;

import java.io.Writer;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Generates Kafka MessageData classes.
 */
public final class MessageDataGenerator {
    private final HeaderGenerator headerGenerator;
    private final SchemaGenerator schemaGenerator;
    private final CodeBuffer buffer;

    MessageDataGenerator() {
        this.headerGenerator = new HeaderGenerator();
        this.schemaGenerator = new SchemaGenerator(headerGenerator);
        this.buffer = new CodeBuffer();
    }

    void generate(MessageSpec message) throws Exception {
        if (message.struct().versions().contains(Short.MAX_VALUE)) {
            throw new RuntimeException("Message " + message.name() + " does " +
                "not specify a maximum version.");
        }
        schemaGenerator.generateSchemas(message);
        generateClass(Optional.of(message),
            message.name() + "Data",
            message.struct(),
            message.struct().versions());
        headerGenerator.generate();
    }

    void write(Writer writer) throws Exception {
        headerGenerator.buffer().write(writer);
        buffer.write(writer);
    }

    private void generateClass(Optional<MessageSpec> topLevelMessageSpec,
                               String className,
                               StructSpec struct,
                               Versions parentVersions) throws Exception {
        buffer.printf("%n");
        boolean isTopLevel = topLevelMessageSpec.isPresent();
        boolean isSetElement = struct.hasKeys(); // Check if the class is inside a set.
        if (isTopLevel && isSetElement) {
            throw new RuntimeException("Cannot set mapKey on top level fields.");
        }
        generateClassHeader(className, isTopLevel, isSetElement);
        buffer.incrementIndent();
        generateFieldDeclarations(struct, isSetElement);
        buffer.printf("%n");
        schemaGenerator.writeSchema(className, buffer);
        generateClassConstructors(className, struct);
        buffer.printf("%n");
        if (isTopLevel) {
            generateShortAccessor("apiKey", topLevelMessageSpec.get().apiKey().orElse((short) -1));
        }
        buffer.printf("%n");
        generateShortAccessor("lowestSupportedVersion", parentVersions.lowest());
        buffer.printf("%n");
        generateShortAccessor("highestSupportedVersion", parentVersions.highest());
        buffer.printf("%n");
        generateClassReader(className, struct, parentVersions);
        buffer.printf("%n");
        generateClassWriter(className, struct, parentVersions);
        buffer.printf("%n");
        generateClassFromStruct(className, struct, parentVersions);
        buffer.printf("%n");
        generateClassToStruct(className, struct, parentVersions);
        buffer.printf("%n");
        generateClassSize(className, struct, parentVersions);
        buffer.printf("%n");
        generateClassEquals(className, struct, isSetElement);
        buffer.printf("%n");
        generateClassHashCode(struct, isSetElement);
        buffer.printf("%n");
        generateClassToString(className, struct);
        generateFieldAccessors(struct, isSetElement);
        generateFieldMutators(struct, className, isSetElement);

        if (!isTopLevel) {
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        generateSubclasses(className, struct, parentVersions, isSetElement);
        if (isTopLevel) {
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
    }

    private void generateClassHeader(String className, boolean isTopLevel,
                                     boolean isSetElement) {
        Set<String> implementedInterfaces = new HashSet<>();
        if (isTopLevel) {
            implementedInterfaces.add("ApiMessage");
            headerGenerator.addImport(MessageGenerator.API_MESSAGE_CLASS);
        } else {
            implementedInterfaces.add("Message");
            headerGenerator.addImport(MessageGenerator.MESSAGE_CLASS);
        }
        if (isSetElement) {
            headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
            implementedInterfaces.add("ImplicitLinkedHashMultiSet.Element");
        }
        Set<String> classModifiers = new HashSet<>();
        classModifiers.add("public");
        if (!isTopLevel) {
            classModifiers.add("static");
        }
        buffer.printf("%s class %s implements %s {%n",
            String.join(" ", classModifiers),
            className,
            String.join(", ", implementedInterfaces));
    }

    private void generateSubclasses(String className, StructSpec struct,
            Versions parentVersions, boolean isSetElement) throws Exception {
        for (FieldSpec field : struct.fields()) {
            if (field.type().isStructArray()) {
                FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
                generateClass(Optional.empty(),
                    arrayType.elementType().toString(),
                    field.toStruct(),
                    parentVersions.intersect(struct.versions()));
            }
        }
        if (isSetElement) {
            generateHashSet(className, struct);
        }
    }

    private void generateHashSet(String className, StructSpec struct) {
        buffer.printf("%n");
        headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
        buffer.printf("public static class %s extends ImplicitLinkedHashMultiSet<%s> {%n",
            hashSetType(className), className);
        buffer.incrementIndent();
        generateHashSetZeroArgConstructor(className);
        generateHashSetSizeArgConstructor(className);
        generateHashSetIteratorConstructor(className);
        generateHashSetFindMethod(className, struct);
        generateHashSetFindAllMethod(className, struct);
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateHashSetZeroArgConstructor(String className) {
        buffer.printf("public %s() {%n", hashSetType(className));
        buffer.incrementIndent();
        buffer.printf("super();%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
    }

    private void generateHashSetSizeArgConstructor(String className) {
        buffer.printf("public %s(int expectedNumElements) {%n", hashSetType(className));
        buffer.incrementIndent();
        buffer.printf("super(expectedNumElements);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
    }

    private void generateHashSetIteratorConstructor(String className) {
        headerGenerator.addImport(MessageGenerator.ITERATOR_CLASS);
        buffer.printf("public %s(Iterator<%s> iterator) {%n", hashSetType(className), className);
        buffer.incrementIndent();
        buffer.printf("super(iterator);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
    }

    private void generateHashSetFindMethod(String className, StructSpec struct) {
        headerGenerator.addImport(MessageGenerator.LIST_CLASS);
        buffer.printf("public %s find(%s) {%n", className,
            commaSeparatedHashSetFieldAndTypes(struct));
        buffer.incrementIndent();
        generateKeyElement(className, struct);
        headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
        buffer.printf("return find(key);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
    }

    private void generateHashSetFindAllMethod(String className, StructSpec struct) {
        headerGenerator.addImport(MessageGenerator.LIST_CLASS);
        buffer.printf("public List<%s> findAll(%s) {%n", className,
            commaSeparatedHashSetFieldAndTypes(struct));
        buffer.incrementIndent();
        generateKeyElement(className, struct);
        headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
        buffer.printf("return findAll(key);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
    }

    private void generateKeyElement(String className, StructSpec struct) {
        buffer.printf("%s key = new %s();%n", className, className);
        for (FieldSpec field : struct.fields()) {
            if (field.mapKey()) {
                buffer.printf("key.set%s(%s);%n",
                    field.capitalizedCamelCaseName(),
                    field.camelCaseName());
            }
        }
    }

    private String commaSeparatedHashSetFieldAndTypes(StructSpec struct) {
        return struct.fields().stream().
            filter(f -> f.mapKey()).
            map(f -> String.format("%s %s", fieldConcreteJavaType(f), f.camelCaseName())).
            collect(Collectors.joining(", "));
    }

    private void generateFieldDeclarations(StructSpec struct, boolean isSetElement) {
        for (FieldSpec field : struct.fields()) {
            generateFieldDeclaration(field);
        }
        if (isSetElement) {
            buffer.printf("private int next;%n");
            buffer.printf("private int prev;%n");
        }
    }

    private void generateFieldDeclaration(FieldSpec field) {
        buffer.printf("private %s %s;%n",
            fieldAbstractJavaType(field), field.camelCaseName());
    }

    private void generateFieldAccessors(StructSpec struct, boolean isSetElement) {
        for (FieldSpec field : struct.fields()) {
            generateFieldAccessor(field);
        }
        if (isSetElement) {
            buffer.printf("%n");
            buffer.printf("@Override%n");
            generateAccessor("int", "next", "next");

            buffer.printf("%n");
            buffer.printf("@Override%n");
            generateAccessor("int", "prev", "prev");
        }
    }

    private void generateFieldMutators(StructSpec struct, String className,
                                       boolean isSetElement) {
        for (FieldSpec field : struct.fields()) {
            generateFieldMutator(className, field);
        }
        if (isSetElement) {
            buffer.printf("%n");
            buffer.printf("@Override%n");
            generateSetter("int", "setNext", "next");

            buffer.printf("%n");
            buffer.printf("@Override%n");
            generateSetter("int", "setPrev", "prev");
        }
    }

    private static String hashSetType(String baseType) {
        return baseType + "Set";
    }

    private String fieldAbstractJavaType(FieldSpec field) {
        if (field.type() instanceof FieldType.BoolFieldType) {
            return "boolean";
        } else if (field.type() instanceof FieldType.Int8FieldType) {
            return "byte";
        } else if (field.type() instanceof FieldType.Int16FieldType) {
            return "short";
        } else if (field.type() instanceof FieldType.Int32FieldType) {
            return "int";
        } else if (field.type() instanceof FieldType.Int64FieldType) {
            return "long";
        } else if (field.type().isString()) {
            return "String";
        } else if (field.type().isBytes()) {
            return "byte[]";
        } else if (field.type().isStruct()) {
            return MessageGenerator.capitalizeFirst(field.typeString());
        } else if (field.type().isArray()) {
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            if (field.toStruct().hasKeys()) {
                headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
                return hashSetType(arrayType.elementType().toString());
            } else {
                headerGenerator.addImport(MessageGenerator.LIST_CLASS);
                return "List<" + getBoxedJavaType(arrayType.elementType()) + ">";
            }
        } else {
            throw new RuntimeException("Unknown field type " + field.type());
        }
    }

    private String fieldConcreteJavaType(FieldSpec field) {
        if (field.type().isArray()) {
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            if (field.toStruct().hasKeys()) {
                headerGenerator.addImport(MessageGenerator.IMPLICIT_LINKED_HASH_MULTI_SET_CLASS);
                return hashSetType(arrayType.elementType().toString());
            } else {
                headerGenerator.addImport(MessageGenerator.ARRAYLIST_CLASS);
                return "ArrayList<" + getBoxedJavaType(arrayType.elementType()) + ">";
            }
        } else {
            return fieldAbstractJavaType(field);
        }
    }

    private void generateClassConstructors(String className, StructSpec struct) {
        headerGenerator.addImport(MessageGenerator.READABLE_CLASS);
        buffer.printf("public %s(Readable readable, short version) {%n", className);
        buffer.incrementIndent();
        initializeArrayDefaults(struct);
        buffer.printf("read(readable, version);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
        headerGenerator.addImport(MessageGenerator.STRUCT_CLASS);
        buffer.printf("public %s(Struct struct, short version) {%n", className);
        buffer.incrementIndent();
        initializeArrayDefaults(struct);
        buffer.printf("fromStruct(struct, version);%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.printf("%n");
        buffer.printf("public %s() {%n", className);
        buffer.incrementIndent();
        for (FieldSpec field : struct.fields()) {
            buffer.printf("this.%s = %s;%n",
                field.camelCaseName(), fieldDefault(field));
        }
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void initializeArrayDefaults(StructSpec struct) {
        for (FieldSpec field : struct.fields()) {
            if (field.type().isArray()) {
                buffer.printf("this.%s = %s;%n",
                    field.camelCaseName(), fieldDefault(field));
            }
        }
    }

    private void generateShortAccessor(String name, short val) {
        buffer.printf("@Override%n");
        buffer.printf("public short %s() {%n", name);
        buffer.incrementIndent();
        buffer.printf("return %d;%n", val);
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateClassReader(String className, StructSpec struct,
                                     Versions parentVersions) {
        headerGenerator.addImport(MessageGenerator.READABLE_CLASS);
        buffer.printf("@Override%n");
        buffer.printf("public void read(Readable readable, short version) {%n");
        buffer.incrementIndent();
        if (generateInverseVersionCheck(parentVersions, struct.versions())) {
            buffer.incrementIndent();
            headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
            buffer.printf("throw new UnsupportedVersionException(\"Can't read " +
                "version \" + version + \" of %s\");%n", className);
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        Versions curVersions = parentVersions.intersect(struct.versions());
        if (curVersions.empty()) {
            throw new RuntimeException("Version ranges " + parentVersions +
                " and " + struct.versions() + " have no versions in common.");
        }
        for (FieldSpec field : struct.fields()) {
            generateFieldReader(field, curVersions);
        }
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldReader(FieldSpec field, Versions curVersions) {
        if (field.type().isArray()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            if (!maybeAbsent) {
                buffer.printf("{%n");
                buffer.incrementIndent();
            }
            boolean hasKeys = field.toStruct().hasKeys();
            buffer.printf("int arrayLength = readable.readInt();%n");
            buffer.printf("if (arrayLength < 0) {%n");
            buffer.incrementIndent();
            buffer.printf("this.%s = null;%n",
                field.camelCaseName());
            buffer.decrementIndent();
            buffer.printf("} else {%n");
            buffer.incrementIndent();
            buffer.printf("this.%s.clear(%s);%n",
                field.camelCaseName(),
                hasKeys ? "arrayLength" : "");
            buffer.printf("for (int i = 0; i < arrayLength; i++) {%n");
            buffer.incrementIndent();
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            buffer.printf("this.%s.add(%s);%n",
                field.camelCaseName(), readFieldFromReadable(arrayType.elementType()));
            buffer.decrementIndent();
            buffer.printf("}%n");
            buffer.decrementIndent();
            buffer.printf("}%n");
            if (maybeAbsent) {
                generateSetDefault(field);
            } else {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("this.%s = %s;%n",
                field.camelCaseName(),
                readFieldFromReadable(field.type()));
            if (maybeAbsent) {
                generateSetDefault(field);
            }
        }
    }

    private String readFieldFromReadable(FieldType type) {
        if (type instanceof FieldType.BoolFieldType) {
            return "readable.readByte() != 0";
        } else if (type instanceof FieldType.Int8FieldType) {
            return "readable.readByte()";
        } else if (type instanceof FieldType.Int16FieldType) {
            return "readable.readShort()";
        } else if (type instanceof FieldType.Int32FieldType) {
            return "readable.readInt()";
        } else if (type instanceof FieldType.Int64FieldType) {
            return "readable.readLong()";
        } else if (type.isString()) {
            return "readable.readNullableString()";
        } else if (type.isBytes()) {
            return "readable.readNullableBytes()";
        } else if (type.isStruct()) {
            return String.format("new %s(readable, version)", type.toString());
        } else {
            throw new RuntimeException("Unsupported field type " + type);
        }
    }

    private void generateClassFromStruct(String className, StructSpec struct,
                                         Versions parentVersions) {
        headerGenerator.addImport(MessageGenerator.STRUCT_CLASS);
        buffer.printf("@Override%n");
        buffer.printf("public void fromStruct(Struct struct, short version) {%n");
        buffer.incrementIndent();
        if (generateInverseVersionCheck(parentVersions, struct.versions())) {
            buffer.incrementIndent();
            headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
            buffer.printf("throw new UnsupportedVersionException(\"Can't read " +
                "version \" + version + \" of %s\");%n", className);
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        Versions curVersions = parentVersions.intersect(struct.versions());
        if (curVersions.empty()) {
            throw new RuntimeException("Version ranges " + parentVersions +
                " and " + struct.versions() + " have no versions in common.");
        }
        for (FieldSpec field : struct.fields()) {
            generateFieldFromStruct(field, curVersions);
        }
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldFromStruct(FieldSpec field, Versions curVersions) {
        if (field.type().isArray()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            if (!maybeAbsent) {
                buffer.printf("{%n");
                buffer.incrementIndent();
            }
            headerGenerator.addImport(MessageGenerator.STRUCT_CLASS);
            buffer.printf("Object[] nestedObjects = struct.getArray(\"%s\");%n",
                field.snakeCaseName());
            boolean maybeNull = false;
            if (!curVersions.intersect(field.nullableVersions()).empty()) {
                maybeNull = true;
                buffer.printf("if (nestedObjects == null) {%n", field.camelCaseName());
                buffer.incrementIndent();
                buffer.printf("this.%s = null;%n", field.camelCaseName());
                buffer.decrementIndent();
                buffer.printf("} else {%n");
                buffer.incrementIndent();
            }
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            FieldType elementType = arrayType.elementType();
            buffer.printf("this.%s = new %s(nestedObjects.length);%n",
                field.camelCaseName(), fieldConcreteJavaType(field));
            buffer.printf("for (Object nestedObject : nestedObjects) {%n");
            buffer.incrementIndent();
            if (elementType.isStruct()) {
                buffer.printf("this.%s.add(new %s((Struct) nestedObject, version));%n",
                    field.camelCaseName(), elementType.toString());
            } else {
                buffer.printf("this.%s.add((%s) nestedObject);%n",
                    field.camelCaseName(), getBoxedJavaType(elementType));
            }
            buffer.decrementIndent();
            buffer.printf("}%n");
            if (maybeNull) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
            if (maybeAbsent) {
                generateSetDefault(field);
            } else {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("this.%s = %s;%n",
                field.camelCaseName(),
                readFieldFromStruct(field.type(), field.snakeCaseName()));
            if (maybeAbsent) {
                generateSetDefault(field);
            }
        }
    }

    private String getBoxedJavaType(FieldType type) {
        if (type instanceof FieldType.BoolFieldType) {
            return "Boolean";
        } else if (type instanceof FieldType.Int8FieldType) {
            return "Byte";
        } else if (type instanceof FieldType.Int16FieldType) {
            return "Short";
        } else if (type instanceof FieldType.Int32FieldType) {
            return "Integer";
        } else if (type instanceof FieldType.Int64FieldType) {
            return "Long";
        } else if (type.isString()) {
            return "String";
        } else if (type.isStruct()) {
            return type.toString();
        } else {
            throw new RuntimeException("Unsupported field type " + type);
        }
    }

    private String readFieldFromStruct(FieldType type, String name) {
        if (type instanceof FieldType.BoolFieldType) {
            return String.format("struct.getBoolean(\"%s\")", name);
        } else if (type instanceof FieldType.Int8FieldType) {
            return String.format("struct.getByte(\"%s\")", name);
        } else if (type instanceof FieldType.Int16FieldType) {
            return String.format("struct.getShort(\"%s\")", name);
        } else if (type instanceof FieldType.Int32FieldType) {
            return String.format("struct.getInt(\"%s\")", name);
        } else if (type instanceof FieldType.Int64FieldType) {
            return String.format("struct.getLong(\"%s\")", name);
        } else if (type.isString()) {
            return String.format("struct.getString(\"%s\")", name);
        } else if (type.isBytes()) {
            return String.format("struct.getByteArray(\"%s\")", name);
        } else if (type.isStruct()) {
            return String.format("new %s(struct, version)", type.toString());
        } else {
            throw new RuntimeException("Unsupported field type " + type);
        }
    }

    private void generateClassWriter(String className, StructSpec struct,
            Versions parentVersions) {
        headerGenerator.addImport(MessageGenerator.WRITABLE_CLASS);
        buffer.printf("@Override%n");
        buffer.printf("public void write(Writable writable, short version) {%n");
        buffer.incrementIndent();
        if (generateInverseVersionCheck(parentVersions, struct.versions())) {
            buffer.incrementIndent();
            headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
            buffer.printf("throw new UnsupportedVersionException(\"Can't write " +
                "version \" + version + \" of %s\");%n", className);
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        Versions curVersions = parentVersions.intersect(struct.versions());
        if (curVersions.empty()) {
            throw new RuntimeException("Version ranges " + parentVersions +
                " and " + struct.versions() + " have no versions in common.");
        }
        for (FieldSpec field : struct.fields()) {
            generateFieldWriter(field, curVersions);
        }
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private String writeFieldToWritable(FieldType type, boolean nullable, String name) {
        if (type instanceof FieldType.BoolFieldType) {
            return String.format("writable.writeByte(%s ? (byte) 1 : (byte) 0)", name);
        } else if (type instanceof FieldType.Int8FieldType) {
            return String.format("writable.writeByte(%s)", name);
        } else if (type instanceof FieldType.Int16FieldType) {
            return String.format("writable.writeShort(%s)", name);
        } else if (type instanceof FieldType.Int32FieldType) {
            return String.format("writable.writeInt(%s)", name);
        } else if (type instanceof FieldType.Int64FieldType) {
            return String.format("writable.writeLong(%s)", name);
        } else if (type instanceof FieldType.StringFieldType) {
            if (nullable) {
                return String.format("writable.writeNullableString(%s)", name);
            } else {
                return String.format("writable.writeString(%s)", name);
            }
        } else if (type instanceof FieldType.BytesFieldType) {
            if (nullable) {
                return String.format("writable.writeNullableBytes(%s)", name);
            } else {
                return String.format("writable.writeBytes(%s)", name);
            }
        } else if (type instanceof FieldType.StructType) {
            return String.format("%s.write(writable, version)", name);
        } else {
            throw new RuntimeException("Unsupported field type " + type);
        }
    }

    private void generateFieldWriter(FieldSpec field, Versions curVersions) {
        if (field.type().isArray()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            boolean maybeNull = generateNullCheck(curVersions, field);
            if (maybeNull) {
                buffer.printf("writable.writeInt(-1);%n");
                buffer.decrementIndent();
                buffer.printf("} else {%n");
                buffer.incrementIndent();
            }
            buffer.printf("writable.writeInt(%s.size());%n", field.camelCaseName());
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            FieldType elementType = arrayType.elementType();
            String nestedTypeName = elementType.isStruct() ?
                elementType.toString() : getBoxedJavaType(elementType);
            buffer.printf("for (%s element : %s) {%n",
                nestedTypeName, field.camelCaseName());
            buffer.incrementIndent();
            buffer.printf("%s;%n", writeFieldToWritable(elementType, false, "element"));
            buffer.decrementIndent();
            buffer.printf("}%n");
            if (maybeNull) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
            if (maybeAbsent) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("%s;%n", writeFieldToWritable(field.type(),
                !field.nullableVersions().empty(),
                field.camelCaseName()));
            if (maybeAbsent) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        }
    }

    private void generateClassToStruct(String className, StructSpec struct,
                                       Versions parentVersions) {
        headerGenerator.addImport(MessageGenerator.STRUCT_CLASS);
        buffer.printf("@Override%n");
        buffer.printf("public Struct toStruct(short version) {%n");
        buffer.incrementIndent();
        if (generateInverseVersionCheck(parentVersions, struct.versions())) {
            buffer.incrementIndent();
            headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
            buffer.printf("throw new UnsupportedVersionException(\"Can't write " +
                "version \" + version + \" of %s\");%n", className);
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        Versions curVersions = parentVersions.intersect(struct.versions());
        if (curVersions.empty()) {
            throw new RuntimeException("Version ranges " + parentVersions +
                " and " + struct.versions() + " have no versions in common.");
        }
        buffer.printf("Struct struct = new Struct(SCHEMAS[version]);%n");
        for (FieldSpec field : struct.fields()) {
            generateFieldToStruct(field, curVersions);
        }
        buffer.printf("return struct;%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldToStruct(FieldSpec field, Versions curVersions) {
        if ((!field.type().canBeNullable()) &&
            (!field.nullableVersions().empty())) {
            throw new RuntimeException("Fields of type " + field.type() +
                " cannot be nullable.");
        }
        if ((field.type() instanceof FieldType.BoolFieldType) ||
                (field.type() instanceof FieldType.Int8FieldType) ||
                (field.type() instanceof FieldType.Int16FieldType) ||
                (field.type() instanceof FieldType.Int32FieldType) ||
                (field.type() instanceof FieldType.Int64FieldType) ||
                (field.type() instanceof FieldType.StringFieldType)) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("struct.set(\"%s\", this.%s);%n",
                field.snakeCaseName(), field.camelCaseName());
            if (maybeAbsent) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else if (field.type().isBytes()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("struct.setByteArray(\"%s\", this.%s);%n",
                field.snakeCaseName(), field.camelCaseName());
            if (maybeAbsent) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else if (field.type().isArray()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            if (!maybeAbsent) {
                buffer.printf("{%n");
                buffer.incrementIndent();
            }
            boolean maybeNull = generateNullCheck(curVersions, field);
            if (maybeNull) {
                buffer.printf("struct.set(\"%s\", null);%n", field.snakeCaseName());
                buffer.decrementIndent();
                buffer.printf("} else {%n");
                buffer.incrementIndent();
            }
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            FieldType elementType = arrayType.elementType();
            String boxdElementType = elementType.isStruct() ? "Struct" : getBoxedJavaType(elementType);
            buffer.printf("%s[] nestedObjects = new %s[%s.size()];%n",
                boxdElementType, boxdElementType, field.camelCaseName());
            buffer.printf("int i = 0;%n");
            buffer.printf("for (%s element : this.%s) {%n",
                getBoxedJavaType(arrayType.elementType()), field.camelCaseName());
            buffer.incrementIndent();
            if (elementType.isStruct()) {
                buffer.printf("nestedObjects[i++] = element.toStruct(version);%n");
            } else {
                buffer.printf("nestedObjects[i++] = element;%n");
            }
            buffer.decrementIndent();
            buffer.printf("}%n");
            buffer.printf("struct.set(\"%s\", (Object[]) nestedObjects);%n",
                field.snakeCaseName());
            if (maybeNull) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
            buffer.decrementIndent();
            buffer.printf("}%n");
        } else {
            throw new RuntimeException("Unsupported field type " + field.type());
        }
    }

    private void generateClassSize(String className, StructSpec struct,
                                   Versions parentVersions) {
        buffer.printf("@Override%n");
        buffer.printf("public int size(short version) {%n");
        buffer.incrementIndent();
        buffer.printf("int size = 0;%n");
        if (generateInverseVersionCheck(parentVersions, struct.versions())) {
            buffer.incrementIndent();
            headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
            buffer.printf("throw new UnsupportedVersionException(\"Can't size " +
                "version \" + version + \" of %s\");%n", className);
            buffer.decrementIndent();
            buffer.printf("}%n");
        }
        Versions curVersions = parentVersions.intersect(struct.versions());
        if (curVersions.empty()) {
            throw new RuntimeException("Version ranges " + parentVersions +
                " and " + struct.versions() + " have no versions in common.");
        }
        for (FieldSpec field : struct.fields()) {
            generateFieldSize(field, curVersions);
        }
        buffer.printf("return size;%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateVariableLengthFieldSize(String fieldName, FieldType type, boolean nullable) {
        if (type instanceof FieldType.StringFieldType) {
            buffer.printf("size += 2;%n");
            if (nullable) {
                buffer.printf("if (%s != null) {%n", fieldName);
                buffer.incrementIndent();
            }
            headerGenerator.addImport(MessageGenerator.MESSAGE_UTIL_CLASS);
            buffer.printf("size += MessageUtil.serializedUtf8Length(%s);%n", fieldName);
            if (nullable) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else if (type instanceof FieldType.BytesFieldType) {
            buffer.printf("size += 4;%n");
            if (nullable) {
                buffer.printf("if (%s != null) {%n", fieldName);
                buffer.incrementIndent();
            }
            buffer.printf("size += %s.length;%n", fieldName);
            if (nullable) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
        } else if (type instanceof FieldType.StructType) {
            buffer.printf("size += %s.size(version);%n", fieldName);
        } else {
            throw new RuntimeException("Unsupported type " + type);
        }
    }

    private void generateFieldSize(FieldSpec field, Versions curVersions) {
        if (field.type().fixedLength().isPresent()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            buffer.printf("size += %d;%n", field.type().fixedLength().get());
            if (maybeAbsent) {
                buffer.decrementIndent();
                generateAbsentValueCheck(field);
            }
        } else if (field.type().isString() || field.type().isBytes() || field.type().isStruct()) {
            boolean nullable = !curVersions.intersect(field.nullableVersions()).empty();
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            generateVariableLengthFieldSize(field.camelCaseName(), field.type(), nullable);
            if (maybeAbsent) {
                buffer.decrementIndent();
                generateAbsentValueCheck(field);
            }
        } else if (field.type().isArray()) {
            boolean maybeAbsent =
                generateVersionCheck(curVersions, field.versions());
            boolean maybeNull = generateNullCheck(curVersions, field);
            if (maybeNull) {
                buffer.printf("size += 4;%n");
                buffer.decrementIndent();
                buffer.printf("} else {%n");
                buffer.incrementIndent();
            }
            buffer.printf("size += 4;%n");
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            FieldType elementType = arrayType.elementType();
            if (elementType.fixedLength().isPresent()) {
                buffer.printf("size += %s.size() * %d;%n",
                    field.camelCaseName(),
                    elementType.fixedLength().get());
            } else if (elementType instanceof FieldType.ArrayType) {
                throw new RuntimeException("Arrays of arrays are not supported " +
                    "(use a struct).");
            } else {
                buffer.printf("for (%s element : %s) {%n",
                    getBoxedJavaType(elementType), field.camelCaseName());
                buffer.incrementIndent();
                generateVariableLengthFieldSize("element", elementType, false);
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
            if (maybeNull) {
                buffer.decrementIndent();
                buffer.printf("}%n");
            }
            if (maybeAbsent) {
                buffer.decrementIndent();
                generateAbsentValueCheck(field);
            }
        } else {
            throw new RuntimeException("Unsupported field type " + field.type());
        }
    }

    private void generateAbsentValueCheck(FieldSpec field) {
        if (field.ignorable()) {
            buffer.printf("}%n");
            return;
        }
        buffer.printf("} else {%n");
        buffer.incrementIndent();
        headerGenerator.addImport(MessageGenerator.UNSUPPORTED_VERSION_EXCEPTION_CLASS);
        if (field.type().isArray()) {
            buffer.printf("if (!%s.isEmpty()) {%n", field.camelCaseName());
        } else if (field.type().isBytes()) {
            buffer.printf("if (%s.length != 0) {%n", field.camelCaseName());
        } else if (field.type().isString()) {
            buffer.printf("if (%s.equals(%s)) {%n", field.camelCaseName(), fieldDefault(field));
        } else if (field.type() instanceof FieldType.BoolFieldType) {
            buffer.printf("if (%s%s) {%n",
                fieldDefault(field).equals("true") ? "!" : "",
                field.camelCaseName());
        } else {
            buffer.printf("if (%s != %s) {%n", field.camelCaseName(), fieldDefault(field));
        }
        buffer.incrementIndent();
        buffer.printf("throw new UnsupportedVersionException(" +
                "\"Attempted to write a non-default %s at version \" + version);%n",
            field.camelCaseName());
        buffer.decrementIndent();
        buffer.printf("}%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateClassEquals(String className, StructSpec struct, boolean onlyMapKeys) {
        buffer.printf("@Override%n");
        buffer.printf("public boolean equals(Object obj) {%n");
        buffer.incrementIndent();
        buffer.printf("if (!(obj instanceof %s)) return false;%n", className);
        if (!struct.fields().isEmpty()) {
            buffer.printf("%s other = (%s) obj;%n", className, className);
            for (FieldSpec field : struct.fields()) {
                if ((!onlyMapKeys) || field.mapKey()) {
                    generateFieldEquals(field);
                }
            }
        }
        buffer.printf("return true;%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldEquals(FieldSpec field) {
        if (field.type().isString() || field.type().isArray() || field.type().isStruct()) {
            buffer.printf("if (this.%s == null) {%n", field.camelCaseName());
            buffer.incrementIndent();
            buffer.printf("if (other.%s != null) return false;%n", field.camelCaseName());
            buffer.decrementIndent();
            buffer.printf("} else {%n");
            buffer.incrementIndent();
            buffer.printf("if (!this.%s.equals(other.%s)) return false;%n",
                field.camelCaseName(), field.camelCaseName());
            buffer.decrementIndent();
            buffer.printf("}%n");
        } else if (field.type().isBytes()) {
            // Arrays#equals handles nulls.
            headerGenerator.addImport(MessageGenerator.ARRAYS_CLASS);
            buffer.printf("if (!Arrays.equals(this.%s, other.%s)) return false;%n",
                field.camelCaseName(), field.camelCaseName());
        } else {
            buffer.printf("if (%s != other.%s) return false;%n",
                field.camelCaseName(), field.camelCaseName());
        }
    }

    private void generateClassHashCode(StructSpec struct, boolean onlyMapKeys) {
        buffer.printf("@Override%n");
        buffer.printf("public int hashCode() {%n");
        buffer.incrementIndent();
        buffer.printf("int hashCode = 0;%n");
        for (FieldSpec field : struct.fields()) {
            if ((!onlyMapKeys) || field.mapKey()) {
                generateFieldHashCode(field);
            }
        }
        buffer.printf("return hashCode;%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldHashCode(FieldSpec field) {
        if (field.type() instanceof FieldType.BoolFieldType) {
            buffer.printf("hashCode = 31 * hashCode + (%s ? 1231 : 1237);%n",
                field.camelCaseName());
        } else if ((field.type() instanceof FieldType.Int8FieldType) ||
                    (field.type() instanceof FieldType.Int16FieldType) ||
                    (field.type() instanceof FieldType.Int32FieldType)) {
            buffer.printf("hashCode = 31 * hashCode + %s;%n",
                field.camelCaseName());
        } else if (field.type() instanceof FieldType.Int64FieldType) {
            buffer.printf("hashCode = 31 * hashCode + ((int) (%s >> 32) ^ (int) %s);%n",
                field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isString()) {
            buffer.printf("hashCode = 31 * hashCode + (%s == null ? 0 : %s.hashCode());%n",
                field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isBytes()) {
            headerGenerator.addImport(MessageGenerator.ARRAYS_CLASS);
            buffer.printf("hashCode = 31 * hashCode + Arrays.hashCode(%s);%n",
                field.camelCaseName());
        } else if (field.type().isStruct() || field.type().isArray()) {
            buffer.printf("hashCode = 31 * hashCode + (%s == null ? 0 : %s.hashCode());%n",
                field.camelCaseName(), field.camelCaseName());
        } else {
            throw new RuntimeException("Unsupported field type " + field.type());
        }
    }

    private void generateClassToString(String className, StructSpec struct) {
        buffer.printf("@Override%n");
        buffer.printf("public String toString() {%n");
        buffer.incrementIndent();
        buffer.printf("return \"%s(\"%n", className);
        buffer.incrementIndent();
        String prefix = "";
        for (FieldSpec field : struct.fields()) {
            generateFieldToString(prefix, field);
            prefix = ", ";
        }
        buffer.printf("+ \")\";%n");
        buffer.decrementIndent();
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldToString(String prefix, FieldSpec field) {
        if (field.type() instanceof FieldType.BoolFieldType) {
            buffer.printf("+ \"%s%s=\" + (%s ? \"true\" : \"false\")%n",
                prefix, field.camelCaseName(), field.camelCaseName());
        } else if ((field.type() instanceof FieldType.Int8FieldType) ||
                (field.type() instanceof FieldType.Int16FieldType) ||
                (field.type() instanceof FieldType.Int32FieldType) ||
                (field.type() instanceof FieldType.Int64FieldType)) {
            buffer.printf("+ \"%s%s=\" + %s%n",
                prefix, field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isString()) {
            buffer.printf("+ \"%s%s='\" + %s + \"'\"%n",
                prefix, field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isBytes()) {
            headerGenerator.addImport(MessageGenerator.ARRAYS_CLASS);
            buffer.printf("+ \"%s%s=\" + Arrays.toString(%s)%n",
                prefix, field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isStruct()) {
            buffer.printf("+ \"%s%s=\" + %s.toString()%n",
                prefix, field.camelCaseName(), field.camelCaseName());
        } else if (field.type().isArray()) {
            headerGenerator.addImport(MessageGenerator.MESSAGE_UTIL_CLASS);
            if (field.nullableVersions().empty()) {
                buffer.printf("+ \"%s%s=\" + MessageUtil.deepToString(%s.iterator())%n",
                    prefix, field.camelCaseName(), field.camelCaseName());
            } else {
                buffer.printf("+ \"%s%s=\" + ((%s == null) ? \"null\" : " +
                    "MessageUtil.deepToString(%s.iterator()))%n",
                    prefix, field.camelCaseName(), field.camelCaseName(), field.camelCaseName());
            }
        } else {
            throw new RuntimeException("Unsupported field type " + field.type());
        }
    }

    private boolean generateNullCheck(Versions prevVersions, FieldSpec field) {
        if (prevVersions.intersect(field.nullableVersions()).empty()) {
            return false;
        }
        buffer.printf("if (%s == null) {%n", field.camelCaseName());
        buffer.incrementIndent();
        return true;
    }

    private boolean generateVersionCheck(Versions prev, Versions cur) {
        if (cur.lowest() > prev.lowest()) {
            if (cur.highest() < prev.highest()) {
                buffer.printf("if ((version >= %d) && (version <= %d)) {%n",
                    cur.lowest(), cur.highest());
                buffer.incrementIndent();
                return true;
            } else {
                buffer.printf("if (version >= %d) {%n", cur.lowest());
                buffer.incrementIndent();
                return true;
            }
        } else {
            if (cur.highest() < prev.highest()) {
                buffer.printf("if (version <= %d) {%n", cur.highest());
                buffer.incrementIndent();
                return true;
            } else {
                return false;
            }
        }
    }

    private boolean generateInverseVersionCheck(Versions prev, Versions cur) {
        if (cur.lowest() > prev.lowest()) {
            if (cur.highest() < prev.highest()) {
                buffer.printf("if ((version < %d) || (version > %d)) {%n",
                    cur.lowest(), cur.highest());
                return true;
            } else {
                buffer.printf("if (version < %d) {%n", cur.lowest());
                return true;
            }
        } else {
            if (cur.highest() < prev.highest()) {
                buffer.printf("if (version > %d) {%n", cur.highest());
                return true;
            } else {
                return false;
            }
        }
    }

    private void generateSetDefault(FieldSpec field) {
        buffer.decrementIndent();
        buffer.printf("} else {%n");
        buffer.incrementIndent();
        buffer.printf("this.%s = %s;%n", field.camelCaseName(), fieldDefault(field));
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private String fieldDefault(FieldSpec field) {
        if (field.type() instanceof FieldType.BoolFieldType) {
            if (field.defaultString().isEmpty()) {
                return "false";
            } else if (field.defaultString().equalsIgnoreCase("true")) {
                return "true";
            } else if (field.defaultString().equalsIgnoreCase("false")) {
                return "false";
            } else {
                throw new RuntimeException("Invalid default for boolean field " +
                    field.name() + ": " + field.defaultString());
            }
        } else if (field.type() instanceof FieldType.Int8FieldType) {
            if (field.defaultString().isEmpty()) {
                return "(byte) 0";
            } else {
                try {
                    Byte.decode(field.defaultString());
                } catch (NumberFormatException e) {
                    throw new RuntimeException("Invalid default for int8 field " +
                        field.name() + ": " + field.defaultString(), e);
                }
                return "(byte) " + field.defaultString();
            }
        } else if (field.type() instanceof FieldType.Int16FieldType) {
            if (field.defaultString().isEmpty()) {
                return "(short) 0";
            } else {
                try {
                    Short.decode(field.defaultString());
                } catch (NumberFormatException e) {
                    throw new RuntimeException("Invalid default for int16 field " +
                        field.name() + ": " + field.defaultString(), e);
                }
                return "(short) " + field.defaultString();
            }
        } else if (field.type() instanceof FieldType.Int32FieldType) {
            if (field.defaultString().isEmpty()) {
                return "0";
            } else {
                try {
                    Integer.decode(field.defaultString());
                } catch (NumberFormatException e) {
                    throw new RuntimeException("Invalid default for int32 field " +
                        field.name() + ": " + field.defaultString(), e);
                }
                return field.defaultString();
            }
        } else if (field.type() instanceof FieldType.Int64FieldType) {
            if (field.defaultString().isEmpty()) {
                return "0L";
            } else {
                try {
                    Integer.decode(field.defaultString());
                } catch (NumberFormatException e) {
                    throw new RuntimeException("Invalid default for int64 field " +
                        field.name() + ": " + field.defaultString(), e);
                }
                return field.defaultString() + "L";
            }
        } else if (field.type() instanceof FieldType.StringFieldType) {
            return "\"" + field.defaultString() + "\"";
        } else if (field.type().isBytes()) {
            if (!field.defaultString().isEmpty()) {
                throw new RuntimeException("Invalid default for bytes field " +
                    field.name() + ": custom defaults are not supported for bytes fields.");
            }
            headerGenerator.addImport(MessageGenerator.BYTES_CLASS);
            return "Bytes.EMPTY";
        } else if (field.type().isStruct()) {
            if (!field.defaultString().isEmpty()) {
                throw new RuntimeException("Invalid default for struct field " +
                    field.name() + ": custom defaults are not supported for struct fields.");
            }
            return "new " + field.type().toString() + "()";
        } else if (field.type().isArray()) {
            if (!field.defaultString().isEmpty()) {
                throw new RuntimeException("Invalid default for array field " +
                    field.name() + ": custom defaults are not supported for array fields.");
            }
            FieldType.ArrayType arrayType = (FieldType.ArrayType) field.type();
            if (field.toStruct().hasKeys()) {
                return "new " + hashSetType(arrayType.elementType().toString()) + "(0)";
            } else {
                headerGenerator.addImport(MessageGenerator.ARRAYLIST_CLASS);
                return "new ArrayList<" + getBoxedJavaType(arrayType.elementType()) + ">()";
            }
        } else {
            throw new RuntimeException("Unsupported field type " + field.type());
        }
    }

    private void generateFieldAccessor(FieldSpec field) {
        buffer.printf("%n");
        generateAccessor(fieldAbstractJavaType(field), field.camelCaseName(),
            field.camelCaseName());
    }

    private void generateAccessor(String javaType, String functionName, String memberName) {
        buffer.printf("public %s %s() {%n", javaType, functionName);
        buffer.incrementIndent();
        buffer.printf("return this.%s;%n", memberName);
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateFieldMutator(String className, FieldSpec field) {
        buffer.printf("%n");
        buffer.printf("public %s set%s(%s v) {%n",
            className,
            field.capitalizedCamelCaseName(),
            fieldAbstractJavaType(field));
        buffer.incrementIndent();
        buffer.printf("this.%s = v;%n", field.camelCaseName());
        buffer.printf("return this;%n");
        buffer.decrementIndent();
        buffer.printf("}%n");
    }

    private void generateSetter(String javaType, String functionName, String memberName) {
        buffer.printf("public void %s(%s v) {%n", functionName, javaType);
        buffer.incrementIndent();
        buffer.printf("this.%s = v;%n", memberName);
        buffer.decrementIndent();
        buffer.printf("}%n");
    }
}
