/*
 * Copyright (c) 2024, 2025, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */
package jdk.jpackage.internal;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.xml.XMLConstants;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stax.StAXResult;
import javax.xml.transform.stream.StreamSource;
import jdk.jpackage.internal.WixToolset.WixToolsetType;
import jdk.jpackage.internal.resources.ResourceLocator;
import jdk.jpackage.internal.util.XmlUtils;
import org.w3c.dom.Document;
import org.xml.sax.SAXException;

/**
 * Converts WiX v3 source file into WiX v4 format.
 */
final class WixSourceConverter {

    enum Status {
        SavedAsIs,
        SavedAsIsMalfromedXml,
        Transformed,
    }

    WixSourceConverter(Path resourceDir) throws IOException {
        var buf = new ByteArrayOutputStream();

        new OverridableResource("wix3-to-wix4-conv.xsl", ResourceLocator.class)
                .setPublicName("wix-conv.xsl")
                .setResourceDir(resourceDir)
                .setCategory(I18N.getString("resource.wix-src-conv"))
                .saveToStream(buf);

        var xslt = new StreamSource(new ByteArrayInputStream(buf.toByteArray()));

        var tf = TransformerFactory.newInstance();
        try {
            this.transformer = tf.newTransformer(xslt);
        } catch (TransformerException ex) {
            // Should never happen
            throw new RuntimeException(ex);
        }

        this.outputFactory = XMLOutputFactory.newInstance();
    }

    Status appyTo(OverridableResource resource, Path resourceSaveAsFile) throws IOException {
        // Save the resource into DOM tree and read xml namespaces from it.
        // If some namespaces are not recognized by this converter, save the resource as is.
        // If all detected namespaces are recognized, run transformation of the DOM tree and save
        // output into destination file.

        var buf = saveResourceInMemory(resource);

        Document inputXmlDom;
        try {
            inputXmlDom = XmlUtils.initDocumentBuilder().parse(new ByteArrayInputStream(buf));
        } catch (SAXException ex) {
            // Malformed XML, don't run converter, save as is.
            resource.saveToFile(resourceSaveAsFile);
            return Status.SavedAsIsMalfromedXml;
        }

        try {
            var nc = new NamespaceCollector();
            TransformerFactory.newInstance().newTransformer().
                    transform(new DOMSource(inputXmlDom), new StAXResult((XMLStreamWriter) Proxy.
                            newProxyInstance(XMLStreamWriter.class.getClassLoader(),
                                    new Class<?>[]{XMLStreamWriter.class}, nc)));
            if (!nc.isOnlyKnownNamespacesUsed()) {
                // Unsupported namespaces detected in input XML, don't run converter, save as is.
                resource.saveToFile(resourceSaveAsFile);
                return Status.SavedAsIs;
            }
        } catch (TransformerException ex) {
            // Should never happen
            throw new RuntimeException(ex);
        }

        Supplier<Source> inputXml = () -> {
            // Should be "new DOMSource(inputXmlDom)", but no transfromation is applied in this case!
            return new StreamSource(new ByteArrayInputStream(buf));
        };

        var nc = new NamespaceCollector();
        try {
            // Run transfomation to collect namespaces from the output XML.
            transformer.transform(inputXml.get(), new StAXResult((XMLStreamWriter) Proxy.
                    newProxyInstance(XMLStreamWriter.class.getClassLoader(),
                            new Class<?>[]{XMLStreamWriter.class}, nc)));
        } catch (TransformerException ex) {
            // Should never happen
            throw new RuntimeException(ex);
        }

        try (var outXml = new ByteArrayOutputStream()) {
            transformer.transform(inputXml.get(), new StAXResult((XMLStreamWriter) Proxy.
                    newProxyInstance(XMLStreamWriter.class.getClassLoader(),
                            new Class<?>[]{XMLStreamWriter.class}, new NamespaceCleaner(nc.
                                    getPrefixToUri(), outputFactory.createXMLStreamWriter(outXml)))));
            Files.createDirectories(IOUtils.getParent(resourceSaveAsFile));
            Files.copy(new ByteArrayInputStream(outXml.toByteArray()), resourceSaveAsFile,
                    StandardCopyOption.REPLACE_EXISTING);
        } catch (TransformerException | XMLStreamException ex) {
            // Should never happen
            throw new RuntimeException(ex);
        }

        return Status.Transformed;
    }

    private static byte[] saveResourceInMemory(OverridableResource resource) throws IOException {
        var buf = new ByteArrayOutputStream();
        resource.saveToStream(buf);
        return buf.toByteArray();
    }

    static final class ResourceGroup {

