package com.atlassian.bitbucket.mesh.git.builder.catfile;

import com.atlassian.bitbucket.mesh.git.submodule.SubmoduleConfig;
import com.atlassian.bitbucket.mesh.process.handler.AbstractLineStdioHandler;
import com.atlassian.bitbucket.mesh.process.handler.LineDecoder;
import com.atlassian.bitbucket.mesh.repository.Repository;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Multimap;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.util.Objects.requireNonNull;

public class SubmoduleCatFileStdioHandler extends AbstractLineStdioHandler<Map<String, SubmoduleConfig>> {

    private static final Pattern PATTERN_CONFIG_SECTION = Pattern.compile("\\[([\\w\\-.]+|)(?: +\"([^\"]+)\")?]");

    private static final Logger log = LoggerFactory.getLogger(SubmoduleCatFileStdioHandler.class);

    private final Repository repository;
    private final List<ConfigSection> sections;

    private EntryBuilder entryBuilder;
    private ConfigSection.Builder sectionBuilder;

    public SubmoduleCatFileStdioHandler(@Nonnull Repository repository) {
        // Force CRLF_OR_LF to ensure we handle mixed EOL modes in the config file
        super(StandardCharsets.UTF_8, LineDecoder.Mode.CRLF_OR_LF);

        this.repository = requireNonNull(repository, "repository");

        sections = new ArrayList<>();
    }

    @Override
    public Map<String, SubmoduleConfig> getResult() {
        Map<String, SubmoduleConfig> modulesByPath = new HashMap<>(sections.size(), 1.0F);
        for (ConfigSection section : sections) {
            String path = section.getEntry("path");
            if (StringUtils.isBlank(path)) {
                log.warn("{}: '{}' has no 'path' configured", repository, section);
                continue;
            }

            String url = section.getEntry("url");
            if (StringUtils.isBlank(url)) {
                log.warn("{}: '{}' has no 'url' configured", repository, section);
                continue;
            }

            String active = section.getEntry("active");

            SubmoduleConfig collision = modulesByPath.put(path, new SubmoduleConfig.Builder(path, url)
                    .active(active == null || "true".equalsIgnoreCase(active))
                    .branch(section.getEntry("branch"))
                    .build());
            if (collision != null) {
                if (url.equals(collision.getUrl())) {
                    // Multiple mappings with the same URL means the end behavior is the same regardless
                    // of which mapping is used, so we log the duplicate at info level
                    log.info("{}: '{}' has multiple entries with the same URL", repository, path);
                } else {
                    // Multiple mappings each with a different URL, on the other hand, means the last
                    // mapping's URL will be the one we use, so we log this as a warning
                    log.warn("{}: '{}' has multiple entries with different URLs", repository, path);
                }
            }
        }

        return Collections.unmodifiableMap(modulesByPath);
    }

    @Override
    protected boolean onStdout(@Nonnull String line, boolean truncated) {
        if (entryBuilder != null) {
            // If we're building a multi-line entry:
            // - Don't trim continuation lines
            // - Don't bother checking the config section pattern
            if (!entryBuilder.append(line)) {
                entryBuilder.build(sectionBuilder);
                entryBuilder = null;
            }

            return true;
        }

        line = line.trim();
        if (line.isEmpty() || isCommentChar(line.charAt(0))) {
            return true;
        }

        Matcher matcher = PATTERN_CONFIG_SECTION.matcher(line);
        if (matcher.matches()) {
            if (sectionBuilder != null) {
                if (entryBuilder != null) {
                    entryBuilder.build(sectionBuilder);
                    entryBuilder = null;
                }
                sections.add(sectionBuilder.build());
            }
            sectionBuilder = new ConfigSection.Builder(matcher.group(1), matcher.group(2));
        } else if (sectionBuilder != null) {
            String[] split = line.split("=", 2);
            if (split.length == 2) {
                String name = split[0].trim();
                String value = split[1].trim();
                if (StringUtils.isEmpty(value)) {
                    sectionBuilder.entry(name, value);
                } else {
                    EntryBuilder builder = new EntryBuilder(name);
                    if (builder.append(value)) {
                        // This value spans multiple lines, so retain the builder to append more
                        entryBuilder = builder;
                    } else {
                        // Otherwise, this line represents the complete value
                        builder.build(sectionBuilder);
                    }
                }
            } else if (split.length == 1 && !(line.contains("#") || line.contains(";"))) {
                sectionBuilder.entry(line, "true");
            } else {
                log.warn("Invalid configuration line: '{}'", line);
            }
        }

        return true;
    }

