package ru.yandex.autodoc.common.doc.view.renderers;

import org.apache.commons.lang3.StringEscapeUtils;
import org.apache.commons.lang3.tuple.Pair;
import ru.yandex.autodoc.common.doc.view.Markup;
import ru.yandex.autodoc.common.doc.view.common.Renderer;
import ru.yandex.autodoc.common.out.json.builder.JsonValueBuilder;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * @author avhaliullin
 */
public class HtmlRenderer implements Renderer {
    public static final HtmlRenderer INSTANCE = new HtmlRenderer();

    public static final String PARAM_TITLE = "title";
    public static final String PARAM_TOP_HEADER_LEVEL = "topHeaderLevel";

    private static final String CSS_RESOURCE_NAME = "autodoc.css";
    private static final String JS_RESOURCE_NAME = "autodoc.js";
    private static final String JQUERY_VERSION = "2.0.0";

    private static final String JS_STATIC_CONTENT;
    private static final String CSS_STATIC_CONTENT;

    static {
        String jsContent = null;
        String cssContent = null;
        try {
            jsContent = readStringResource(JS_RESOURCE_NAME);
            cssContent = readStringResource(CSS_RESOURCE_NAME);
        } catch (Exception e) {
            // документация - вспомагательная конструкция. Очень страшно ронять приложение в static конструкторе отсюда
        }
        JS_STATIC_CONTENT = jsContent;
        CSS_STATIC_CONTENT = cssContent;
    }

    private static final Map<String, String> DEFAULT_SCOPE_NAMES = Collections.singletonMap("types", "Типы данных");

    private final JsonRenderer jsonRenderer;
    private final XmlRenderer xmlRenderer;

    public HtmlRenderer() {
        this.jsonRenderer = new JsonRenderer();
        this.xmlRenderer = new XmlRenderer();
    }

    @Override
    public String render(Markup markup, Map<String, String> params) {
        String pageTitle = params.getOrDefault(PARAM_TITLE, "Документация");
        int topHeaderLevel = 1;
        if (params.containsKey(PARAM_TOP_HEADER_LEVEL)) {
            try {
                topHeaderLevel = Integer.parseInt(params.get(PARAM_TOP_HEADER_LEVEL));
            } catch (NumberFormatException e) {
                //ignored
            }
        }
        return render(markup, pageTitle, topHeaderLevel);
    }

    private String render(Markup markup, String pageTitle, int topHeaderLevel) {
        return render(markup, pageTitle, topHeaderLevel, 2);
    }

    public String render(Markup markup, String pageTitle, int topHeaderLevel, int contentsDepth) {
        RenderingContext ctx = new RenderingContext(topHeaderLevel);
        Markup markupWithApplications = extractAndMarkupApplications(markup, DEFAULT_SCOPE_NAMES);
        String body = renderContents(markupWithApplications, contentsDepth, topHeaderLevel) +
                renderElement(ctx, topHeaderLevel, markupWithApplications, "", false);
        return "<!doctype html >" +
                "<html >" +
                " <head >" +
                "   <title > " + pageTitle + " </title >" +
                "   <script src='//yandex.st/jquery/" + JQUERY_VERSION + "/jquery.min.js' ></script >" +
                "   <script type='text/javascript'> " + getJsStaticContent() + " </script >" +
                " <style type='text/css' > " + getCssStaticContent() + " </style >" +
                " </head >" +
                " <body >" +
                body +
                "   <script type='text/javascript' >" +
                renderTypesJS(ctx) +
                "   </script>" +
                " <div id='blackout' ></div >" +
                " <div id='invisibles' ></div >" +
                " </body >" +
                "</html >";
    }

    private String renderTypesJS(RenderingContext ctx) {
        StringBuilder result = new StringBuilder();
        for (int i = 0; i < ctx.polyTypeSets.size(); i++) {
            Set<String> typeSet = ctx.polyTypeSets.get(i);
            result.append("injectOptions('").append(i).append("', [")
                    .append(typeSet.stream().map(tpe -> "'" + tpe + "'").collect(Collectors.joining(",")))
                    .append("]);\n");
            result.append("processTypeChange('").append(i).append("', '")
                    .append(typeSet.iterator().next())
                    .append("');\n");
        }
        return result.toString();
    }

