/*
 * 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.netbeans.modules.html.knockout;

import org.netbeans.modules.html.knockout.api.KODataBindTokenId;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.netbeans.api.editor.mimelookup.MimeRegistration;
import org.netbeans.api.html.lexer.HTMLTokenId;
import static org.netbeans.api.html.lexer.HTMLTokenId.TAG_CLOSE;
import static org.netbeans.api.html.lexer.HTMLTokenId.TAG_OPEN;
import static org.netbeans.api.html.lexer.HTMLTokenId.VALUE;
import org.netbeans.api.lexer.Language;
import org.netbeans.api.lexer.Token;
import org.netbeans.api.lexer.TokenSequence;
import org.netbeans.modules.html.editor.api.gsf.HtmlParserResult;
import org.netbeans.modules.html.editor.spi.embedding.JsEmbeddingProviderPlugin;
import org.netbeans.modules.html.knockout.KODataBindContext.ParentContext;
import org.netbeans.modules.html.knockout.model.KOModel;
import org.netbeans.modules.javascript2.lexer.api.JsTokenId;
import org.netbeans.modules.parsing.api.Embedding;
import org.netbeans.modules.parsing.api.Snapshot;
import org.netbeans.spi.knockout.Bindings;
import org.openide.filesystems.FileObject;
import org.openide.util.Pair;

/**
 * Knockout javascript virtual source extension
 *
 * @author mfukala@netbeans.org, phejl@netbeans.org
 */
@MimeRegistration(mimeType = "text/html", service = JsEmbeddingProviderPlugin.class)
public class KOJsEmbeddingProviderPlugin extends JsEmbeddingProviderPlugin {

    private static final Logger LOGGER = Logger.getLogger(KOJsEmbeddingProviderPlugin.class.getName());

    private static final String WITH_BIND = "with";
    private static final String FOREACH_BIND = "foreach";
    private static final String TEMPLATE_BIND = "template";

    private TokenSequence<HTMLTokenId> tokenSequence;
    private Snapshot snapshot;
    private List<Embedding> embeddings;
    private final Language JS_LANGUAGE;
    private final LinkedList<StackItem> stack;
    private String lastTagOpen = null;

    private final Map<String, KOTemplateContext.TemplateUsage> templateUsages = new HashMap<>();

    private final List<TemplateBoundary> templateBoundaries = new LinkedList<>();

    private final KODataBindContext dataBindContext = new KODataBindContext();

    private final KOTemplateContext templateContext = new KOTemplateContext();

    private KODataBindContext currentTemplateContext;

    private String generatedSource;

    public KOJsEmbeddingProviderPlugin() {
        JS_LANGUAGE = Language.find(KOUtils.JAVASCRIPT_MIMETYPE); //NOI18N
        this.stack = new LinkedList();
    }

    @Override
    public boolean startProcessing(HtmlParserResult parserResult, Snapshot snapshot, TokenSequence<HTMLTokenId> tokenSequence, List<Embedding> embeddings) {
        this.snapshot = snapshot;
        this.tokenSequence = tokenSequence;
        this.embeddings = embeddings;

        if(!KOModel.getModel(parserResult).containsKnockout()) {
            return false;
        }

        FileObject fo = snapshot.getSource().getFileObject();
        if (fo != null) {
            generatedSource = Bindings.findBindings(fo, 1);
        }
        return true;
    }

    @Override
    public void endProcessing() {
        int offset = 0;
        // XXX JsEmbeddingProvider:179 - embeddings are cleared on cancel
        // before (!) calling endProcessing
        if (!embeddings.isEmpty()) {
            for (TemplateBoundary boundary : templateBoundaries) {
                if (boundary.isStart()) {
                    KOTemplateContext.TemplateUsage usage = templateUsages.get(boundary.getName());
                    if (usage != null) {
                        KODataBindContext context = usage.getContext();
                        String name = null;
                        Set<KOTemplateContext.TemplateUsage> hierarchy = new LinkedHashSet<KOTemplateContext.TemplateUsage>();
                        hierarchy.add(usage);
                        while (usage != null && (name = usage.getParentTemplateName()) != null) {
                            usage = templateUsages.get(name);
                            // prevents endless loops while evaluating of cycled templates
                            if (hierarchy.contains(usage)) {
                                break;
                            }
                            hierarchy.add(usage);
                            if (usage != null) {
                                context = KODataBindContext.combine(usage.getContext(), context);
                            }
                        }

                        startKnockoutSnippet(context, boundary.getPosition() + offset);
                        offset++;
                    } else {
                        LOGGER.log(Level.FINE, "No usage for template {0}", boundary.getName());
                    }
                } else {
                    endKnockoutSnippet(boundary.getPosition() + offset);
                    offset++;
                }
            }
        }
        templateUsages.clear();
        templateBoundaries.clear();
        dataBindContext.clear();
        templateContext.clear();
        stack.clear();
        lastTagOpen = null;
    }

