/*
 * 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.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;

/**
 * Increments specification versions of all specified modules,
 * in the trunk or in a branch, in a regulated manner.
 * @author Jesse Glick
 */
public final class RefreshDependencyVersions extends Task {
    
    private File nbroot;
    private String codenameBase;
    private String release;
    private String specification;
    
    private boolean dryRun = false;
    private final Set<Dep> injectDeps = new HashSet<>();
    
    public RefreshDependencyVersions() {}
    
    public void setNbroot(File f) {
        nbroot = f;
    }
    
    public void setModule(String codenameBase) {
        this.codenameBase = codenameBase;
    }

    public void setRelease(String release) {
        this.release = release == null || release.length() == 0 ? null : release;
    }

    public void setSpecification(String specification) {
        this.specification = specification == null || specification.length() == 0 ? null : specification;
    }
    
    public void setDryRun(boolean b) {
        dryRun = b;
    }
    
    public Dep createInject() {
        Dep dep = new Dep();
        injectDeps.add(dep);
        return dep;
    }
    
    public @Override void execute() throws BuildException {
        if (nbroot == null || codenameBase == null) {
            throw new BuildException("Missing params 'nbroot' or 'modules'", getLocation());
        }

        log("RefreshDependencyVersions parameters: nbroot = '" + nbroot.getAbsolutePath() + "'"
                + ", module = " + codenameBase + ", dryrun = " + dryRun + ", inject = " + injectDeps, Project.MSG_VERBOSE);

        validateInjectedDependencies(injectDeps);

        Map<String,Object> properties = getProject().getProperties();
        ModuleListParser listParser;
        try {
            listParser = new ModuleListParser(properties, ModuleType.NB_ORG, getProject());
        } catch (IOException ioe) {
            throw new BuildException("Can't read module list!", ioe, getLocation());
        }
        Set<ModuleListParser.Entry> allModules = listParser.findAll();
        
        Dep sourceModuleVersion = null;
        
        // find the versions of the source module
        for(ModuleListParser.Entry moduleEntry: allModules) {
            String path = moduleEntry.getNetbeansOrgPath();
            File projectDir = new File(nbroot, path.replace('/', File.separatorChar));
            if (!projectDir.isDirectory()) {
                log("No such directory " + projectDir + "; skipping", Project.MSG_WARN);
                continue;
            }
            
            Dep mv = loadDepFrom(projectDir);
            if (mv == null) {
                log("Not a module: " + projectDir + ", skipping!", Project.MSG_WARN);
                continue;
            }
            
            log("Loading module versions: " + mv.getCodenameBase()
                    + ", release = " + mv.getRelease()
                    + ", spec = " + mv.getSpecification(),
                Project.MSG_VERBOSE);
            
            if (codenameBase.equals(mv.getCodenameBase())) {
                if (mv.getRelease() == null) {
                    throw new BuildException("Unknown release version of '" + codenameBase + "'.", getLocation());
                }
                if (mv.getSpecification() == null) {
                    throw new BuildException("Unknown specification version of '" + codenameBase + "'.", getLocation());
                }
                if (release != null && compare(mv.getRelease(), release) < 0) {
                    throw new BuildException("Release version of '" + codenameBase + "' is " + mv.getRelease() + ". Requested to update to " + release, getLocation());
                }
                if (specification != null && compare(mv.getSpecification(), specification) < 0) {
                    throw new BuildException("Specification version of '" + codenameBase + "' is " + mv.getSpecification() + ". Requested to update to " + specification, getLocation());
                }
                sourceModuleVersion = mv;
                break;
            }
        }
        if (sourceModuleVersion == null) {
            throw new BuildException("Can't find '" + codenameBase + "' module!", getLocation());
        }
        
        // update all modules that depend on the source module version older or equal to release & specification,
        // but not update the source module itself and not the modules we are injecting
        NBPRJ:
        for(ModuleListParser.Entry moduleEntry: allModules) {
            String path = moduleEntry.getNetbeansOrgPath();
            File projectFile = new File(new File(nbroot, path.replace('/', File.separatorChar)), "nbproject" + File.separatorChar + "project.xml");
            if (!projectFile.exists()) {
                log("Non-existent project.xml: " + path + ", skipping!", Project.MSG_WARN);
            }

            Document nbprj = null;
            try {
                try (InputStream is = new FileInputStream(projectFile)) {
                    nbprj = XMLUtil.parse(new InputSource(is), false, true, null, null);
                }
            } catch (Exception ioe) {
                throw new BuildException("Can't parse " + projectFile, ioe, getLocation());
            }

            // check that nbprj is not the source module or one of the injects
            Node cnb = findChild(findChild(findChild(nbprj.getDocumentElement(), "configuration"), "data"), "code-name-base");
            if (cnb.getTextContent().equals(codenameBase)) {
                log("Won't touch dependencies of the source module (" + codenameBase + ")", Project.MSG_VERBOSE);
                continue;
            }
            for(Dep inject : injectDeps) {
                if (cnb.getTextContent().equals(inject.getCodenameBase())) {
                    log("Won't touch dependencies of the injected module (" + inject.getCodenameBase() + ")", Project.MSG_VERBOSE);
                    continue NBPRJ;
                }
            }
            
            boolean updated = false;
            StringBuilder refreshMsg = new StringBuilder();
            StringBuilder injectMsg = new StringBuilder();
            
            Node sourceDep = findDependencyFor(nbprj, codenameBase);
            if (sourceDep != null) {
                boolean compareSpecification = true;
                // check release versions
                if (release != null) {
                    Node sourceRelease = findChild(findChild(sourceDep, "run-dependency"), "release-version");
                    if (sourceRelease != null && sourceRelease.getTextContent().length() > 0)
                    {
                        int c = compare(sourceRelease.getTextContent(), release);
                        if (c > 0) {
                            // sourceDep is newer than release, skip
                            continue;
                        } else if (c < 0) {
                            // sourceDep is older then release, ignore specification and updateVersions
                            compareSpecification = false;
                        }
                    }
                }
                
                // check specification versions
                if (compareSpecification && specification != null) {
                    Node sourceSpecification = findChild(findChild(sourceDep, "run-dependency"), "specification-version");
                    if (sourceSpecification != null && sourceSpecification.getTextContent().length() > 0) {
                        if (compare(sourceSpecification.getTextContent(), specification) >= 0) {
                            // sourceDep is newer or equal than specification, skip
                            continue;
                        }
                    } else {
                        Node sourceImplementation = findChild(findChild(sourceDep, "run-dependency"), "implementation-version");
                        if (sourceImplementation != null) {
                            // sourceDep is implementation dependency, skip
                            continue;
                        }
                    }
                }
                
                updated |= updateVersions(sourceDep, sourceModuleVersion, false, refreshMsg);
                
                for(Dep inject : injectDeps) {
                    if (injectMsg.length() > 0) {
                        injectMsg.append(", ");
                    }
                    Node injectedDep = findDependencyFor(nbprj, inject.getCodenameBase());
                    if (injectedDep != null) {
                        updated |= updateVersions(injectedDep, inject, true, injectMsg);
                    } else {
                        addDependency(sourceDep.getParentNode(), inject, injectMsg);
                        updated = true;
                    }
                }
            }
            
            if (updated) {
                try {
                    if (!dryRun) {
                        try (OutputStream os = new FileOutputStream(projectFile)) {
                            XMLUtil.write(nbprj, os);
                        }
                    } else {
                        if (!projectFile.canWrite()) {
                            log("Would need to update " + projectFile + ", but it's readonly!", Project.MSG_ERR);
                        }
                    }
                } catch (IOException ioe) {
                    throw new BuildException("Can't write " + projectFile, ioe, getLocation());
                }
            }
            
            if (refreshMsg.length() > 0) {
                log("Dependencies refreshed in " + projectFile.getAbsolutePath() + ": " + refreshMsg.toString(), Project.MSG_INFO);
            }
            if (injectMsg.length() > 0) {
                log("Dependencies injected to " + projectFile.getAbsolutePath() + ": " + injectMsg.toString(), Project.MSG_INFO);
            }
        }
    }