    private String renderContents(Markup markup, int maxContentsDepth, int topHeaderLevel) {
        List<String> contents = doRenderContents(0, markup, "", maxContentsDepth, topHeaderLevel);
        if (contents.isEmpty()) {
            return "";
        } else {
            return header(topHeaderLevel, "Содержание") +
                    "<ul>" + String.join("", contents) + "</ul>";
        }
    }

    private List<String> doRenderContents(int level, Markup markup, String idPrefix, int maxContentsDepth, int topHeaderLevel) {
        if (level >= maxContentsDepth) {
            return Collections.emptyList();
        }
        if (markup instanceof Markup.Section) {
            Markup.Section section = (Markup.Section) markup;
            String id = makeId(idPrefix, section.getId());
            List<String> children = doRenderContents(level + 1, section.getContent(), id,
                    maxContentsDepth, topHeaderLevel);
            String result = "<li class='contents-item contents-item-" + level + "'>" +
                    header(topHeaderLevel + level + 1, href(section.getTitle(), "#" + id));
            if (!section.getChildren().isEmpty()) {
                result += "<ul>" + String.join("", children) + "</ul>";
            }
            result += "</li>";
            return Collections.singletonList(result);
        } else if (markup instanceof Markup.NonLeafMarkup) {
            return ((Markup.NonLeafMarkup) markup).getChildren().stream()
                    .flatMap(child -> doRenderContents(level, child, idPrefix, maxContentsDepth, topHeaderLevel).stream())
                    .collect(Collectors.toList());
        } else {
            return Collections.emptyList();
        }
    }