        ResourceGroup(WixToolsetType wixToolsetType) {
            this.wixToolsetType = wixToolsetType;
        }

        void addResource(OverridableResource resource, Path resourceSaveAsFile) {
            resources.put(resourceSaveAsFile, resource);
        }

        void saveResources() throws IOException {
            switch (wixToolsetType) {
                case Wix3 -> {
                    for (var e : resources.entrySet()) {
                        e.getValue().saveToFile(e.getKey());
                    }
                }
                case Wix4 -> {
                    var resourceDir = resources.values().stream().filter(res -> {
                        return null != res.getResourceDir();
                    }).findAny().map(OverridableResource::getResourceDir).orElse(null);
                    var conv = new WixSourceConverter(resourceDir);
                    for (var e : resources.entrySet()) {
                        conv.appyTo(e.getValue(), e.getKey());
                    }
                }
                default -> {
                    throw new IllegalArgumentException();
                }
            }
        }

        private final Map<Path, OverridableResource> resources = new HashMap<>();
        private final WixToolsetType wixToolsetType;
    }

    //
    //    Default JDK XSLT v1.0 processor is not handling well default namespace mappings.
    //    Running generic template:
    //
    //    <xsl:template match="wix3loc:*">
    //      <xsl:element name="{local-name()}" namespace="http://wixtoolset.org/schemas/v4/wxl">
    //        <xsl:apply-templates select="@*|node()"/>
    //      </xsl:element>
    //    </xsl:template>
    //
    //    produces:
    //
    //    <ns0:WixLocalization xmlns:ns0="http://wixtoolset.org/schemas/v4/wxl" Culture="en-us" Codepage="1252">
    //      <ns1:String xmlns:ns1="http://wixtoolset.org/schemas/v4/wxl" Value="The folder [INSTALLDIR] already exist. Would you like to install to that folder anyway?" Id="message.install.dir.exist"/>
    //      <ns2:String xmlns:ns2="http://wixtoolset.org/schemas/v4/wxl" Value="Main Feature" Id="MainFeatureTitle"/>
    //      ...
    //      <ns12:String xmlns:ns12="http://wixtoolset.org/schemas/v4/wxl" Value="Open with [ProductName]" Id="ContextMenuCommandLabel"/>
    //    </ns0:WixLocalization>
    //
    //    which is conformant XML but WiX4 doesn't like it:
    //
    //    wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns1'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns1 attribute using the correct XML namespace?
    //    wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns2'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns2 attribute using the correct XML namespace?
    //    wix.exe : error WIX0202: The {http://wixtoolset.org/schemas/v4/wxl}String element contains an unsupported extension attribute '{http://www.w3.org/2000/xmlns/}ns3'. The {http://wixtoolset.org/schemas/v4/wxl}String element does not currently support extension attributes. Is the {http://www.w3.org/2000/xmlns/}ns3 attribute using the correct XML namespace?
    //
    //    Someone hit this issue long ago - https://stackoverflow.com/questions/26904623/replace-default-namespace-using-xsl and they suggested to use different XSLT processor.
    //    Two online XSLT processors used in testing produce clean XML with this template indeed:
    //
    //    <WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Codepage="1252" Culture="en-us">
    //      <String Value="The folder [INSTALLDIR] already exist. Would you like to install to that folder anyway?" Id="message.install.dir.exist"/>
    //      <String Value="Main Feature" Id="MainFeatureTitle"/>
    //      ...
    //      <String Value="Open with [ProductName]" Id="ContextMenuCommandLabel"/>
    //    </WixLocalization>
    //
    //    To workaround default JDK's XSLT processor limitations we do additionl postprocessing of output XML with NamespaceCleaner class.
    //
    private static class NamespaceCleaner implements InvocationHandler {

