package p.studio.workspaces.assets.tiled;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

public final class TiledXmlCodec {
    public TiledMapDocument readMap(Path path) throws IOException {
        final Document document = parseDocument(path);
        final Element root = document.getDocumentElement();
        if (!"map".equals(root.getTagName())) {
            throw new IOException("TMX root element must be <map>.");
        }
        if (!"0".equals(root.getAttribute("infinite")) && !root.getAttribute("infinite").isBlank()) {
            throw new TiledUnsupportedFeatureException("Infinite maps are not supported in wave 1.");
        }
        rejectMapUnsupportedChildren(root);
        return new TiledMapDocument(
                root.getAttribute("version"),
                root.getAttribute("tiledversion"),
                root.getAttribute("orientation"),
                root.getAttribute("renderorder"),
                intAttribute(root, "width", 0),
                intAttribute(root, "height", 0),
                intAttribute(root, "tilewidth", 0),
                intAttribute(root, "tileheight", 0),
                intAttribute(root, "nextlayerid", 0),
                intAttribute(root, "nextobjectid", 0),
                readProperties(child(root, "properties")),
                readTilesets(root),
                readTileLayers(root),
                readObjectLayers(root));
    }

    public void writeMap(Path path, TiledMapDocument map) throws IOException {
        final Document document = newDocument();
        final Element root = document.createElement("map");
        document.appendChild(root);
        root.setAttribute("version", map.version());
        root.setAttribute("tiledversion", map.tiledVersion());
        root.setAttribute("orientation", map.orientation());
        root.setAttribute("renderorder", map.renderOrder());
        root.setAttribute("width", Integer.toString(map.width()));
        root.setAttribute("height", Integer.toString(map.height()));
        root.setAttribute("tilewidth", Integer.toString(map.tileWidth()));
        root.setAttribute("tileheight", Integer.toString(map.tileHeight()));
        root.setAttribute("infinite", "0");
        root.setAttribute("nextlayerid", Integer.toString(map.nextLayerId()));
        root.setAttribute("nextobjectid", Integer.toString(map.nextObjectId()));
        appendProperties(document, root, map.properties());
        for (TiledTilesetReference tileset : map.tilesets()) {
            final Element tilesetElement = document.createElement("tileset");
            tilesetElement.setAttribute("firstgid", Integer.toString(tileset.firstGid()));
            tilesetElement.setAttribute("source", tileset.source());
            root.appendChild(tilesetElement);
        }
        for (TiledTileLayer layer : map.tileLayers()) {
            final Element layerElement = document.createElement("layer");
            layerElement.setAttribute("id", Integer.toString(layer.id()));
            layerElement.setAttribute("name", layer.name());
            layerElement.setAttribute("width", Integer.toString(layer.width()));
            layerElement.setAttribute("height", Integer.toString(layer.height()));
            appendProperties(document, layerElement, layer.properties());
            final Element dataElement = document.createElement("data");
            dataElement.setAttribute("encoding", "csv");
            dataElement.setTextContent(csv(layer.gids(), layer.width()));
            layerElement.appendChild(dataElement);
            root.appendChild(layerElement);
        }
        for (TiledObjectLayer layer : map.objectLayers()) {
            root.appendChild(writeObjectLayer(document, layer, false));
        }
        writeDocument(path, document);
    }

    public TiledTilesetDocument readTileset(Path path) throws IOException {
        final Document document = parseDocument(path);
        final Element root = document.getDocumentElement();
        if (!"tileset".equals(root.getTagName())) {
            throw new IOException("TSX root element must be <tileset>.");
        }
        rejectTilesetUnsupportedChildren(root);
        final List<TiledTilesetTile> tiles = new ArrayList<>();
        for (Element tileElement : children(root, "tile")) {
            if (child(tileElement, "animation") != null) {
                throw new TiledUnsupportedFeatureException("Tiled animations are not supported in wave 1.");
            }
            final Element imageElement = child(tileElement, "image");
            if (imageElement == null) {
                throw new IOException("TSX tile must contain an <image> element.");
            }
            tiles.add(new TiledTilesetTile(
                    intAttribute(tileElement, "id", 0),
                    imageElement.getAttribute("source"),
                    intAttribute(imageElement, "width", 0),
                    intAttribute(imageElement, "height", 0),
                    readProperties(child(tileElement, "properties")),
                    readEmbeddedObjectLayer(tileElement)));
        }
        tiles.sort(Comparator.comparingInt(TiledTilesetTile::id));
        return new TiledTilesetDocument(
                root.getAttribute("version"),
                root.getAttribute("tiledversion"),
                root.getAttribute("name"),
                intAttribute(root, "tilewidth", 0),
                intAttribute(root, "tileheight", 0),
                intAttribute(root, "tilecount", tiles.size()),
                intAttribute(root, "columns", 0),
                readProperties(child(root, "properties")),
                tiles);
    }