    @Override
    public boolean processToken() {
        boolean processed = false;

        Pair<Boolean, String> templateCheck = templateContext.process(tokenSequence.token());
        if (templateCheck != null) {
            if (templateCheck.first()) {
                currentTemplateContext = new KODataBindContext();
            } else {
                currentTemplateContext = null;
            }
        }

        String tokenText = tokenSequence.token().text().toString();

        switch (tokenSequence.token().id()) {
            case TAG_OPEN:
                lastTagOpen = tokenText;
                StackItem top = stack.peek();
                if (top != null && top.tag.equals(lastTagOpen)) {
                    top.balance++;
                }
                break;
            case TAG_CLOSE:
                top = stack.peek();
                if (top != null && top.tag.equals(tokenText)) {
                    top.balance--;
                    if (top.balance == 0) {
                        processed = true;
                        stack.pop();
                        String templateId = templateContext.getCurrentScriptId();
                        if (templateId != null) {
                            currentTemplateContext.pop();
                        } else {
                            dataBindContext.pop();
                        }
                    }
                }
                break;
            case VALUE:
                TokenSequence<KODataBindTokenId> embedded = tokenSequence.embedded(KODataBindTokenId.language());
                boolean setData = false;
                boolean setTemplate = false;
                if (embedded != null) {
                    String templateId = templateContext.getCurrentScriptId();
                    if (templateId != null) {
                        templateBoundaries.add(new TemplateBoundary(
                                templateId, embeddings.size(), true));
                    }
                    embedded.moveStart();
                    Token<KODataBindTokenId> dataValue = null;
                    boolean foreach = false;
                    while (embedded.moveNext()) {
                        if (embedded.token().id() == KODataBindTokenId.KEY) {
                            if (WITH_BIND.equals(embedded.token().text().toString()) // NOI18N
                                    || FOREACH_BIND.equals(embedded.token().text().toString())) { // NOI18N
                                stack.push(new StackItem(lastTagOpen));
                                setData = true;
                                foreach = FOREACH_BIND.equals(embedded.token().text().toString()); // NOI18N
                            } else if (TEMPLATE_BIND.equals(embedded.token().text().toString())) {
                                setTemplate = true;
                            }
                        }
                        if (setData && embedded.token().id() == KODataBindTokenId.VALUE && dataValue == null) {
                            dataValue = embedded.token();
                        }
                        if (setTemplate && embedded.token().id() == KODataBindTokenId.VALUE && dataValue == null) {
                            KODataBindContext context = currentTemplateContext != null
                                    ? currentTemplateContext : dataBindContext;
                            KODataBindContext templateBindContext = new KODataBindContext(context);
                            KODataBindDescriptor desc = KODataBindDescriptor.getDataBindDescriptor(
                                    snapshot, embedded.embedded(JsTokenId.javascriptLanguage()), false);
                            if (desc != null) {
                                templateBindContext.push(desc.getData(), desc.isIsForEach(), desc.getAlias());
                                String templateName = desc.getName();
                                KOTemplateContext.TemplateUsage usage = templateUsages.get(templateName);

                                if (usage == null) {
                                    usage = new KOTemplateContext.TemplateUsage(templateBindContext);
                                    if (templateId != null) {
                                        usage.addParentTemplateName(templateId);
                                    }
                                    templateUsages.put(templateName, usage);
                                } else {
                                    KODataBindContext current = usage.getContext();
                                    if (Objects.equals(current.getOriginal(), context)) {
                                        current.setData(current.getData() + " || " + templateBindContext.getData());
                                    } else {
                                        LOGGER.log(Level.INFO, "Multiple incompatible template usage; storing the last one");
                                        usage = new KOTemplateContext.TemplateUsage(templateBindContext);
                                        if (templateId != null) {
                                            usage.addParentTemplateName(templateId);
                                        }
                                        templateUsages.put(templateName, usage);
                                    }
                                }
                            } else {
                                LOGGER.log(Level.INFO, "Cannot get the template name at design time; ignoring");
                            }
                        }
                        if (embedded.embedded(JS_LANGUAGE) != null) {
                            processed = true;

                            if (templateId == null) {
                                startKnockoutSnippet(dataBindContext);
                            }

                            String embeddedText = embedded.token().text().toString();
                            boolean putParenthesis = !embeddedText.trim().isEmpty() &&
                                    !embeddedText.trim().endsWith(";"); // NOI18N

                            if (putParenthesis) {
                                embeddings.add(snapshot.create("(", KOUtils.JAVASCRIPT_MIMETYPE)); // NOI18N
                            }
                            CharSequence seq = embedded.token().text();
                            int emptyLength = 0;
                            for (int i = 0; i < seq.length(); i++) {
                                if (Character.isWhitespace(seq.charAt(i))) {
                                    emptyLength++;
                                } else {
                                    break;
                                }
                            }
                            if (emptyLength < seq.length()) {
                                embeddings.add(snapshot.create(embedded.offset() + emptyLength,
                                        embedded.token().length() - emptyLength, KOUtils.JAVASCRIPT_MIMETYPE));
                            } else {
                                embeddings.add(snapshot.create(embedded.offset(),
                                        embedded.token().length(), KOUtils.JAVASCRIPT_MIMETYPE));
                            }
                            if (putParenthesis) {
                                embeddings.add(snapshot.create(")", KOUtils.JAVASCRIPT_MIMETYPE)); // NOI18N
                            }
                            if (putParenthesis || !embeddedText.trim().endsWith(";")) { // NOI18N
                                embeddings.add(snapshot.create(";", KOUtils.JAVASCRIPT_MIMETYPE)); // NOI18N
                            }

                            if (templateId == null) {
                                endKnockoutSnippet();
                            }
                        }
                    }
                    if (setData) {
                        if (dataValue != null) {
                            if (templateId != null) {
                                currentTemplateContext.push(dataValue.text().toString().trim(), foreach, null);
                            } else {
                                KODataBindDescriptor desc = KODataBindDescriptor.getDataBindDescriptor(
                                        snapshot, embedded.embedded(JsTokenId.javascriptLanguage()), true);
                                if (desc != null) {
                                    dataBindContext.push(desc.getData().trim(), foreach, desc.getAlias());
                                } else {
                                    dataBindContext.push(dataValue.text().toString().trim(), foreach, null);
                                }
                            }
                        } else {
                            stack.pop();
                        }
                    }
                    if (templateId != null) {
                        templateBoundaries.add(new TemplateBoundary(
                                templateId, embeddings.size(), false));
                    }
                }
                break;
            default:
                break;
        }
        return processed;
    }

