/**
 * Copyright 2015 Santhosh Kumar Tekuri
 *
 * The JLibs authors license 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 jlibs.core.util.i18n;

import jlibs.core.annotation.processing.AnnotationError;
import jlibs.core.annotation.processing.AnnotationProcessor;
import jlibs.core.annotation.processing.Environment;
import jlibs.core.annotation.processing.Printer;
import jlibs.core.lang.StringUtil;
import jlibs.core.lang.model.ModelUtil;
import org.kohsuke.MetaInfServices;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.util.ElementFilter;
import javax.lang.model.util.Elements;
import javax.tools.FileObject;
import javax.tools.StandardLocation;
import java.io.BufferedWriter;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.*;

import static jlibs.core.annotation.processing.Printer.MINUS;
import static jlibs.core.annotation.processing.Printer.PLUS;
import static jlibs.core.util.i18n.PropertiesUtil.*;

/**
 * @author Santhosh Kumar T
 */
@SuppressWarnings({"unchecked"})
@SupportedAnnotationTypes({ "jlibs.core.util.i18n.ResourceBundle", "jlibs.core.util.i18n.Bundle" })
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedOptions("ResourceBundle.basename")
@MetaInfServices(Processor.class)
public class BundleAnnotationProcessor extends AnnotationProcessor{
    private static String basename;

    private static class Info{
        private String pakage;
        private BufferedWriter props;
        private Map<String, Element> entries = new HashMap<String, Element>();
        private Interfaces interfaces;
        private Bundles bundles;

        public Info(Element element, AnnotationMirror mirror) throws IOException{
            pakage = ModelUtil.getPackage(element);
            if(ModelUtil.exists(pakage, basename+".properties"))
                throw new AnnotationError(element, mirror, basename+".properties in package "+pakage+" already exists in source path");

            FileObject resource = Environment.get().getFiler().createResource(StandardLocation.CLASS_OUTPUT, pakage, basename+".properties");
            props = new BufferedWriter(resource.openWriter());
        }

        public void addResourceBundle(TypeElement clazz) throws IOException{
            if(interfaces==null)
                interfaces = new Interfaces(entries);
            interfaces.add(clazz);
        }

        public void addBundle(Element element) throws IOException{
            if(bundles==null)
                bundles = new Bundles();
            bundles.add(element);
        }

        public void generate() throws IOException{
            writeComments(props, " DON'T EDIT THIS FILE. THIS IS GENERATED BY JLIBS");
            writeComments(props, " @author Santhosh Kumar T");
            props.newLine();

            if(interfaces!=null){
                interfaces.generateClass(basename);
                interfaces.generateProperties(props);
            }
            if(bundles!=null)
                bundles.generateProperties(entries, props);

            close();
        }

        public void close() throws IOException{
            if(interfaces!=null){
                interfaces.close();
                interfaces = null;
            }
            if(props!=null){
                props.close();
                props = null;
            }
        }
    }
    private static Map<String, Info> infos = new HashMap<String, Info>();

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv){
        basename = Environment.get().getOptions().get("ResourceBundle.basename");
        if(basename==null)
            basename = "Bundle";

        try{
            for(TypeElement annotation: annotations){
                if(annotation.getQualifiedName().contentEquals(ResourceBundle.class.getName())){
                    for(Element elem: roundEnv.getElementsAnnotatedWith(annotation)){
                        TypeElement c = (TypeElement)elem;
                        String pakage = ModelUtil.getPackage(c);

                        if(c.getKind()!=ElementKind.INTERFACE)
                            throw new AnnotationError(elem, ResourceBundle.class.getName()+" annotation can be applied only for interface");

                        Info info = infos.get(pakage);
                        if(info==null)
                            infos.put(pakage, info=new Info(c, ModelUtil.getAnnotationMirror(c, ResourceBundle.class)));
                        info.addResourceBundle(c);
                    }
                }else{
                    for(Element elem: roundEnv.getElementsAnnotatedWith(annotation)){
                        String pakage = ModelUtil.getPackage(elem);
                        Info info = infos.get(pakage);
                        if(info==null)
                            infos.put(pakage, info=new Info(elem, ModelUtil.getAnnotationMirror(elem, Bundle.class)));
                        info.addBundle(elem);
                    }
                }
            }

            for(Info info: infos.values())
                info.generate();
        }catch(AnnotationError error){
            error.report();
        }catch(IOException ex){
            throw new RuntimeException(ex);
        }finally{
            for(Info info : infos.values()){
                try{
                    info.close();
                }catch(IOException ignore){
                    // ignore
                }
            }
            infos.clear();
        }
        return true;
    }
}