    public void writeTileset(Path path, TiledTilesetDocument tileset) throws IOException {
        final Document document = newDocument();
        final Element root = document.createElement("tileset");
        document.appendChild(root);
        root.setAttribute("version", tileset.version());
        root.setAttribute("tiledversion", tileset.tiledVersion());
        root.setAttribute("name", tileset.name());
        root.setAttribute("tilewidth", Integer.toString(tileset.tileWidth()));
        root.setAttribute("tileheight", Integer.toString(tileset.tileHeight()));
        root.setAttribute("tilecount", Integer.toString(tileset.tileCount()));
        root.setAttribute("columns", Integer.toString(tileset.columns()));
        final Element grid = document.createElement("grid");
        grid.setAttribute("orientation", "orthogonal");
        grid.setAttribute("width", "1");
        grid.setAttribute("height", "1");
        root.appendChild(grid);
        appendProperties(document, root, tileset.properties());
        for (TiledTilesetTile tile : tileset.tiles()) {
            final Element tileElement = document.createElement("tile");
            tileElement.setAttribute("id", Integer.toString(tile.id()));
            appendProperties(document, tileElement, tile.properties());
            final Element imageElement = document.createElement("image");
            imageElement.setAttribute("source", tile.imageSource());
            imageElement.setAttribute("width", Integer.toString(tile.imageWidth()));
            imageElement.setAttribute("height", Integer.toString(tile.imageHeight()));
            tileElement.appendChild(imageElement);
            if (tile.collisionLayer() != null) {
                tileElement.appendChild(writeObjectLayer(document, tile.collisionLayer(), true));
            }
            root.appendChild(tileElement);
        }
        writeDocument(path, document);
    }

    private Document parseDocument(Path path) throws IOException {
        try {
            final var factory = DocumentBuilderFactory.newInstance();
            factory.setNamespaceAware(false);
            return factory.newDocumentBuilder().parse(path.toFile());
        } catch (TiledUnsupportedFeatureException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new IOException("Unable to parse Tiled XML: " + exception.getMessage(), exception);
        }
    }

    private Document newDocument() throws IOException {
        try {
            return DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
        } catch (Exception exception) {
            throw new IOException("Unable to create XML document: " + exception.getMessage(), exception);
        }
    }

    private void writeDocument(Path path, Document document) throws IOException {
        try {
            Files.createDirectories(Objects.requireNonNull(path, "path").toAbsolutePath().normalize().getParent());
            final var transformer = TransformerFactory.newInstance().newTransformer();
            transformer.setOutputProperty(OutputKeys.INDENT, "yes");
            transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
            transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
            transformer.transform(new DOMSource(document), new StreamResult(path.toFile()));
        } catch (Exception exception) {
            throw new IOException("Unable to write Tiled XML: " + exception.getMessage(), exception);
        }
    }

    private void rejectMapUnsupportedChildren(Element root) {
        for (Element child : childElements(root)) {
            final String tag = child.getTagName();
            if ("imagelayer".equals(tag) || "group".equals(tag) || "template".equals(tag)) {
                throw new TiledUnsupportedFeatureException("Unsupported TMX feature in wave 1: " + tag);
            }
        }
    }

    private void rejectTilesetUnsupportedChildren(Element root) {
        for (Element child : childElements(root)) {
            final String tag = child.getTagName();
            if ("wangsets".equals(tag) || "tileoffset".equals(tag) || "transformations".equals(tag)) {
                throw new TiledUnsupportedFeatureException("Unsupported TSX feature in wave 1: " + tag);
            }
        }
    }

    private List<TiledTilesetReference> readTilesets(Element root) {
        final List<TiledTilesetReference> tilesets = new ArrayList<>();
        for (Element tilesetElement : children(root, "tileset")) {
            if (!tilesetElement.hasAttribute("source")) {
                throw new TiledUnsupportedFeatureException("Inline tilesets are not supported in wave 1.");
            }
            tilesets.add(new TiledTilesetReference(
                    intAttribute(tilesetElement, "firstgid", 1),
                    tilesetElement.getAttribute("source")));
        }
        return List.copyOf(tilesets);
    }

