/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

package org.netbeans.nbbuild;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.StringTokenizer;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import javax.xml.parsers.SAXParserFactory;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.Resource;
import org.apache.tools.ant.types.ResourceCollection;
import org.apache.tools.ant.types.resources.ZipResource;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * Task to scan all XML layers in a NB installation
 * and report on which modules registers which files.
 * @author Jesse Glick
 */
public class LayerIndex extends Task {

    public LayerIndex() {}

    List<FileSet> filesets = new ArrayList<>();
    public void addConfiguredModules(FileSet fs) {
        filesets.add(fs);
    }

    private File output;
    public void setOutput(File f) {
        output = f;
    }

    private File serviceOutput;
    public void setServiceOutput(File f) {
        serviceOutput = f;
    }

    private String resourceId;
    private List<Resource> resources;
    /** If this parameter is provided, then this tasks creates a resource
     * composed from all the layerfiles and makes it accessible under this refId
     * @param id the refId to associate the collection with
     */
    public void setResourceId(String id) {
        resourceId = id;
        resources = new ZipArray();
    }

    @Override
    public void execute() throws BuildException {
        if (filesets.isEmpty()) {
            throw new BuildException();
        }
        SortedMap<String,String> files = new TreeMap<>(); // layer path -> cnb
        SortedMap<String,SortedMap<String,String>> labels = new TreeMap<>(); // layer path -> cnb -> label
        final Map<String,Integer> positions = new TreeMap<>(); // layer path -> position
        SortedMap<String,SortedMap<String,Set<String>>> serviceImpls = new TreeMap<>(); // path -> interface -> [impl]
        Map<String,Integer> servicePositions = new HashMap<>(); // impl -> position
        for (FileSet fs : filesets) {
            DirectoryScanner ds = fs.getDirectoryScanner(getProject());
            File basedir = ds.getBasedir();
            for (String path : ds.getIncludedFiles()) {
                File jar = new File(basedir, path);
                try {
                    try (JarFile jf = new JarFile(jar)) {
                        Manifest mf = jf.getManifest();
                        if (mf == null) {
                            continue;
                        }
                        String modname = JarWithModuleAttributes.extractCodeName(mf.getMainAttributes());
                        if (modname == null) {
                            continue;
                        }
                        // XXX services.txt has e.g. "SERVICE org.openide.filesystems.MIMEResolver\n PROVIDER org.netbeans.modules.java.hints.test.Utilities$JavaMimeResolver"
                        // which is misleading since this pseudomodule is used only in unit tests
                        // maybe define Normally-Disabled: true in manifest.mf and skip from here (and disabledAutoloads)?
                        String cnb = modname.replaceFirst("/\\d+$", "");
                        String layer = mf.getMainAttributes().getValue("OpenIDE-Module-Layer");
                        if (layer != null) {
                            if (resources != null) {
                                ZipResource res = new LayerResource(jar, layer, layer.replaceFirst("/[^/]+$", "").replace('/', '.') + ".xml");
                                resources.add(res);
                            } else {
                                parse(jf.getInputStream(jf.getEntry(layer)), files, labels, positions, cnb, jf);
                            }
                        }
                        ZipEntry generatedLayer = jf.getEntry("META-INF/generated-layer.xml");
                        if (generatedLayer != null) {
                            if (resources != null) {
                                ZipResource res = new LayerResource(jar, generatedLayer.getName(), cnb + "-generated.xml");
                                resources.add(res);
                            } else {
                                parse(jf.getInputStream(generatedLayer), files, labels, positions, cnb + "@", jf);
                            }
                        }
                        if (serviceOutput != null) {
                            // Could remember CNBs too.
                            parseServices(jf, serviceImpls, servicePositions);
                        }
                    }
                } catch (Exception x) {
                    throw new BuildException("Reading " + jar + ": " + x, x, getLocation());
                }
            }
        }

        if (resources != null) {
            assignReferences();
            return;
        }

        try {
            writeLayerIndex(files, positions, labels);
            if (serviceOutput != null) {
                writeServiceIndex(serviceImpls, servicePositions);
            }
        } catch (IOException x) {
            throw new BuildException(x, getLocation());
        }
    }