class Interfaces{
    private List<String> interfaces = new ArrayList<String>();
    private Printer printer;
    private Map<String, ExecutableElement> entries;
    private Map<Element, Map<String, ExecutableElement>> classes = new HashMap<Element, Map<String, ExecutableElement>>();

    @SuppressWarnings({"unchecked"})
    Interfaces(Map entries){
        this.entries = entries;
    }

    public void add(TypeElement clazz) throws IOException{
        if(printer==null)
            printer = Printer.get(clazz, ResourceBundle.class, I18N.FORMAT);

        interfaces.add(clazz.getSimpleName().toString());

        while(clazz!=null && !clazz.getQualifiedName().contentEquals(Object.class.getName())){
            for(ExecutableElement method: ElementFilter.methodsIn(clazz.getEnclosedElements()))
                add(method);
            clazz = ModelUtil.getSuper(clazz);
        }
    }

    private void add(ExecutableElement method){
        AnnotationMirror mirror = ModelUtil.getAnnotationMirror(method, Message.class);
        if(mirror==null)
            throw new AnnotationError(method, Message.class.getName()+" annotation is missing on this method");
        if(!ModelUtil.isAssignable(method.getReturnType(), String.class)){
            if(!ModelUtil.isAssignable(method.getReturnType(), Throwable.class)){
                throw new AnnotationError(method, "method annotated with "+Message.class.getName()+
                        " must return java.lang.String or a subclass of java.lang.Throwable");
            }
            Element element = ((DeclaredType)method.getReturnType()).asElement();
            boolean foundValidConstructor = false;
            for(ExecutableElement constructor: ElementFilter.constructorsIn(element.getEnclosedElements())){
                List<? extends VariableElement> params = constructor.getParameters();
                if(params.size()==2){
                    if(ModelUtil.isAssignable(params.get(0).asType(), String.class)
                        && ModelUtil.isAssignable(params.get(1).asType(), String.class)){
                        foundValidConstructor = true;
                        break;
                    }
                }
            }
            if(!foundValidConstructor){
                String className = ModelUtil.toString(method.getReturnType(), false);
                throw new AnnotationError(method, "Constructor "+className+"(String errorCode, String message) not found");
            }
        }

        String signature = ModelUtil.signature(method, false);
        for(ExecutableElement m : entries.values()){
            if(signature.equals(ModelUtil.signature(m, false)))
                throw new AnnotationError(method, "clashes with similar method in "+m.getEnclosingElement()+" interface");
        }

        AnnotationMirror messageMirror = ModelUtil.getAnnotationMirror(method, Message.class);
        String key = ModelUtil.getAnnotationValue(method, messageMirror, "key");
        if(StringUtil.isEmpty(key))
            key = method.getSimpleName().toString();

        ExecutableElement clash = entries.put(key, method);
        Element interfase = method.getEnclosingElement();
        if(clash!=null)
            throw new AnnotationError(method, "key '"+key+"' is already used by \""+ModelUtil.signature(clash, false)+"\" in "+ clash.getEnclosingElement()+" interface");

        Map<String, ExecutableElement> methods = classes.get(interfase);
        if(methods==null)
            classes.put(interfase, methods=new HashMap<String, ExecutableElement>());
        methods.put(key, method);
    }