    private List<TiledTileLayer> readTileLayers(Element root) {
        final List<TiledTileLayer> layers = new ArrayList<>();
        for (Element layerElement : children(root, "layer")) {
            final Element dataElement = child(layerElement, "data");
            if (dataElement == null || !"csv".equalsIgnoreCase(dataElement.getAttribute("encoding"))) {
                throw new TiledUnsupportedFeatureException("Wave 1 supports only CSV tile layer encoding.");
            }
            layers.add(new TiledTileLayer(
                    intAttribute(layerElement, "id", 0),
                    layerElement.getAttribute("name"),
                    intAttribute(layerElement, "width", 0),
                    intAttribute(layerElement, "height", 0),
                    parseCsvData(dataElement.getTextContent()),
                    readProperties(child(layerElement, "properties"))));
        }
        return List.copyOf(layers);
    }

    private List<TiledObjectLayer> readObjectLayers(Element root) {
        final List<TiledObjectLayer> layers = new ArrayList<>();
        for (Element objectGroup : children(root, "objectgroup")) {
            layers.add(readObjectLayer(objectGroup));
        }
        return List.copyOf(layers);
    }

    private TiledObjectLayer readEmbeddedObjectLayer(Element parent) {
        final Element objectGroup = child(parent, "objectgroup");
        return objectGroup == null ? null : readObjectLayer(objectGroup);
    }

    private TiledObjectLayer readObjectLayer(Element objectGroup) {
        final List<TiledObjectData> objects = new ArrayList<>();
        for (Element objectElement : children(objectGroup, "object")) {
            final Element polygon = child(objectElement, "polygon");
            if (child(objectElement, "ellipse") != null || child(objectElement, "point") != null || child(objectElement, "polyline") != null) {
                throw new TiledUnsupportedFeatureException("Unsupported object-layer geometry in wave 1.");
            }
            objects.add(new TiledObjectData(
                    intAttribute(objectElement, "id", 0),
                    objectElement.getAttribute("name"),
                    doubleAttribute(objectElement, "x", 0.0d),
                    doubleAttribute(objectElement, "y", 0.0d),
                    doubleAttribute(objectElement, "width", 0.0d),
                    doubleAttribute(objectElement, "height", 0.0d),
                    polygon == null ? List.of() : parsePolygon(polygon.getAttribute("points")),
                    readProperties(child(objectElement, "properties"))));
        }
        return new TiledObjectLayer(
                intAttribute(objectGroup, "id", 0),
                objectGroup.getAttribute("name"),
                objects,
                readProperties(child(objectGroup, "properties")));
    }

    private Element writeObjectLayer(Document document, TiledObjectLayer layer, boolean embedded) {
        final Element objectGroup = document.createElement("objectgroup");
        if (layer.id() > 0) {
            objectGroup.setAttribute("id", Integer.toString(layer.id()));
        }
        if (!layer.name().isBlank()) {
            objectGroup.setAttribute("name", layer.name());
        }
        if (embedded) {
            objectGroup.setAttribute("draworder", "index");
        }
        appendProperties(document, objectGroup, layer.properties());
        for (TiledObjectData object : layer.objects()) {
            final Element objectElement = document.createElement("object");
            if (object.id() > 0) {
                objectElement.setAttribute("id", Integer.toString(object.id()));
            }
            if (!object.name().isBlank()) {
                objectElement.setAttribute("name", object.name());
            }
            objectElement.setAttribute("x", formatDecimal(object.x()));
            objectElement.setAttribute("y", formatDecimal(object.y()));
            if (object.width() > 0.0d) {
                objectElement.setAttribute("width", formatDecimal(object.width()));
            }
            if (object.height() > 0.0d) {
                objectElement.setAttribute("height", formatDecimal(object.height()));
            }
            appendProperties(document, objectElement, object.properties());
            if (!object.polygonPoints().isEmpty()) {
                final Element polygon = document.createElement("polygon");
                polygon.setAttribute("points", polygonPoints(object.polygonPoints()));
                objectElement.appendChild(polygon);
            }
            objectGroup.appendChild(objectElement);
        }
        return objectGroup;
    }