    String renderElement(RenderingContext ctx, int level, Markup element, String idPrefix, boolean inInvisible) {
        if (element instanceof Markup.Text) {
            return escapeHtml(((Markup.Text) element).getContent());
        } else if (element instanceof Markup.Link) {
            Markup.Link link = (Markup.Link) element;
            return href(link.getText(), link.getLink());
        } else if (element instanceof Markup.PreformattedText) {
            return "<pre>"
                    + escapeHtml(((Markup.PreformattedText) element).getContent())
                    + "</pre>";
        } else if (element instanceof Markup.Group) {
            return ((Markup.Group) element).getItems().stream()
                    .map(e -> renderElement(ctx, level, e, idPrefix, inInvisible))
                    .collect(Collectors.joining());
        } else if (element instanceof Markup.Section) {
            Markup.Section section = (Markup.Section) element;
            String id = ctx.bindId(idPrefix, section.getId());
            StringBuilder result = new StringBuilder("<div id='").append(id).append("' ");
            if (inInvisible) {
                result.append("parentid='").append(idPrefix).append("'");
            }
            result.append(" class='section'>");
            result.append(header(
                    level,
                    escapeHtml(section.getTitle())
                            + href("§", "#" + id, CSSClass.create("section-anchor"))
            ));
            result.append(renderElement(ctx, level + 1, section.getContent(), id, inInvisible));
            result.append("</div>");
            return result.toString();
        } else if (element instanceof Markup.Spoiler) {
            Markup.Spoiler spoiler = (Markup.Spoiler) element;
            String spoilerId = ctx.nextSpoilerId();
            String id = ctx.bindId(idPrefix, spoilerId);
            return href(spoiler.getTitle(), "javascript:spoiler(\"" + id + "\");")
                    + String.format("<br><div id='%1$s' class='spoiler hide'>%2$s</div>",
                    id, renderElement(ctx, level, spoiler.getContent(), idPrefix, inInvisible
                    )
            );
        } else if (element instanceof Markup.Popup) {
            Markup.Popup popup = (Markup.Popup) element;
            String id = ctx.bindId(idPrefix, popup.getId());
            String renderedTitle = renderElement(ctx, ctx.topHeaderLevel, new Markup.Header(popup.getTitle()), id, true);
            String renderedContent = renderElement(ctx, ctx.topHeaderLevel + 1, popup.getContent(), id, true);
            return href(escapeHtml(popup.getAnchor()), "#open(" + id + ")")
                    + "<div class='popup-box' id='" + id + "' parentid='" + idPrefix + "'>"
                    + "<div class='popup-close'>X</div>"
                    + "<div class='popup-header'>"
                    + renderedTitle
                    + "</div>"
                    + "<div class='popup-content'>"
                    + renderedContent
                    + "</div>"
                    + "</div>";
        } else if (element instanceof Markup.DocumentedJson) {
            String id = ctx.bindId(idPrefix, "json");
            StringBuilder result = new StringBuilder();
            result.append("<pre id='").append(id).append("' parentid='").append(idPrefix).append("' class='json code'>");
            ((Markup.DocumentedJson) element).getJson().writeValue(
                    JsonValueBuilder.createRootBuilder(new EnumResolvingHtmlJSONAppendable(result, new ElementContext(this, ctx, level, id, inInvisible), true))
            );
            result.append("</pre>");
            return result.toString();
        } else if (element instanceof Markup.UL) {
            Markup.UL ul = (Markup.UL) element;
            if (ul.getItems().isEmpty()) {
                return "";
            } else {
                return "<ul class='no-padding'>" +
                        ul.getItems().stream()
                                .map(e -> "<li>" + renderElement(ctx, level, e, idPrefix, inInvisible) + "</li>")
                                .collect(Collectors.joining()) +
                        "</ul>";
            }
        } else if (element instanceof Markup.Tabs) {
            Markup.Tabs tabs = (Markup.Tabs) element;
            String tabId = ctx.nextTabId();
            StringBuilder tabNames = new StringBuilder();
            StringBuilder tabContents = new StringBuilder();
            for (int i = 0; i < tabs.getPages().size(); i++) {
                Pair<String, Markup> page = tabs.getPages().get(i);
                tabNames.append(href(
                        page.getLeft(),
                        "javascript:tabBox(\"" + tabId + "\", " + i + ");",
                        CSSClass.create("tabs-selector", tabId + "-selector", tabId + "-selector-" + i)
                ));
                tabContents.append("<div class='tabs-tab-content'>")
                        .append(renderElement(ctx, level, page.getRight(), idPrefix, false))
                        .append("</div>");
            }
            return "<div class='tabs-box'>" +
                    "<div class='tabs-header'>" +
                    tabNames.toString() +
                    "</div>" +
                    "<div class='tabs-content' id=" + tabId + ">" +
                    tabContents +
                    "</div>" +
                    "</div>";
        } else if (element instanceof Markup.Table) {
            Markup.Table table = (Markup.Table) element;
            String headers = table.getHeaders().stream()
                    .map(h -> "<th>" + renderElement(ctx, level, h, idPrefix, inInvisible) + "</th>")
                    .collect(Collectors.joining());
            String rows = table.getRows().stream()
                    .map(row -> "<tr>" +
                            row.stream()
                                    .map(col -> "<td>" + renderElement(ctx, level, col, idPrefix, inInvisible) + "</td>")
                                    .collect(Collectors.joining()) + "</tr>"
                    ).collect(Collectors.joining());
            return "<table class='table'>" +
                    " <thead>" +
                    "   <tr>" +
                    headers +
                    "   </tr>" +
                    " </thead>" +
                    " <tbody>" +
                    rows +
                    " </tbody>" +
                    "</table>";
        } else if (element instanceof Markup.JsonObject) {
            return jsonRenderer.renderJson(
                    new ElementContext(this, ctx, level, idPrefix, inInvisible),
                    ((Markup.JsonObject) element).getJson()
            );
        } else if (element instanceof Markup.XmlObject) {
            return xmlRenderer.renderXml(
                    new ElementContext(this, ctx, level, idPrefix, inInvisible),
                    ((Markup.XmlObject) element).getXml()
            );
        } else if (element instanceof Markup.Header) {
            return header(level, ((Markup.Header) element).getContent());
        } else if (element instanceof Markup.B) {
            return "<span class='bold'>" +
                    renderElement(ctx, level, ((Markup.B) element).getContent(), idPrefix, inInvisible) +
                    "</span>";
        } else if (element instanceof Markup.I) {
            return "<span class='italic'>" +
                    renderElement(ctx, level, ((Markup.I) element).getContent(), idPrefix, inInvisible) +
                    "</span>";
        } else if (element instanceof Markup.Block) {
            return "<div>" +
                    renderElement(ctx, level, ((Markup.Block) element).getContent(), idPrefix, inInvisible) +
                    "</div>";
        } else if (element == Markup.EMPTY) {
            return "";
        } else {
            throw new RuntimeException("Unexpected markup block " + element);
        }
    }