    @SuppressWarnings("unchecked")
    private void assignReferences() {
        getProject().getReferences().put(resourceId, resources);
    }

    static String shortenCNB(String cnb) {
        if (cnb != null) {
            return cnb.replaceFirst("^org\\.netbeans\\.", "o.n.").replaceFirst("^org\\.openide\\.", "o.o.").replaceFirst("\\.modules\\.", ".m.");
        } else {
            return "";
        }
    }

    private String shortenPath(String path) {
        return path.replaceAll("(^|/)org-netbeans-", "$1o-n-").replaceAll("(^|/)org-openide-", "$1o-o-").replaceAll("-modules-", "-m-")
                .replaceAll("(^|/)org\\.netbeans\\.", "$1o.n.").replaceAll("(^|/)org\\.openide\\.", "$1o.o.").replaceAll("\\.modules\\.", ".m.");
    }

    private void parse(InputStream is, final Map<String,String> files, final SortedMap<String,SortedMap<String,String>> labels,
            final Map<String,Integer> positions, final String cnb, final JarFile jf) throws Exception {
        SAXParserFactory f = SAXParserFactory.newInstance();
        f.setValidating(false);
        f.setNamespaceAware(false);
        f.newSAXParser().parse(is, new DefaultHandler() {
            String prefix = "";
            void register(String path) {
                if (!files.containsKey(path)) {
                    files.put(path, cnb);
                } else if (!cnb.equals(files.get(path))) {
                    // Possibly >1 owner, but consider layer.xml vs. generated-layer.xml.
                    if (cnb.equals(files.get(path) + "@")) {
                        // leave alone
                    } else if ((cnb + "@").equals(files.get(path))) { // mark as defined in layer.xml
                        files.put(path, cnb);
                    } else { // different modules
                        files.put(path, null);
                    }
                }
            }
            @Override
            public void startElement(String uri, String localName, String qName,  Attributes attributes) throws SAXException {
                if (qName.equals("folder")) {
                    String n = attributes.getValue("name");
                    prefix += n + "/";
                    register(prefix);
                } else if (qName.equals("file")) {
                    String n = attributes.getValue("name");
                    prefix += n;
                    register(prefix);
                } else if (qName.equals("attr") && attributes.getValue("name").equals("SystemFileSystem.localizingBundle")) {
                    String bundle = attributes.getValue("stringvalue");
                    if (bundle != null) {
                        loadDisplayName(bundle,prefix.replaceAll("/$", ""));
                    } else {
                        log("No stringvalue for SystemFileSystem.localizingBundle on " + prefix + " in " + cnb, Project.MSG_WARN);
                    }
                } else if (qName.equals("attr") && attributes.getValue("name").equals("displayName")) {
                    String bundleKey = attributes.getValue("bundlevalue");
                    if (bundleKey != null) {
                        String[] bundlevalue = bundleKey.split("#", 2);
                        loadDisplayName(bundlevalue[0], bundlevalue[1]);
                    } else {
                        String literal = attributes.getValue("stringvalue");
                        if (literal != null) {
                            loadDisplayName(literal);
                        }
                    }
                } else if (qName.equals("attr") && attributes.getValue("name").equals("position")) {
                    String intvalue = attributes.getValue("intvalue");
                    if (intvalue != null && /* #107550 */ !intvalue.equals("0")) {
                        try {
                            positions.put(prefix, Integer.parseInt(intvalue));
                        } catch (NumberFormatException x) {
                            throw new SAXException(x);
                        }
                    }
                }
            }
            private void loadDisplayName(String bundle, String key) throws SAXException {
                Properties props = new Properties();
                try {
                    ZipEntry entry = jf.getEntry(bundle.replace('.', '/') + ".properties");
                    if (entry == null) {
                        /* Should be covered by ValidateLayerConsistencyTest.testLocalizingBundles:
                        log(bundle + " not found in reference from " + prefix + " in " + cnb, Project.MSG_WARN);
                         */
                        return;
                    }
                    props.load(jf.getInputStream(entry));
                } catch (IOException x) {
                    throw new SAXException(x);
                }
                String label = props.getProperty(key);
                if (label == null) {
                    /* Should be covered by ValidateLayerConsistencyTest.testLocalizingBundles:
                    log("Key " + key + " not found in " + bundle + " from " + cnb, Project.MSG_WARN);
                     */
                    return;
                }
                loadDisplayName(label);
            }
            private void loadDisplayName(String label) {
                SortedMap<String,String> cnb2label = labels.get(prefix);
                if (cnb2label == null) {
                    cnb2label = new TreeMap<>();
                    labels.put(prefix, cnb2label);
                }
                cnb2label.put(cnb, label);
            }
            @Override
            public void endElement(String uri, String localName, String qName) throws SAXException {
                if (qName.equals("folder")) {
                    prefix = prefix.replaceFirst("[^/]+/$", "");
                } else if (qName.equals("file")) {
                    prefix = prefix.replaceFirst("[^/]+$", "");
                }
            }
            @Override
            public InputSource resolveEntity(String pub, String sys) throws IOException, SAXException {
                return new InputSource(new StringReader(""));
            }
        });
    }