    private static Node findChild(Node node, String childName) {
        NodeList kids = node.getChildNodes();
        for(int i = 0; i < kids.getLength(); i++) {
            if (kids.item(i).getNodeName().equals(childName)) {
                return kids.item(i);
            }
        }
        return null;
    }
    
    private static Node findDependencyFor(Document nbprj, String codenameBase) {
        NodeList allDeps = nbprj.getElementsByTagName("dependency");
        for(int i = 0; i < allDeps.getLength(); i++) {
            Node dep = allDeps.item(i);
            Node cnb = findChild(dep, "code-name-base");

            if (cnb.getTextContent().equals(codenameBase)) {
                return dep;
            }
        }
        return null;
    }

    private static boolean updateVersions(Node dep, Dep sourceDep, boolean injecting, StringBuilder log) {
        StringBuilder s = new StringBuilder();
        Node runDep = findChild(dep, "run-dependency");
        if (injecting) {
            Node buildPrerequisite = findChild(dep, "build-prerequisite");
            if (buildPrerequisite == null) {
                buildPrerequisite = dep.getOwnerDocument().createElement("build-prerequisite");
                dep.appendChild(buildPrerequisite);
                s.append(" adding <build-prerequisite/>");
            }

            Node compileDep = findChild(dep, "compile-dependency");
            if (compileDep == null) {
                compileDep = dep.getOwnerDocument().createElement("compile-dependency");
                dep.appendChild(compileDep);
                s.append(" adding <compile-dependency/>");
            }
            
            if (runDep == null) {
                runDep = dep.getOwnerDocument().createElement("run-dependency");
                dep.appendChild(runDep);
                s.append(" adding <run-dependency/>");
            }
        }
        
        if (runDep != null) {
            // update the release version
            Node releaseVersion = findChild(runDep, "release-version");
            if (releaseVersion != null && sourceDep.getRelease() != null) {
                String nue = checkReleaseVersion(sourceDep.getRelease());
                if (!releaseVersion.getTextContent().equals(nue)) {
                    releaseVersion.setTextContent(nue);
                    s.append(" release = ").append(nue);
                }
            }

            // update the specification version
            Node specVersion = findChild(runDep, "specification-version");
            if (!injecting) {
                assert sourceDep.getSpecification() != null : "Need specification version when refreshing dependencies"; //NOI18N
                if (specVersion == null) {
                    Node implVersion = findChild(runDep, "implementation-version");
                    if (implVersion == null) {
                        // umm, no spec version and not an impl. dependency
                        specVersion = dep.getOwnerDocument().createElement("specification-version");
                        specVersion.setTextContent("");
                        runDep.appendChild(specVersion);
                    }
                }                
                if (specVersion != null) {
                    String nue = checkSpecificationVersion(sourceDep.getSpecification());
                    if (!specVersion.getTextContent().equals(nue)) {
                        specVersion.setTextContent(nue);
                        s.append(" spec = ").append(nue);
                    }
                }
            } else {
                Node implVersion = findChild(runDep, "implementation-version");
                if (sourceDep.isImplementation()) {
                    if (specVersion != null) {
                        runDep.removeChild(specVersion);
                        s.append(" removing spec = " + specVersion.getTextContent());
                    }
                    if (implVersion == null) {
                        implVersion = dep.getOwnerDocument().createElement("implementation-version");
                        runDep.appendChild(implVersion);
                        s.append(" adding impl");
                    }
                } else {
                    if (sourceDep.getSpecification() != null) {
                        String nue = checkSpecificationVersion(sourceDep.getSpecification());
                        if (specVersion == null) {
                            specVersion = dep.getOwnerDocument().createElement("specification-version");
                            specVersion.setTextContent(nue);
                            runDep.appendChild(specVersion);
                            s.append(" adding spec = ").append(nue);
                        } else if (!specVersion.getTextContent().equals(nue)) {
                            specVersion.setTextContent(nue);
                            s.append(" updating spec = ").append(nue);
                        }
                    }
                    if (implVersion != null) {
                        runDep.removeChild(implVersion);
                        s.append(" removing impl");
                    }
                }
            }
        }
        
        log.append(sourceDep.getCodenameBase()).append(':');
        if (s.length() > 0) {
            log.append(s);
            return true;
        } else {
            log.append(" up-to-date");
            return false;
        }
    }