    public void generateClass(String basename) throws IOException{
        printer.printPackage();

        printer.importClass(java.util.ResourceBundle.class);
        printer.importClass(MessageFormat.class);
        printer.importClass(LocaleContext.class);
        printer.emptyLine(true);

        printer.printClassDoc();
        printer.println("@SuppressWarnings(\"unchecked\")");
        printer.println("public class "+printer.generatedClazz +" implements "+StringUtil.join(interfaces.iterator(), ", ")+"{");
        printer.indent++;

        printer.println("public static final "+printer.generatedClazz +" INSTANCE = new "+printer.generatedClazz +"();");
        printer.emptyLine(true);
        printer.printlns(
            "private final ResourceBundle BUNDLE(){",
                PLUS,
                "return ResourceBundle.getBundle(\""+printer.generatedPakage.replace('.', '/')+"/"+basename+"\", LocaleContext.getLocale());",
                MINUS,
            "}"
        );
        printer.emptyLine(true);

        for(Map.Entry<Element, Map<String, ExecutableElement>> methods : classes.entrySet()){
            printer.emptyLine(true);
            printer.println("/*-------------------------------------------------[ "+methods.getKey().getSimpleName()+" ]---------------------------------------------------*/");
            printer.emptyLine(true);

            for(Map.Entry<String, ExecutableElement> entry : methods.getValue().entrySet()){
                String key = entry.getKey();
                ExecutableElement method = entry.getValue();

                printer.println("@Override");

                boolean returnsException = ModelUtil.isAssignable(method.getReturnType(), Throwable.class);
                String returnType = returnsException
                                        ? ModelUtil.toString(method.getReturnType(), false)
                                        : "String" ;
                printer.print("public "+returnType+" "+method.getSimpleName()+"(");

                int i = 0;
                StringBuilder params = new StringBuilder();
                for(VariableElement param : method.getParameters()){
                    String paramName = param.getSimpleName().toString();

                    params.append(", ");
                    if(i>0)
                        printer.print(", ");
                    params.append(paramName);
                    printer.print(ModelUtil.toString(param.asType(), false)+" "+paramName);
                    i++;
                }
                if(params.length()==0)
                    params.append(", new Object[0]");

                printer.println("){");
                printer.indent++;

                final String message = "MessageFormat.format(BUNDLE().getString(\""+key+"\")"+params+")";
                if(returnsException){
                    String prefix = printer.generatedPakage;
                    String option = Environment.get().getOptions().get("ResourceBundle.ignorePackageCount");
                    int packageIgnoreCount = option==null ? 2 : Integer.parseInt(option);
                    if(packageIgnoreCount==-1)
                        prefix = "";
                    else{
                        for(int j=0; j<packageIgnoreCount; j++){
                            int dot = prefix.indexOf(".");
                            if(dot==-1)
                                break;
                            prefix = prefix.substring(dot+1);
                        }
                    }
                    String errorCode = StringUtil.capitalize(key);
                    if(!prefix.isEmpty())
                        errorCode = prefix+"."+errorCode;
                    printer.println(" return new "+returnType+"(\""+errorCode+"\", "+message+");");
                }else
                    printer.println("return "+message+";");
                printer.indent--;
                printer.println("}");
            }
        }

        printer.indent--;
        printer.println("}");

        close();
    }