    private void parseServices(JarFile jf, SortedMap<String,SortedMap<String,Set<String>>> serviceImplsByPath,
            Map<String,Integer> servicePositions) throws IOException {
        Enumeration<JarEntry> entries = jf.entries();
        while (entries.hasMoreElements()) {
            JarEntry entry = entries.nextElement();
            if (entry.isDirectory()) {
                continue;
            }
            String name = entry.getName();
            String path, xface;
            if (name.startsWith("META-INF/services/")) {
                path = "";
                xface = name.substring("META-INF/services/".length());
            } else if (name.startsWith("META-INF/namedservices/")) {
                String rest = name.substring("META-INF/namedservices/".length());
                int x = rest.lastIndexOf('/');
                path = rest.substring(0, x);
                xface = rest.substring(x + 1);
            } else {
                continue;
            }
            try (InputStream is = jf.getInputStream(entry)) {
                BufferedReader r = new BufferedReader(new InputStreamReader(is, "UTF-8"));
                String lastImpl = null;
                String line;
                while ((line = r.readLine()) != null) {
                    if (line.startsWith("#position=") && lastImpl != null) {
                        servicePositions.put(lastImpl, Integer.parseInt(line.substring("#position=".length())));
                    } else if (line.startsWith("#-") || (line.length() > 0 && !line.startsWith("#"))) {
                        lastImpl = line;
                        SortedMap<String,Set<String>> serviceImpls = serviceImplsByPath.get(path);
                        if (serviceImpls == null) {
                            serviceImpls = new TreeMap<>();
                            serviceImplsByPath.put(path, serviceImpls);
                        }
                        Set<String> impls = serviceImpls.get(xface);
                        if (impls == null) {
                            impls = new HashSet<>();
                            serviceImpls.put(xface, impls);
                        }
                        impls.add(lastImpl);
                    }
                }
            }
        }
    }

    private static final class ZipArray extends ArrayList<Resource>
    implements ResourceCollection {
        public boolean isFilesystemOnly() {
            return false;
        }

        @Override
        public Stream<Resource> stream() {
            return super.stream();
        }
    }

    private static final class LayerResource extends ZipResource {
        private final String name;
        
        private LayerResource(
            File jar, String path, String name) throws ZipException {
            super(jar, "UTF-8", new org.apache.tools.zip.ZipEntry(path));
            this.name = name;
        }

        @Override
        public String getName() {
            return name;
        }

