package cz.habarta.typescript.generator.ext;

import cz.habarta.typescript.generator.Settings;
import cz.habarta.typescript.generator.TsType;
import cz.habarta.typescript.generator.emitter.EmitterExtension;
import cz.habarta.typescript.generator.emitter.EmitterExtensionFeatures;
import cz.habarta.typescript.generator.emitter.TsBeanModel;
import cz.habarta.typescript.generator.emitter.TsModel;
import cz.habarta.typescript.generator.emitter.TsPropertyModel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Emitter which generates type-safe property path getters.
 *
 * Many javascript frameworks require that you specify "property paths"
 * to extract data from objects. For instance, if you have a data model
 * which is an array of items, and you want to display them in a grid,
 * you can give column specifications as strings, like "field1.field2".
 * With this emitter you can specify such paths like so:
 * {@code ClassName.field1.field2.get()}
 * Once you call {@code get()}, you get a string
 * (in this case "field1.field2")
 */
public class BeanPropertyPathExtension extends EmitterExtension {

    @Override
    public EmitterExtensionFeatures getFeatures() {
        final EmitterExtensionFeatures features = new EmitterExtensionFeatures();
        features.generatesRuntimeCode = true;
        return features;
    }

    @Override
    public void emitElements(Writer writer, Settings settings, boolean exportKeyword, TsModel model) {
        emitFieldsClass(writer, settings);

        Set<TsBeanModel> emittedBeans = new HashSet<>();
        for (TsBeanModel bean : model.getBeans()) {
            emittedBeans.addAll(
                writeBeanAndParentsFieldSpecs(writer, settings, model, emittedBeans, bean));
        }
        for (TsBeanModel bean : model.getBeans()) {
            createBeanFieldConstant(writer, exportKeyword, bean);
        }
    }

    private static void emitFieldsClass(Writer writer, Settings settings) {
        List<String> fieldsClassLines = Arrays.asList(
            "export class Fields {",
            "    protected $$parent: Fields | undefined;",
            "    protected $$name: string;",
            "    constructor(parent?: Fields, name?: string) {",
            "        this.$$parent = parent;",
            "        this.$$name = name || '';",
            "    };",
            "    get(): string {",
            "        if (this.$$parent && this.$$parent.get().length > 0) {",
            "            return this.$$parent.get() + \".\" + this.$$name;",
            "        } else {",
            "            return this.$$name;",
            "        }",
            "    }",
            "}");
        writer.writeIndentedLine("");
        for (String fieldsClassLine : fieldsClassLines) {
            writer.writeIndentedLine(fieldsClassLine.replace("    ", settings.indentString));
        }
    }

    /**
     * Emits a bean and its parent beans before if needed.
     * Returns the list of beans that were emitted.
     */
    private static Set<TsBeanModel> writeBeanAndParentsFieldSpecs(
        Writer writer, Settings settings, TsModel model, Set<TsBeanModel> emittedSoFar, TsBeanModel bean) {
        if (emittedSoFar.contains(bean)) {
            return new HashSet<>();
        }
        final TsBeanModel parentBean = getBeanModelByType(model, bean.getParent());
        final Set<TsBeanModel> emittedBeans = parentBean != null
            ? writeBeanAndParentsFieldSpecs(writer, settings, model, emittedSoFar, parentBean)
            : new HashSet<TsBeanModel>();
        final String parentClassName = parentBean != null
            ? getBeanModelClassName(parentBean) + "Fields"
            : "Fields";
        writer.writeIndentedLine("");
        writer.writeIndentedLine(
            "class " + getBeanModelClassName(bean) + "Fields extends " + parentClassName + " {");
        writer.writeIndentedLine(
            settings.indentString + "constructor(parent?: Fields, name?: string) { super(parent, name); }");
        for (TsPropertyModel property : bean.getProperties()) {
            writeBeanProperty(writer, settings, model, bean, property);
        }
        writer.writeIndentedLine("}");

        emittedBeans.add(bean);
        return emittedBeans;
    }

    /**
     * is this type an 'original' TS type, or a contextual information?
     * null, undefined and optional info are not original types, everything
     * else is original
     */
    private static boolean isOriginalTsType(TsType type) {
        if (type instanceof TsType.BasicType) {
            TsType.BasicType basicType = (TsType.BasicType)type;
            return !(basicType.name.equals("null") || basicType.name.equals("undefined"));
        }
        return true;
    }

    /**
     * If the type is optional of number|null|undefined, or list of
     * of integer, we want to be able to recognize it as number
     * to link the member to another class.
     * => extract the original type while ignoring the |null|undefined
     * and optional informations.
     */
    private static TsType extractOriginalTsType(TsType type) {
        if (type instanceof TsType.OptionalType) {
            return extractOriginalTsType(((TsType.OptionalType)type).type);
        }
        if (type instanceof TsType.UnionType) {
            TsType.UnionType union = (TsType.UnionType)type;
            List<TsType> originalTypes = new ArrayList<>();
            for (TsType curType : union.types) {
                if (isOriginalTsType(curType)) {
                    originalTypes.add(curType);
                }
            }
            return originalTypes.size() == 1
                ? extractOriginalTsType(originalTypes.get(0))
                : type;
        }
        if (type instanceof TsType.BasicArrayType) {
            return extractOriginalTsType(((TsType.BasicArrayType)type).elementType);
        }
        return type;
    }

    private static TsBeanModel getBeanModelByType(TsModel model, TsType type) {
        TsType originalType = extractOriginalTsType(type);
        if (!(originalType instanceof TsType.ReferenceType)) {
            return null;
        }
        TsType.ReferenceType originalTypeBean = (TsType.ReferenceType)originalType;

        for (TsBeanModel curBean : model.getBeans()) {
            if (curBean.getName().equals(originalTypeBean.symbol)) {
                return curBean;
            }
        }
        return null;
    }

    private static String getBeanModelClassName(TsBeanModel bean) {
        return bean.getName().getSimpleName();
    }

    private static void writeBeanProperty(
        Writer writer, Settings settings, TsModel model, TsBeanModel bean,
        TsPropertyModel property) {
        TsBeanModel fieldBeanModel = getBeanModelByType(model, property.getTsType());
        String fieldClassName = fieldBeanModel != null ? getBeanModelClassName(fieldBeanModel) : "";
        // if a class has a field of its own type, we get stackoverflow exception
        if (fieldClassName.equals(bean.getName().getSimpleName())) {
            fieldClassName = "";
        }
        writer.writeIndentedLine(
            settings.indentString + property.getName() + " = new " + fieldClassName + "Fields(this, \"" + property.getName() + "\");");
    }

    private static void createBeanFieldConstant(Writer writer, boolean exportKeyword, TsBeanModel bean) {
        writer.writeIndentedLine((exportKeyword ? "export " : "")
            + "const " + getBeanModelClassName(bean) + " = new " + getBeanModelClassName(bean) + "Fields();");
    }
}