    @Override
    protected void onStdoutClosed() {
        if (sectionBuilder != null) {
            sections.add(sectionBuilder.build());
        }
    }

    private static boolean isCommentChar(char c) {
        return c == '#' || c == ';';
    }

    private static class ConfigSection {

        private final Multimap<String, String> entries;
        private final String name;
        private final String subsection;

        ConfigSection(String name, String subsection, Multimap<String, String> entries) {
            this.name = requireNonNull(name, "name");
            this.subsection = subsection;
            this.entries = requireNonNull(entries, "entries");
        }

        @Override
        public String toString() {
            if (subsection == null) {
                return name;
            }
            return name + "." + subsection;
        }

        @Nullable
        String getEntry(@Nonnull String key) {
            Iterator<String> values = entries.get(key).iterator();
            if (values.hasNext()) {
                return values.next();
            }

            return null;
        }

        static class Builder {

            private final ImmutableMultimap.Builder<String, String> entries;
            private final String section;
            private final String subsection;

            Builder(@Nonnull String section, @Nullable String subsection) {
                this.section = requireNonNull(section, "section");
                this.subsection = subsection;

                entries = ImmutableMultimap.builder();
            }

            @Nonnull
            ConfigSection build() {
                return new ConfigSection(section, subsection, entries.build());
            }

            @Nonnull
            Builder entry(@Nonnull String name, @Nonnull String value) {
                entries.put(name, value);

                return this;
            }
        }
    }

    /**
     * A convenience class for keeping track of quote state for a single value.
     */
    private static class EntryBuilder {

        private final StringBuilder buffer;
        private final String name;

        private boolean inQuote;
        private int lastQuote;

        EntryBuilder(String name) {
            this.name = name;

            buffer = new StringBuilder();
        }

        boolean append(String line) {
            int length = line.length();
            for (int i = 0; i < length; i++) {
                char c = line.charAt(i);
                if (isCommentChar(c) && !inQuote) {
                    break;
                }
                boolean escaped = c == '\\';
                if (escaped) {
                    i++;
                    // At the end of the line but there is more
                    if (i == length) {
                        return true;
                    }
                    c = decodeEscapedCharacter(line.charAt(i));
                }
                if (c == '"' && !escaped) {
                    inQuote = !inQuote;
                    if (!inQuote) {
                        // Keep track of the last quote for correct trimming
                        lastQuote = buffer.length() - 1;
                    }
                } else {
                    // Trim whitespace at start of string when not quoted
                    if (buffer.length() > 0 || inQuote || !Character.isWhitespace(c)) {
                        buffer.append(c);
                    }
                }
            }
            // No more to read for this value
            return false;
        }

        void build(ConfigSection.Builder sectionBuilder) {
            trimWhitespacesToLastQuote(buffer, lastQuote);

            sectionBuilder.entry(name, buffer.toString());
        }

        // These are the only supported encoded characters
        private static char decodeEscapedCharacter(char c) {
            if (c == 't') {
                c = '\t';
            } else if (c == 'n') {
                c = '\n';
            } else if (c == 'b') {
                c = '\b';
            }
            return c;
        }

        private static void trimWhitespacesToLastQuote(StringBuilder buffer, int lastQuote) {
            for (int i = buffer.length() - 1; i >= 0 && Character.isWhitespace(buffer.charAt(i)) && i > lastQuote; i--) {
                buffer.setLength(i);
            }
        }
    }
}