    private void addDependency(Node moduleDependencies, Dep inject, StringBuilder log) {
        Document nbprj = moduleDependencies.getOwnerDocument();
        Element nueDep = nbprj.createElement("dependency");
        moduleDependencies.appendChild(nueDep);

        Element nueCnb = nbprj.createElement("code-name-base");
        nueCnb.setTextContent(inject.getCodenameBase());
        nueDep.appendChild(nueCnb);
        log.append(inject.getCodenameBase());

        Element nueBuildPrerequisite = nbprj.createElement("build-prerequisite");
        nueDep.appendChild(nueBuildPrerequisite);

        Element nueCompileDep = nbprj.createElement("compile-dependency");
        nueDep.appendChild(nueCompileDep);
        
        Element nueRunDep = nbprj.createElement("run-dependency");
        nueDep.appendChild(nueRunDep);

        Element nueRelease = nbprj.createElement("release-version");
        String nue = checkReleaseVersion(inject.getRelease());
        nueRelease.setTextContent(nue);
        nueRunDep.appendChild(nueRelease);
        log.append('/').append(nue);

        if (inject.isImplementation()) {
            nueRunDep.appendChild(nbprj.createElement("implementation-version"));
            log.append(" = impl");
        } else {
            Element nueSpec = nbprj.createElement("specification-version");
            nue = checkSpecificationVersion(inject.getSpecification());
            nueSpec.setTextContent(nue);
            nueRunDep.appendChild(nueSpec);
            log.append(" >= " + nue);
        }
    }
    