    private void appendProperties(Document document, Element parent, List<TiledProperty> properties) {
        if (properties.isEmpty()) {
            return;
        }
        final Element propertiesElement = document.createElement("properties");
        for (TiledProperty property : properties) {
            final Element propertyElement = document.createElement("property");
            propertyElement.setAttribute("name", property.name());
            if (!"string".equals(property.type())) {
                propertyElement.setAttribute("type", property.type());
            }
            propertyElement.setAttribute("value", property.value());
            propertiesElement.appendChild(propertyElement);
        }
        parent.appendChild(propertiesElement);
    }

    private List<TiledProperty> readProperties(Element propertiesElement) {
        if (propertiesElement == null) {
            return List.of();
        }
        final List<TiledProperty> properties = new ArrayList<>();
        for (Element propertyElement : children(propertiesElement, "property")) {
            properties.add(new TiledProperty(
                    propertyElement.getAttribute("name"),
                    propertyElement.getAttribute("type"),
                    propertyElement.hasAttribute("value")
                            ? propertyElement.getAttribute("value")
                            : propertyElement.getTextContent()));
        }
        return List.copyOf(properties);
    }

    private List<Long> parseCsvData(String text) {
        final List<Long> gids = new ArrayList<>();
        for (String token : Objects.requireNonNullElse(text, "").split(",")) {
            final String normalized = token.trim();
            if (normalized.isBlank()) {
                continue;
            }
            gids.add(Long.parseLong(normalized));
        }
        return List.copyOf(gids);
    }

    private List<TiledPoint> parsePolygon(String points) {
        final List<TiledPoint> parsed = new ArrayList<>();
        for (String segment : Objects.requireNonNullElse(points, "").trim().split(" ")) {
            final String normalized = segment.trim();
            if (normalized.isBlank()) {
                continue;
            }
            final String[] pair = normalized.split(",");
            if (pair.length != 2) {
                throw new TiledUnsupportedFeatureException("Invalid polygon point format in Tiled XML.");
            }
            parsed.add(new TiledPoint(Double.parseDouble(pair[0]), Double.parseDouble(pair[1])));
        }
        return List.copyOf(parsed);
    }

    private String csv(List<Long> gids, int width) {
        final StringBuilder builder = new StringBuilder();
        for (int index = 0; index < gids.size(); index += 1) {
            if (index > 0) {
                builder.append(index % Math.max(width, 1) == 0 ? ",\n" : ",");
            }
            builder.append(Long.toUnsignedString(gids.get(index)));
        }
        return builder.toString();
    }

    private String polygonPoints(List<TiledPoint> points) {
        final StringBuilder builder = new StringBuilder();
        for (int index = 0; index < points.size(); index += 1) {
            if (index > 0) {
                builder.append(' ');
            }
            builder.append(formatDecimal(points.get(index).x()))
                    .append(',')
                    .append(formatDecimal(points.get(index).y()));
        }
        return builder.toString();
    }

    private int intAttribute(Element element, String attribute, int fallback) {
        final String value = element.getAttribute(attribute);
        if (value == null || value.isBlank()) {
            return fallback;
        }
        return Integer.parseInt(value.trim());
    }

    private double doubleAttribute(Element element, String attribute, double fallback) {
        final String value = element.getAttribute(attribute);
        if (value == null || value.isBlank()) {
            return fallback;
        }
        return Double.parseDouble(value.trim());
    }

    private String formatDecimal(double value) {
        if (Math.rint(value) == value) {
            return Long.toString(Math.round(value));
        }
        return String.format(Locale.ROOT, "%.6f", value)
                .replaceAll("0+$", "")
                .replaceAll("\\.$", "");
    }

    private Element child(Element parent, String name) {
        for (Element child : childElements(parent)) {
            if (name.equals(child.getTagName())) {
                return child;
            }
        }
        return null;
    }

    private List<Element> children(Element parent, String name) {
        final List<Element> elements = new ArrayList<>();
        for (Element child : childElements(parent)) {
            if (name.equals(child.getTagName())) {
                elements.add(child);
            }
        }
        return List.copyOf(elements);
    }

    private List<Element> childElements(Element parent) {
        final NodeList children = parent.getChildNodes();
        final List<Element> elements = new ArrayList<>();
        for (int index = 0; index < children.getLength(); index += 1) {
            final Node node = children.item(index);
            if (node instanceof Element element) {
                elements.add(element);
            }
        }
        return List.copyOf(elements);
    }
}