        NamespaceCleaner(Map<String, String> prefixToUri, XMLStreamWriter target) {
            this.uriToPrefix = prefixToUri.entrySet().stream().collect(Collectors.toMap(
                    Map.Entry::getValue, e -> {
                        return new Prefix(e.getKey());
                    }, (x, y) -> x));
            this.prefixToUri = prefixToUri;
            this.target = target;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            switch (method.getName()) {
                case "writeNamespace" -> {
                    final String uri = (String) args[1];
                    var prefixObj = uriToPrefix.get(uri);
                    if (!prefixObj.written) {
                        prefixObj.written = true;
                        target.writeNamespace(prefixObj.name, uri);
                    }
                    return null;
                }
                case "writeStartElement", "writeEmptyElement" -> {
                    final String name;
                    switch (args.length) {
                        case 1 ->
                            name = (String) args[0];
                        case 2, 3 ->
                            name = (String) args[1];
                        default ->
                            throw new IllegalArgumentException();
                    }

                    final String prefix;
                    final String localName;
                    final String[] tokens = name.split(":", 2);
                    if (tokens.length == 2) {
                        prefix = tokens[0];
                        localName = tokens[1];
                    } else {
                        localName = name;
                        switch (args.length) {
                            case 3 ->
                                prefix = (String) args[0];
                            case 2 ->
                                prefix = uriToPrefix.get((String) args[0]).name;
                            default ->
                                prefix = null;
                        }
                    }

                    if (prefix != null && !XMLConstants.DEFAULT_NS_PREFIX.equals(prefix)) {
                        final String uri = prefixToUri.get(prefix);
                        var prefixObj = uriToPrefix.get(uri);
                        if (prefixObj.written) {
                            var writeName = String.join(":", prefixObj.name, localName);
                            if ("writeStartElement".equals(method.getName())) {
                                target.writeStartElement(writeName);
                            } else {
                                target.writeEmptyElement(writeName);
                            }
                            return null;
                        } else {
                            prefixObj.written = (args.length > 1);
                            args = Arrays.copyOf(args, args.length, Object[].class);
                            if (localName.equals(name)) {
                                // No prefix in the name
                                if (args.length == 3) {
                                    args[0] = prefixObj.name;
                                }
                            } else {
                                var writeName = String.join(":", prefixObj.name, localName);
                                switch (args.length) {
                                    case 1 ->
                                        args[0] = writeName;
                                    case 2 -> {
                                        args[0] = uri;
                                        args[1] = writeName;
                                    }
                                    case 3 -> {
                                        args[0] = prefixObj.name;
                                        args[1] = writeName;
                                        args[2] = uri;
                                    }
                                }
                            }
                        }
                    }
                }
            }

            return method.invoke(target, args);
        }

        static class Prefix {

            Prefix(String name) {
                this.name = name;
            }

            private final String name;
            private boolean written;
        }

        private final Map<String, Prefix> uriToPrefix;
        private final Map<String, String> prefixToUri;
        private final XMLStreamWriter target;
    }

    private static class NamespaceCollector implements InvocationHandler {

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            switch (method.getName()) {
                case "setPrefix", "writeNamespace" -> {
                    var prefix = (String) args[0];
                    var namespace = prefixToUri.computeIfAbsent(prefix, k -> createValue(args[1]));
                    if (XMLConstants.XMLNS_ATTRIBUTE.equals(prefix)) {
                        namespace.setValue(true);
                    }
                }
                case "writeStartElement", "writeEmptyElement" -> {
                    switch (args.length) {
                        case 3 ->
                            prefixToUri.computeIfAbsent((String) args[0], k -> createValue(
                                    (String) args[2])).setValue(true);
                        case 2 ->
                            initFromElementName((String) args[1], (String) args[0]);
                        case 1 ->
                            initFromElementName((String) args[0], null);
                    }
                }
            }
            return null;
        }

        boolean isOnlyKnownNamespacesUsed() {
            return prefixToUri.values().stream().filter(namespace -> {
                return namespace.getValue();
            }).allMatch(namespace -> {
                if (!namespace.getValue()) {
                    return true;
                } else {
                    return KNOWN_NAMESPACES.contains(namespace.getKey());
                }
            });
        }

        Map<String, String> getPrefixToUri() {
            return prefixToUri.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
                    e -> {
                        return e.getValue().getKey();
                    }));
        }

        private void initFromElementName(String name, String namespace) {
            final String[] tokens = name.split(":", 2);
            if (tokens.length == 2) {
                if (namespace != null) {
                    prefixToUri.computeIfAbsent(tokens[0], k -> createValue(namespace)).setValue(
                            true);
                } else {
                    prefixToUri.computeIfPresent(tokens[0], (k, v) -> {
                        v.setValue(true);
                        return v;
                    });
                }
            }
        }

        private Map.Entry<String, Boolean> createValue(Object prefix) {
            return new AbstractMap.SimpleEntry<String, Boolean>((String) prefix, false);
        }

        private final Map<String, Map.Entry<String, Boolean>> prefixToUri = new HashMap<>();
    }

    private final Transformer transformer;
    private final XMLOutputFactory outputFactory;

    // The list of WiX v3 namespaces this converter can handle
    private static final Set<String> KNOWN_NAMESPACES = Set.of(
            "http://schemas.microsoft.com/wix/2006/localization",
            "http://schemas.microsoft.com/wix/2006/wi");
}