    private Markup extractAndMarkupApplications(Markup markup, Map<String, String> scopeNames) {
        Map<ApplicationId, ApplicationInfo> applications = new HashMap<>();
        Markup result = extractApplications(markup, applications);
        Markup appsMarkup = markupApplications(applications, scopeNames);
        return result.concat(appsMarkup);
    }

    private Markup markupApplications(Map<ApplicationId, ApplicationInfo> applications, Map<String, String> scopeNames) {
        if (applications.isEmpty()) {
            return Markup.EMPTY;
        }
        Map<String, Markup> sectionId2Items = applications.entrySet().stream()
                .sorted(Comparator
                        .comparing((Map.Entry<ApplicationId, ApplicationInfo> e) -> e.getKey().scopeId)
                        .thenComparing(e -> e.getKey().itemId))
                .map(appEntry -> Pair.of(appEntry.getKey().scopeId, (Markup) new Markup.Section(
                        appEntry.getKey().itemId,
                        appEntry.getValue().title,
                        appEntry.getValue().content))
                ).collect(Collectors.groupingBy(// группируем по scope, конкатенируем разметку внутри
                        Pair::getLeft,
                        HashMap::new,
                        Markup.reducer(Pair::getRight)
                ));
        Markup appsMarkup = sectionId2Items.entrySet().stream()
                .sorted(Comparator.comparing(Map.Entry::getKey))
                .map(e -> new Markup.Section(
                        e.getKey(),
                        scopeNames.getOrDefault(e.getKey(), e.getKey()),
                        e.getValue()
                )).collect(Markup.reducer());
        return new Markup.Section("applications", "Приложения", appsMarkup);
    }

    private Markup extractApplications(Markup markup, Map<ApplicationId, ApplicationInfo> applications) {
        if (markup instanceof Markup.NonLeafMarkup) {
            return ((Markup.NonLeafMarkup) markup).mapContents(m -> extractApplications(m, applications));
        } else if (markup instanceof Markup.Application) {
            Markup.Application application = (Markup.Application) markup;
            ApplicationId appId = new ApplicationId(application.getScope(), application.getId());
            if (!applications.containsKey(appId)) {
                Markup appContent = application.getContent().get();
                //заглушка, чтобы не словить stack overflow
                applications.put(appId, new ApplicationInfo("", Markup.EMPTY));
                Markup resolvedContent = extractApplications(appContent, applications);
                applications.put(appId, new ApplicationInfo(application.getTitle(), resolvedContent));
            }
            return linkToApplication(application);
        } else {
            return markup;
        }
    }

    private Markup linkToApplication(Markup.Application app) {
        return new Markup.Link(
                app.getAnchor(),
                "#" + makeId(makeId("applications", app.getScope()), app.getId())
        );
    }

    static String href(String title, String href) {
        return href(title, href, CSSClass.EMPTY);
    }