    private static String[] gulp(File file, String enc) throws IOException {
        try (InputStream is = new FileInputStream(file)) {
            BufferedReader r = new BufferedReader(new InputStreamReader(is, enc));
            List<String> l = new ArrayList<>();
            String line;
            while ((line = r.readLine()) != null) {
                l.add(line);
            }
            return l.toArray(new String[l.size()]);
        }
    }
    
//    private static void spit(File file, String enc, String[] lines) throws IOException {
//        OutputStream os = new FileOutputStream(file);
//        try {
//            PrintWriter w = new PrintWriter(new OutputStreamWriter(os, enc));
//            for (String line : lines) {
//                w.println(line);
//            }
//            w.flush();
//        } finally {
//            os.close();
//        }
//    }

    private void validateInjectedDependencies(Set<Dep> deps) throws BuildException {
        for(Dep dep : deps) {
            if (dep.getCodenameBase() == null || dep.getCodenameBase().length() == 0) {
                throw new BuildException("The 'codenamebase' attribute of injected dependency can't be empty.", getLocation());
            }
            if (dep.getRelease() == null || dep.getRelease().length() == 0) {
                throw new BuildException("The 'release' attribute of injected dependency can't be empty.", getLocation());
            }
            if (!dep.isImplementation()) {
                if (dep.getSpecification() == null || dep.getSpecification().length() == 0) {
                    throw new BuildException("The 'specification' attribute of injected dependency can't be empty. Or implementation='true' must be specified.", getLocation());
                }
            }
        }
    }

    private static String checkReleaseVersion(String v) {
        if (v.equals("0")) {
            return "0-1";
        } else {
            return v;
        }
    }
    
    private static String checkSpecificationVersion(String v) {
        if (v.endsWith(".0") && v.indexOf('.') != v.length() - 2) {
            // ends up with '.0' and it's not just 'x.0', but eg. x.x.0
            v = v.substring(0, v.length() - 2);
        }
        return v;
    }

    private Dep loadDepFrom(File projectDir) throws BuildException {
        Dep dep = new Dep();
        try {
            File pp = new File(projectDir, "nbproject" + File.separatorChar + "project.properties");
            if (pp.isFile()) {
                String[] lines = gulp(pp, "ISO-8859-1");
                for (int i = 0; i < lines.length; i++) {
                    Matcher m1 = Pattern.compile("(spec\\.version\\.base=)(.+)").matcher(lines[i]);
                    if (m1.matches()) {
                        dep.setSpecification(m1.group(2));
                    }
                }
            } else {
                if (!new File(projectDir, "nbproject" + File.separatorChar + "project.xml").isFile()) {
                    log("No such file " + pp + "; unprojectized module?", Project.MSG_VERBOSE);
                    return null;
                }
            }
            File mf = new File(projectDir, "manifest.mf");
            if (mf.isFile()) {
                String[] lines = gulp(mf, "UTF-8");
                for (int i = 0; i < lines.length; i++) {
                    Matcher m1 = Pattern.compile("(OpenIDE-Module: )(.+)").matcher(lines[i]);
                    if (m1.matches()) {
                        String fullName = m1.group(2);
                        int idx = fullName.lastIndexOf('/');
                        if (idx != -1) {
                            dep.setCodenameBase(fullName.substring(0, idx));
                            dep.setRelease(fullName.substring(idx + 1));
                        } else {
                            dep.setCodenameBase(fullName);
                            dep.setCodenameBase("0");
                        }
                    }
                    m1 = Pattern.compile("(OpenIDE-Module-Specification-Version: )(.+)").matcher(lines[i]);
                    if (m1.matches()) {
                        dep.setSpecification(m1.group(2));
                    }
                }
                return dep;
            } else {
                log("No such file " + mf + "; not a real module?", Project.MSG_VERBOSE);
                return null;
            }
        } catch (IOException e) {
            throw new BuildException("While processing project files in " + projectDir + ": " + e, e, getLocation());
        }
    }
    