    private void startKnockoutSnippet(KODataBindContext context) {
        startKnockoutSnippet(context, null);
    }

    private void startKnockoutSnippet(KODataBindContext context, Integer position) {
        StringBuilder sb = new StringBuilder();
        sb.append("(function(){\n"); // NOI18N

        if (generatedSource != null) {
            sb.append(generatedSource).append("\n"); //NOI18N
        }

        // for now this is actually just a placeholder
        sb.append("var $element;\n");

        // define root as reference
        sb.append("var $root = ko.$bindings;\n"); // NOI18N

        if (context.isInForEach()) {
            sb.append("var $index = 0;\n");
        }

        // define data object
        String currentData = context.getData();
        if (currentData == null) {
            currentData = "$root"; // NOI18N
        }

        sb.append("var $parentContext = ");
        generateContext(sb, context.getParents());
        sb.append(";\n");

        sb.append("var $context = ");
        List<ParentContext> current = new ArrayList<>(context.getParents());
        current.add(new ParentContext(currentData, context.isInForEach(), context.getAlias()));
        generateContext(sb, current);
        sb.append(";\n");
        generateParentAndContextData("$context.", sb, context.getParents());

        generateParents(sb, context.getParents());

        generateWithHierarchyStart(sb, context.getParents());

        String dataValue = currentData;
        if ("$root".equals(currentData)) {
            dataValue = "ko.$bindings";
        }
        // may happen if enclosing with/foreach is empty - user is
        // going to fill it
        if (dataValue.trim().isEmpty()) {
            dataValue = "undefined";
        }
        sb.append("var $data = ").append(dataValue).append(";\n");
        if (context.getAlias() != null) {
            sb.append("var ").append(context.getAlias()).append(" = ").append(dataValue).append(";\n");
        }
        generateWithHierarchyEnd(sb, context.getParents());

        sb.append("with ($data) {\n");

        if (position == null) {
            embeddings.add(snapshot.create(sb.toString(), KOUtils.JAVASCRIPT_MIMETYPE));
        } else {
            embeddings.add(position, snapshot.create(sb.toString(), KOUtils.JAVASCRIPT_MIMETYPE));
        }
    }