    static String href(String title, String href, CSSClass css) {
        return String.format(
                "<a href='%1$s' %2$s>%3$s</a>",
                href,
                css.getAttribute(),
                escapeHtml(title)
        );
    }

    private String header(int level, String text) {
        return header(level, text, CSSClass.create("header"));
    }

    private String header(int level, String text, CSSClass css) {
        return String.format(
                "<h%1$d %2$s>%3$s</h%1$d>",
                level, css.getAttribute(), text
        );
    }

    private static String makeId(String prefix, String id) {
        return escapeId(prefix.isEmpty() ? id : prefix + "-" + id);
    }

    private static String escapeHtml(String s) {
        return StringEscapeUtils.escapeHtml4(s);
    }

    private static String escapeId(String id) {
        return id.replaceAll(" |\\#|\\$||\\(|\\)", "");
    }

    @Override
    public String contentType() {
        return "text/html";
    }

    private static class ApplicationId {
        private final String scopeId;
        private final String itemId;

        public ApplicationId(String scopeId, String itemId) {
            this.scopeId = scopeId;
            this.itemId = itemId;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            ApplicationId that = (ApplicationId) o;
            return Objects.equals(scopeId, that.scopeId) &&
                    Objects.equals(itemId, that.itemId);
        }

        @Override
        public int hashCode() {
            return Objects.hash(scopeId, itemId);
        }
    }

    private static class ApplicationInfo {
        private final String title;
        private final Markup content;

        public ApplicationInfo(String title, Markup content) {
            this.title = title;
            this.content = content;
        }
    }

    static class RenderingContext {
        final List<Set<String>> polyTypeSets = new ArrayList<>();
        final Set<String> ids = new HashSet<>();
        int spoilerCount;
        int tabCount;
        final BracketIndexes bracketIndexes = new BracketIndexes();
        final int topHeaderLevel;

        public RenderingContext(int topHeaderLevel) {
            this.topHeaderLevel = topHeaderLevel;
        }

        public String bindId(String idPrefix, String id) {
            String newId = makeId(idPrefix, id);
            if (ids.add(newId)) {
                return newId;
            } else {
                return bindId(idPrefix, id + "0");
            }
        }

        public String nextSpoilerId() {
            spoilerCount++;
            return "spoiler_" + spoilerCount;
        }

        public String nextTabId() {
            tabCount++;
            return "tab_" + tabCount;
        }
    }

    static class ElementContext {
        final HtmlRenderer htmlRenderer;
        final RenderingContext rCtx;
        final int level;
        final String idPrefix;
        final boolean inInvisible;

        public ElementContext(HtmlRenderer htmlRenderer, RenderingContext rCtx, int level, String idPrefix, boolean inInvisible) {
            this.htmlRenderer = htmlRenderer;
            this.rCtx = rCtx;
            this.level = level;
            this.idPrefix = idPrefix;
            this.inInvisible = inInvisible;
        }
    }

    static class BracketIndexes {
        private final Deque<Integer> stack = new ArrayDeque<>();
        private int index = 0;

        public int open() {
            index += 1;
            stack.push(index);
            return index;
        }

        public int close() {
            return stack.pop();
        }
    }

    private static String getJsStaticContent() {
        return JS_STATIC_CONTENT == null ? readStringResource(JS_RESOURCE_NAME) : JS_STATIC_CONTENT;
    }

    private static String getCssStaticContent() {
        return CSS_STATIC_CONTENT == null ? readStringResource(CSS_RESOURCE_NAME) : CSS_STATIC_CONTENT;
    }

    private static String readStringResource(String resourceName) {
        StringBuilder result = new StringBuilder();
        try (BufferedReader in =
                     new BufferedReader(new InputStreamReader(
                             HtmlRenderer.class.getResourceAsStream("/" + resourceName)
                     ))) {
            String line;
            boolean first = true;
            while ((line = in.readLine()) != null) {
                if (!first) {
                    result.append("\n");
                }
                first = false;
                result.append(line);
            }
            return result.toString();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