        @Override
        public InputStream getInputStream() throws IOException {
            final ZipFile z = new ZipFile(getZipfile(), ZipFile.OPEN_READ);
            ZipEntry ze = z.getEntry(super.getName());
            if (ze == null) {
                z.close();
                throw new BuildException("no entry " + getName() + " in "
                                         + getArchive());
            }
            return z.getInputStream(ze);
        }

    }

    private void writeLayerIndex(SortedMap<String,String> files, final Map<String,Integer> positions,
            SortedMap<String,SortedMap<String,String>> labels) throws IOException {
        int maxlength = 0;
        for (String cnb : files.values()) {
            maxlength = Math.max(maxlength, shortenCNB(cnb).length());
        }
        PrintWriter pw = output != null ? new PrintWriter(output, "UTF-8") : null;
        Map<String,String> virtualEntries = computeMIMELookupEntries(files.keySet());
        updateMap(files, virtualEntries);
        updateMap(positions, virtualEntries);
        updateMap(labels, virtualEntries);
        SortedSet<String> layerPaths = new TreeSet<>(new LayerPathComparator(positions));
        layerPaths.addAll(files.keySet());
        SortedSet<String> remaining = new TreeSet<>(files.keySet());
        remaining.removeAll(layerPaths);
        assert remaining.isEmpty() : remaining;
        for (String path : layerPaths) {
            String cnb = files.get(path);
            String line = String.format("%-" + maxlength + "s %s", shortenCNB(cnb), shortenPath(path));
            if (virtualEntries.containsKey(path)) {
                line += " (merged)";
            }
            Integer pos = positions.get(path);
            if (pos != null) {
                line += String.format(" @%d", pos);
            }
            SortedMap<String,String> cnb2Label = labels.get(path);
            if (cnb2Label != null) {
                if (cnb2Label.size() == 1 && cnb2Label.keySet().iterator().next().equals(cnb)) {
                    line += String.format(" (\"%s\")", cnb2Label.values().iterator().next());
                } else {
                    for (Map.Entry<String,String> labelEntry : cnb2Label.entrySet()) {
                        line += String.format(" (%s: \"%s\")", shortenCNB(labelEntry.getKey()), labelEntry.getValue());
                    }
                }
            }
            if (pw != null) {
                pw.println(line);
            } else {
                log(line);
            }
        }
        if (pw != null) {
            pw.close();
        }
        if (output != null) {
            log(output + ": layer index written");
        }
    }

    /**
     * Map from virtual file paths to original literal file path.
     * E.g. Editors/text/html/Popup/foo.instance -> Editors/Popup/foo.instance
     * See ValidateLayerConsistencyTest.testFolderOrdering for comparison.
     */
    private Map<String,String> computeMIMELookupEntries(Set<String> files) {
        Pattern editorFolderPattern = Pattern.compile("Editors/(application|text)/([^/]+)(.*/)");
        Map<String,String> result = new HashMap<>();
        for (String editorFolder : files) {
            Matcher m = editorFolderPattern.matcher(editorFolder);
            if (!m.matches()) {
                continue;
            }
            // $0="Editors/text/html/Popup/" $1="text" $2="html" $3="/Popup/"
            List<String> prefixen = new ArrayList<>(2);
            prefixen.add("Editors" + m.group(3)); // "Editors/Popup/"
            if (m.group(2).endsWith("+xml")) { // Editors/text/x-ant+xml/Popup/
                prefixen.add("Editors/" + m.group(1) + "/xml" + m.group(3)); // Editors/text/xml/Popup/
            }
            for (String prefix : prefixen) {
                for (String file : files) {
                    if (file.startsWith(prefix)) { // "Editors/Popup/foo.instance"
                        String basename = file.substring(prefix.length());
                        if (basename.contains("/")) {
                            // Would technically be correct to show, but usually irrelevant.
                            continue;
                        }
                        String virtual = editorFolder + basename; // Editors/text/html/Popup/foo.instance
                        if (!files.contains(virtual)) {
                            result.put(virtual, file);
                        }
                    }
                }
            }
        }
        return result;
    }