    private static int compare(String v1, String v2) {
        String [] v1arr = v1.split("\\.");
        String [] v2arr = v2.split("\\.");
        int len = Math.min(v1arr.length, v2arr.length);
        for (int i = 0; i < len; i++) {
            int v1int = str2ver(v1arr[i]);
            int v2int = str2ver(v2arr[i]);
            int result = v1int - v2int;
            if (result != 0) {
                return result;
            }
        }
        // 1.1 is older then 1.1.1
        return v1arr.length - v2arr.length;
    }
    
    private static int str2ver(String s) {
        int i = s.indexOf('-');
        if (i == -1) {
            return Integer.parseInt(s);
        } else {
            return Integer.parseInt(s.substring(0, i));
        }
    }
    
//    private class ModuleVersion {
//        private String codebaseName;
//        private String release;
//        private String specification;
//        private String implementation;
//        
//        public ModuleVersion() {
//        }
//
//        public String getCodebaseName() {
//            return codebaseName;
//        }
//
//        public String getImplementation() {
//            return implementation;
//        }
//
//        public String getRelease() {
//            return release;
//        }
//
//        public String getSpecification() {
//            return specification;
//        }
//        
//    } // End of ModuleVersion class

    public static final class Dep {
        
        private String codenameBase;
        private String release;
        private String specification;
        private boolean implementation;

        public Dep() {
            
        }
        
        public String getCodenameBase() {
            return codenameBase;
        }

        public void setCodenameBase(String codenameBase) {
            this.codenameBase = codenameBase == null || codenameBase.length() == 0 ? null : codenameBase;
        }

        public boolean isImplementation() {
            return implementation;
        }

        public void setImplementation(boolean implementation) {
            if (implementation && specification != null) {
                throw new IllegalArgumentException("Can't use implementation dependency when specification version is set.");
            }
            this.implementation = implementation;
        }

        public String getRelease() {
            return release;
        }

        public void setRelease(String release) {
            this.release = release == null || release.length() == 0 ? null : release;
        }

        public String getSpecification() {
            return specification;
        }

        public void setSpecification(String specification) {
            if (implementation && specification != null && specification.length() > 0) {
                throw new IllegalArgumentException("Can't specification version is set when using implementation dependency.");
            }
            this.specification = specification == null || specification.length() == 0 ? null : specification;
        }

        public @Override boolean equals(Object obj) {
            if (obj == null) {
                return false;
            }
            if (getClass() != obj.getClass()) {
                return false;
            }
            final Dep other = (Dep) obj;
            if (this.codenameBase != other.codenameBase && (this.codenameBase == null || !this.codenameBase.equals(other.codenameBase))) {
                return false;
            }
            if (this.release != other.release && (this.release == null || !this.release.equals(other.release))) {
                return false;
            }
            if (this.specification != other.specification && (this.specification == null || !this.specification.equals(other.specification))) {
                return false;
            }
            if (this.implementation != other.implementation) {
                return false;
            }
            return true;
        }

        public @Override int hashCode() {
            int hash = 7;
            hash = 79 * hash + (this.codenameBase != null ? this.codenameBase.hashCode() : 0);
            hash = 79 * hash + (this.release != null ? this.release.hashCode() : 0);
            hash = 79 * hash + (this.specification != null ? this.specification.hashCode() : 0);
            hash = 79 * hash + (this.implementation ? 1 : 0);
            return hash;
        }

        public @Override String toString() {
            if (specification != null) {
                return codenameBase + "/" + release + " >= " + specification;
            } else {
                return codenameBase + "/" + release + " = impl";
            }
        }

    } // End of Dep class
}