    private void endKnockoutSnippet() {
        endKnockoutSnippet(null);
    }

    private void endKnockoutSnippet(Integer position) {
        StringBuilder sb = new StringBuilder();
        sb.append("}\n");
        sb.append("});\n");
        if (position == null) {
            embeddings.add(snapshot.create(sb.toString(), KOUtils.JAVASCRIPT_MIMETYPE));
        } else {
            embeddings.add(position, snapshot.create(sb.toString(), KOUtils.JAVASCRIPT_MIMETYPE));
        }
    }

    private static void generateContext(StringBuilder sb, List<ParentContext> parents) {
        if (parents.isEmpty()) {
            sb.append("undefined");
        } else {
            sb.append("{\n");
            sb.append("$parentContext :");
            generateContext(sb, parents.subList(0, parents.size() - 1));
            ParentContext parent = parents.get(parents.size() - 1);
            sb.append(",\n");
            sb.append("$root : ko.$bindings,\n");
            if (parent.isInForEach()) {
                sb.append("$index : 0,\n");
            }
            sb.append("}");
        }
    }

    private static void generateParentAndContextData(String additionalPrefix,
            StringBuilder sb, List<ParentContext> parents) {

        if (parents.isEmpty()) {
            if (additionalPrefix != null) {
                sb.append(additionalPrefix).append("$parentContext.$data = undefined;\n");
            }
            sb.append("$parentContext.$data = undefined;\n");
            sb.append("var $parent = undefined;\n");
            return;
        }
        StringBuilder prefix = new StringBuilder("$parentContext.");
        for (int i = 0; i < parents.size() - 1; i++) {
            sb.append("with (").append(parents.get(i).getValue()).append(") {\n");
        }
        sb.append("var $parent = ").append(parents.get(parents.size() - 1).getValue()).append(";\n");
        for (int i = parents.size() - 2; i >= 0; i--) {
            if (additionalPrefix != null) {
                sb.append(additionalPrefix).append(prefix).append("$data = ").append(parents.get(i + 1).getValue()).append(";\n");
            }
            sb.append(prefix).append("$data = ").append(parents.get(i + 1).getValue()).append(";\n");
            prefix.append("$parentContext.");
            sb.append("}\n");
        }
        if (additionalPrefix != null) {
            sb.append(additionalPrefix).append(prefix).append("$data = ko.$bindings;\n");
        }
        sb.append(prefix).append("$data = ko.$bindings;\n");
    }

    private static void generateParents(StringBuilder sb, List<ParentContext> parents) {
        sb.append("var $parents = ["); // NOI18N
        int pos = sb.length();
        StringBuilder prefix = new StringBuilder("$parentContext.");
        for (int i = 0; i < parents.size(); i++) {
            sb.insert(pos, ",");
            sb.insert(pos, "$data");
            sb.insert(pos, prefix);
            prefix.append("$parentContext.");
        }
        if (!parents.isEmpty()) {
            sb.setLength(sb.length() - 1);
        }
        sb.append("];\n"); // NOI18N
    }

    private static void generateWithHierarchyStart(StringBuilder sb, List<ParentContext> parents) {
        for (ParentContext context : parents) {
            if (context.getAlias() != null) {
                sb.append("var ").append(context.getAlias()).append(" = ").append(context.getValue()).append(";\n");
            }
            sb.append("with (").append(context.getValue()).append(") {\n");
        }
    }

    private static void generateWithHierarchyEnd(StringBuilder sb, List<ParentContext> parents) {
        for (int i = 0; i < parents.size(); i++) {
            sb.append("}\n");
        }
    }

    private static class StackItem {

        final String tag;
        
        int balance;

        public StackItem(String tag) {
            this.tag = tag;
            this.balance = 1;
        }
    }

    private static class TemplateBoundary {

        private final String name;

        private final int position;

        private final boolean start;

        public TemplateBoundary(String name, int position, boolean start) {
            this.name = name;
            this.position = position;
            this.start = start;
        }

        public String getName() {
            return name;
        }

        public int getPosition() {
            return position;
        }

        public boolean isStart() {
            return start;
        }
    }

}