    private <T> void updateMap(Map<String,T> map, Map<String,String> virtualEntries) {
        for (Map.Entry<String,String> entry : virtualEntries.entrySet()) {
            String orig = entry.getValue();
            if (map.containsKey(orig)) {
                map.put(entry.getKey(), map.get(orig));
            }
        }
    }

    private static class LayerPathComparator implements Comparator<String> {
        private final Map<String,Integer> positions;
        public LayerPathComparator(Map<String,Integer> positions) {
            this.positions = positions;
        }
        public int compare(String p1, String p2) {
            StringTokenizer tok1 = new StringTokenizer(p1, "/");
            StringTokenizer tok2 = new StringTokenizer(p2, "/");
            String prefix = "";
            while (tok1.hasMoreTokens()) {
                String piece1 = tok1.nextToken();
                if (tok2.hasMoreTokens()) {
                    String piece2 = tok2.nextToken();
                    if (piece1.equals(piece2)) {
                        prefix += piece1 + "/";
                    } else {
                        Integer pos1 = pos(prefix + piece1);
                        Integer pos2 = pos(prefix + piece2);
                        if (pos1 == null) {
                            if (pos2 == null) {
                                return piece1.compareTo(piece2);
                            } else {
                                return 1;
                            }
                        } else {
                            if (pos2 == null) {
                                return -1;
                            } else {
                                int diff = pos1 - pos2;
                                if (diff != 0) {
                                    return diff;
                                } else {
                                    return piece1.compareTo(piece2);
                                }
                            }
                        }
                    }
                } else {
                    return 1;
                }
            }
            if (tok2.hasMoreTokens()) {
                return -1;
            }
            assert p1.equals(p2) : p1 + " vs. " + p2;
            return 0;
        }
        Integer pos(String path) {
            return positions.containsKey(path) ? positions.get(path) : positions.get(path + "/");
        }
    }

    private void writeServiceIndex(SortedMap<String,SortedMap<String,Set<String>>> serviceImpls,
            final Map<String,Integer> servicePositions) throws IOException {
        try (PrintWriter pw = new PrintWriter(serviceOutput, "UTF-8")) {
            for (Map.Entry<String,SortedMap<String,Set<String>>> mainEntry : serviceImpls.entrySet()) {
                String path = mainEntry.getKey();
                for (Map.Entry<String,Set<String>> entry : mainEntry.getValue().entrySet()) {
                    pw.print("SERVICE " + entry.getKey());
                    if (path.length() > 0) {
                        pw.println(" under " + path);
                    } else {
                        pw.println();
                    }
                    SortedSet<String> impls = new TreeSet<>(new ServiceComparator(servicePositions));
                    impls.addAll(entry.getValue());
                    Set<String> masked = new HashSet<>();
                    for (String impl : impls) {
                        if (impl.startsWith("#-")) {
                            masked.add(impl);
                            masked.add(impl.substring(2));
                        }
                    }
                    impls.removeAll(masked);
                    for (String impl : impls) {
                        if (servicePositions.containsKey(impl)) {
                            impl += " @" + servicePositions.get(impl);
                        }
                        pw.println("  PROVIDER " + impl);
                    }
                }
            }
        }
        log(serviceOutput + ": service index written");
    }

    private static class ServiceComparator implements Comparator<String> {
        private final Map<String,Integer> servicePositions;
        public ServiceComparator(Map<String,Integer> servicePositions) {
            this.servicePositions = servicePositions;
        }
        public int compare(String i1, String i2) {
            Integer pos1 = servicePositions.get(i1);
            Integer pos2 = servicePositions.get(i2);
            if (pos1 == null) {
                if (pos2 == null) {
                    return i1.compareTo(i2);
                } else {
                    return 1;
                }
            } else {
                if (pos2 == null) {
                    return -1;
                } else {
                    int diff = pos1 - pos2;
                    if (diff != 0) {
                        return diff;
                    } else {
                        return i1.compareTo(i2);
                    }
                }
            }
        }
    }

}
