package tc.oc.pgm.map;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;

import com.google.common.base.Joiner;
import com.google.common.collect.Iterables;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.inject.assistedinject.Assisted;
import org.jdom2.Content;
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.JDOMParseException;
import org.jdom2.input.SAXBuilder;
import tc.oc.commons.core.logging.Loggers;
import tc.oc.commons.core.util.HashingInputStream;
import tc.oc.pgm.utils.XMLUtils;
import tc.oc.pgm.xml.InvalidXMLException;
import tc.oc.pgm.xml.Node;

import static com.google.common.base.Preconditions.checkNotNull;

public class MapFilePreprocessor {

    public interface Factory {
        MapFilePreprocessor create(MapSource source);
    }

    private final SAXBuilder builder;
    private final Logger logger;
    private final MapConfiguration mapConfiguration;
    private final MapSource source;
    private final Stack<Path> includeStack = new Stack<>();
    private final Map<Path, HashCode> includedFiles = new HashMap<>();

    @Inject MapFilePreprocessor(@Assisted MapSource source, Loggers loggers, SAXBuilder builder, MapConfiguration mapConfiguration) {
        this.source = source;
        this.logger = loggers.get(getClass(), source.getPath().toString());
        this.builder = builder;
        this.mapConfiguration = mapConfiguration;
    }

    public Map<Path, HashCode> getIncludedFiles() {
        return includedFiles;
    }

    public Document readRootDocument(Path file) throws InvalidXMLException {
        checkNotNull(file, "file");

        this.includeStack.clear();

        Document result = this.readDocument(file);

        this.includeStack.clear();

        if(source.globalIncludes()) {
            for(Path globalInclude : mapConfiguration.globalIncludes()) {
                final Path includePath = findIncludeFile(null, globalInclude, null);
                if(includePath != null) {
                    result.getRootElement().addContent(0, readIncludedDocument(includePath, null));
                }
            }
        }

        return result;
    }

    private Document readDocument(Path absolutePath) throws InvalidXMLException {
        final Path relativePath = source.getPath().relativize(absolutePath);
        Document doc;

        try(HashingInputStream istream = new HashingInputStream(Hashing.sha256(), new FileInputStream(absolutePath.toFile()))) {
            doc = this.builder.build(istream);
            doc.setBaseURI(relativePath.toString());
            this.includedFiles.put(absolutePath, istream.hash());

        } catch(FileNotFoundException e) {
            throw new InvalidXMLException("File not found", absolutePath.toString());
        } catch(IOException e) {
            throw new InvalidXMLException("Error reading file: " + e.getMessage(), absolutePath.toString());
        } catch(JDOMParseException e) {
            throw InvalidXMLException.fromJDOM(e, absolutePath.toString());
        } catch(JDOMException e) {
            throw new InvalidXMLException("Unhandled " + e.getClass().getSimpleName(), absolutePath.toString(), e);
        }

        this.processChildren(absolutePath, doc.getRootElement());

        return doc;
    }

    private @Nullable Path findIncludeFile(@Nullable Path basePath, Path includeFile, @Nullable Element includeElement) throws InvalidXMLException {
        Iterable<Path> includePaths = mapConfiguration.includePaths();
        if(basePath != null) {
            includePaths = Iterables.concat(includePaths, Collections.singleton(basePath));
        }

        for(Path includePath : includePaths) {
            Path fullPath = includePath.resolve(includeFile);
            if(Files.isRegularFile(fullPath)) {
                return fullPath.toAbsolutePath();
            }
        }

        return null;
    }

    private List<Content> readIncludedDocument(@Nullable Path basePath, Path includeFile, @Nullable Element includeElement) throws InvalidXMLException {
        final Path fullPath = findIncludeFile(basePath, includeFile, includeElement);
        if(fullPath == null) {
            throw new InvalidXMLException("Failed to find include: " + includeFile, includeElement);
        }
        return readIncludedDocument(fullPath, includeElement);
    }

    private List<Content> readIncludedDocument(Path fullPath, @Nullable Element includeElement) throws InvalidXMLException {
        if(includeStack.contains(fullPath)) {
            throw new InvalidXMLException("Circular include: " + Joiner.on(" --> ").join(includeStack), includeElement);
        }

        includeStack.push(fullPath);
        try {
            return readDocument(fullPath).getRootElement().cloneContent();
        } finally {
            includeStack.pop();
        }
    }

    private List<Content> processIncludeElement(Path baseFile, Element el) throws InvalidXMLException {
        Path path = XMLUtils.parseRelativePath(Node.fromRequiredAttr(el, "src"));
        return readIncludedDocument(baseFile.getParent(), path, el);
    }

    private <T> T getEnvironment(String key, Class<T> type, Node node) throws InvalidXMLException {
        Object value = mapConfiguration.environment().get(key);
        if(value == null) {
            logger.warning("Unknown environment variable '" + key + "', using default value: false");
            value = false;
        }
        if(!type.isInstance(value)) {
            throw new InvalidXMLException("Wrong variable type, expected " + type.getSimpleName() + ", was " + value.getClass().getSimpleName(), node);
        }
        return type.cast(value);
    }

    private List<Content> processConditional(Element el, boolean invert) throws InvalidXMLException {
        for(Node attr : Node.fromAttrs(el)) {
            boolean expected = XMLUtils.parseBoolean(attr);
            boolean actual = getEnvironment(attr.getName(), Boolean.class, attr);
            if(expected != actual) {
                return invert ? el.cloneContent() : Collections.emptyList();
            }
        }

        return invert ? Collections.emptyList() : el.cloneContent();
    }

    private void processChildren(Path file, Element parent) throws InvalidXMLException {
        for(int i = 0; i < parent.getContentSize(); i++) {
            Content content = parent.getContent(i);
            if(!(content instanceof Element)) continue;

            Element child = (Element) content;
            List<Content> replacement = null;

            switch(child.getName()) {
                case "include":
                    replacement = processIncludeElement(file, child);
                    break;

                case "if":
                    replacement = processConditional(child, false);
                    break;

                case "unless":
                    replacement = processConditional(child, true);
                    break;
            }

            if(replacement != null) {
                parent.removeContent(i);
                parent.addContent(i, replacement);
                i--; // Process replacement content
            } else {
                processChildren(file, child);
            }
        }
    }
}