    public void generateProperties(BufferedWriter props) throws IOException{
        Elements elemUtil = Environment.get().getElementUtils();
        for(Map.Entry<Element, Map<String, ExecutableElement>> methods : classes.entrySet()){
            writeComments(props, "-------------------------------------------------[ "+methods.getKey().getSimpleName()+" ]---------------------------------------------------");
            props.newLine();

            for(Map.Entry<String, ExecutableElement> entry : methods.getValue().entrySet()){
                String key = entry.getKey();
                ExecutableElement method = entry.getValue();

                String doc = elemUtil.getDocComment(method);
                String methodDoc = ModelUtil.getMethodDoc(doc);
                if(!StringUtil.isEmpty(methodDoc))
                    writeComments(props, " "+methodDoc);

                int i = 0;
                Map<String, String> paramDocs = ModelUtil.getMethodParamDocs(doc);
                for(VariableElement param : method.getParameters()){
                    String paramName = param.getSimpleName().toString();
                    String paramDoc = paramDocs.get(paramName);
                    if(StringUtil.isEmpty(paramDoc))
                        writeComments(props, " {"+i+"} "+paramName);
                    else
                        writeComments(props, " {"+i+"} "+paramName+" ==> "+paramDoc);
                    i++;
                }

                AnnotationMirror messageMirror = ModelUtil.getAnnotationMirror(method, Message.class);
                String value = ModelUtil.getAnnotationValue(method, messageMirror, "value");

                try{
                    new MessageFormat(value);
                }catch(IllegalArgumentException ex){
                    throw new AnnotationError(method, messageMirror, ModelUtil.getRawAnnotationValue(method, messageMirror, "value"), "Invalid Message Format: "+ex.getMessage());
                }

                NavigableSet<Integer> args = findArgs(value);
                int argCount = args.size()==0 ? 0 : (args.last()+1);
                if(argCount!=method.getParameters().size())
                    throw new AnnotationError(method, "no of args in message format doesn't match with the number of parameters this method accepts");
                for(i=0; i<argCount; i++){
                    if(!args.remove(i))
                        throw new AnnotationError(method, messageMirror, "{"+i+"} is missing in message");
                }

                writeProperty(props, key, value);
                props.newLine();
            }
        }
    }

    public void close() throws IOException{
        if(printer!=null){
            printer.close();
            printer = null;
        }
    }
}

class Bundles{
    private Map<Element, List<Element>> classes = new HashMap<Element, List<Element>>();

    public void add(Element element){
        Element container = element.getEnclosingElement();
        if(container instanceof PackageElement)
            container = element;
        List<Element> list = classes.get(container);
        if(list==null)
            classes.put(container, list=new ArrayList<Element>());
        list.add(element);
    }

    @SuppressWarnings({"unchecked"})
    public void generateProperties(Map<String, Element> entries, BufferedWriter props) throws IOException{
        for(Map.Entry<Element, List<Element>> entry: classes.entrySet()){
            writeComments(props, "-------------------------------------------------[ "+entry.getKey().getSimpleName()+" ]---------------------------------------------------");
            props.newLine();

            for(Element method : entry.getValue()){
                AnnotationMirror mirror = ModelUtil.getAnnotationMirror(method, Bundle.class);
                for(AnnotationValue value: (Collection<AnnotationValue>)ModelUtil.getAnnotationValue(method, mirror, "value")){
                    AnnotationMirror entryMirror = (AnnotationMirror)value.getValue();
                    String rhs = ModelUtil.getAnnotationValue(method, entryMirror, "rhs");
                    if(rhs.length()>0){
                        String lhs = ModelUtil.getAnnotationValue(method, entryMirror, "lhs");
                        if(lhs.length()==0){
                            String hintName = ModelUtil.getAnnotationValue(method, entryMirror, "hintName");
                            if(hintName.length()==0){
                                Hint hint = Hint.valueOf(((VariableElement)ModelUtil.getAnnotationValue(method, entryMirror, "hint")).getSimpleName().toString());
                                if(hint==Hint.NONE)
                                    throw new AnnotationError("");
                                hintName = hint.key();
                            }
                            lhs = method.getEnclosingElement().getSimpleName()+"."+method.getSimpleName()+"."+hintName;
                        }
                        Element clash = entries.put(lhs, method);
                        if(clash!=null){
                            String signature;
                            if(clash instanceof ExecutableElement)
                                signature = ModelUtil.signature((ExecutableElement)clash, false);
                            else if(clash instanceof TypeElement)
                                throw new AnnotationError(method, "key '"+lhs+"' is already used by \""+((TypeElement)clash).getQualifiedName());
                            else
                                signature = clash.getSimpleName().toString();
                            throw new AnnotationError(method, "key '"+lhs+"' is already used by \""+signature+"\" in "+ clash.getEnclosingElement());
                        }

                        writeProperty(props, lhs, rhs);
                    }else{
                        String comment = ModelUtil.getAnnotationValue(method, entryMirror, "value");
                        if(comment.length()>0)
                            writeComments(props, comment);
                        else
                            props.newLine();
                    }
                }
            }
            props.newLine();
        }
    }
}