/*
 * 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.*;
import java.net.*;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.*;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.FileScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.MatchingTask;

import org.apache.tools.ant.types.Mapper;

// XXX in Ant 1.6, permit <xmlcatalog> entries to make checking of "external" links
// work better in the case of cross-links between APIs

/** Task to check for broken links in HTML.
 * Note that this is a matching task and you must give it a list of things to match.
 * The Java VM's configured HTTP proxy will be used (${http.proxyHost} and ${http.proxyPort}).
 * @author Jesse Glick
 */
public class CheckLinks extends MatchingTask {

    private File basedir;
    private boolean checkexternal = true;
    private boolean checkspaces = true;
    private boolean checkforbidden = true;
    private List<Mapper> mappers = new LinkedList<>();
    private List<Filter> filters = new ArrayList<>();
    private File report;

    /** Set whether to check external links (absolute URLs).
     * Local relative links are always checked.
     * By default, external links are checked.
     */
    public void setCheckexternal (boolean ce) {
        checkexternal = ce;
    }
    
    /** False if spaces in URLs shall not be reported. Default to true.
     */
    public void setCheckspaces (boolean s) {
        checkspaces = s;
    }

    /** Allows to disable check for forbidden links.
     */
    public void setCheckforbidden(boolean s) {
        checkforbidden = s;
    }
    
    /** Set the base directory from which to scan files.
     */
    public void setBasedir (File basedir) {
        this.basedir = basedir;
    }
    
    public Filter createFilter () {
        Filter f = new Filter ();
        filters.add (f);
        return f;
    }

    /**
     * If set, create a JUnit-style report on failure, rather than halting the build.
     */
    public void setReport(File report) {
        this.report = report;
    }

    /**
     * Add a mapper to translate file names to the "originals".
     */
    public Mapper createMapper() {
        Mapper m = new Mapper(getProject());
        mappers.add(m);
        return m;
    }

    public void execute () throws BuildException {
        if (basedir == null) throw new BuildException ("Must specify the basedir attribute");
        FileScanner scanner = getDirectoryScanner (basedir);
        scanner.scan ();
        String message = "Scanning for broken links in " + basedir + " ...";
        if (! checkexternal) message += " (external URLs will be skipped)";
        log (message);
        String[] files = scanner.getIncludedFiles ();
        Set<URI> okurls = new HashSet<>(1000);
        Set<URI> badurls = new HashSet<>(100);
        Set<URI> cleanurls = new HashSet<>(100);
        List<String> errors = new ArrayList<>();
        for (int i = 0; i < files.length; i++) {
            File file = new File (basedir, files[i]);
            URI fileurl = file.toURI();
            log ("Scanning " + file, Project.MSG_VERBOSE);
            try {
                scan(this, null, null, getLocation().toString(), "", fileurl, okurls, badurls, cleanurls, checkexternal, checkspaces, checkforbidden, 1, mappers, filters, errors);
            } catch (IOException ioe) {
                throw new BuildException("Could not scan " + file + ": " + ioe, ioe, getLocation());
            }
        }
        String testMessage = null;
        if (!errors.isEmpty()) {
            StringBuilder b = new StringBuilder("There were broken links");
            for (String error : errors) {
                b.append("\n" + error);
            }
            testMessage = b.toString();
        }
        JUnitReportWriter.writeReport(this, null, report, Collections.singletonMap("testBrokenLinks", testMessage));
    }
    
    private static Pattern hrefOrAnchor = Pattern.compile("<(a|img|link)(\\s+shape=\"rect\")?(?:\\s+rel=\"stylesheet\")?\\s+(href|name|src)=\"([^\"#]*)(#[^\"]+)?\"(\\s+shape=\"rect\")?(?:\\s+type=\"text/css\")?\\s*/?>", Pattern.CASE_INSENSITIVE);
    private static Pattern lineBreak = Pattern.compile("^", Pattern.MULTILINE);
    
    /**
     * Scan for broken links.
     * @param task an Ant task to associate with this
     * @param referrer the referrer file path (or full URL if not file:)
     * @param referrerLocation the location in the referrer, e.g. ":38:12", or "" if unavailable
     * @param u the URI to check
     * @param okurls a set of URIs known to be fully checked (including all anchored variants etc.)
     * @param badurls a set of URIs known to be bogus
     * @param cleanurls a set of (base) URIs known to have had their contents checked
     * @param checkexternal if true, check external links (all protocols besides file:)
     * @param recurse one of:
     *                0 - just check that it can be opened;
     *                1 - check also that any links from it can be opened;
     *                2 - recurse
     * @param mappers a list of Mappers to apply to get source files from HTML files
     */
    public static void scan
    (Task task, ClassLoader globalClassLoader, java.util.Map<String,URLClassLoader> classLoaderMap,
     String referrer, String referrerLocation, 
     URI u, Set<URI> okurls, Set<URI> badurls, Set<URI> cleanurls, 
     boolean checkexternal, boolean checkspaces, boolean checkforbidden, int recurse, 
     List<Mapper> mappers, List<String> errors) throws IOException {
        scan (task, globalClassLoader, classLoaderMap,
        referrer, referrerLocation, u, okurls, badurls, cleanurls, checkexternal, checkspaces, checkforbidden, recurse, mappers, Collections.<Filter>emptyList(), errors);
    }
    
    private static void scan
    (Task task, ClassLoader globalClassLoader, java.util.Map<String,URLClassLoader> classLoaderMap,
     String referrer, String referrerLocation, 
     URI u, Set<URI> okurls, Set<URI> badurls, Set<URI> cleanurls,
     boolean checkexternal, boolean checkspaces, boolean checkforbidden, int recurse,
     List<Mapper> mappers, List<Filter> filters, List<String> errors) throws IOException {
        //task.log("scan: u=" + u + " referrer=" + referrer + " okurls=" + okurls + " badurls=" + badurls + " cleanurls=" + cleanurls + " recurse=" + recurse, Project.MSG_DEBUG);
        //System.out.println("");
        //System.out.println("CheckLinks.scan ref: " + referrer);
        //System.out.println("CheckLinks.scan   u: " + u);
        if (okurls.contains(u) && recurse == 0) {
            // Yes it is OK.
            return;
        }
        //Check if referrer is jar file and if u is relative if yes make path absolute
        if (referrer.startsWith("jar:file:") && (u.getScheme() == null) && !u.toString().startsWith("#")) {
            if (u.toString().length() == 0) {
                System.out.println("Invalid URL: Empty URL referred from: " + referrer);
                return;
            }
            if (!u.isAbsolute()) {
                //This is to make inner jar path after ! absolute.
                //It uses java.io.File to remove ../ sequences but as file path
                //on Windows is different from inner jar path it requires some
                //'fix' on Windows.
                int pos = referrer.indexOf("!");
                if (pos != -1) {
                    String base = referrer.substring(0,pos+1);
                    String path1 = referrer.substring(pos+1,referrer.length());
                    //System.out.println("base:" + base);
                    //System.out.println("path1:" + path1);
                    File f1 = new File(path1);
                    File p = f1.getParentFile();
                    File f2 = new File(p,u.getPath());
                    //System.out.println("f1:" + f1);
                    //System.out.println("f2:" + f2);
                    String path2 = null;
                    try {
                        path2 = f2.getCanonicalPath();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    //Ugly hack to get jar inner path from Win FS path
                    //System.out.println("path2:" + path2);
                    if (System.getProperty("os.name").startsWith("Windows")) {
                        path2 = path2.substring(2).replace('\\','/');
                    }
                    try {
                        u = new URI(base+path2);
                    } catch (URISyntaxException ex) {
                        ex.printStackTrace();
                    }
                    //System.out.println("u:" + u);
                }
            }
        }
        URI base;
        if (u.toString().startsWith("#")) {
            try {
                u = new URI(referrer + u.toString());
            } catch (URISyntaxException ex) {
                ex.printStackTrace();
            }
        }
        String b = u.toString().replaceFirst("[#?].*$", "");
        try {
            base = new URI(b);
            //base = new URI(u.getScheme(), u.getUserInfo(), u.getHost(), u.getPort(), u.toURL().getPath(), u.getQuery(), /*fragment*/null);
        } catch (URISyntaxException e) {
            e.printStackTrace();
            throw new Error(e);
        }
        String frag = u.getFragment();
        String basepath = base.toString();
        if ("file".equals(base.getScheme())) {
            try {
                basepath = new File(base).getAbsolutePath();
            } catch (IllegalArgumentException e) {
                errors.add(normalize(referrer, mappers) + referrerLocation + ": malformed URL: " + base + " (" + e.getLocalizedMessage() + ")");
            }
        }
        //task.log("scan: base=" + base + " frag=" + frag, Project.MSG_DEBUG);
        if (badurls.contains(u) || badurls.contains(base)) {
            errors.add(normalize(referrer, mappers) + referrerLocation + ": broken link (already reported): " + u);
            return;
        }

        if (checkforbidden) {
            for (Filter f : filters) {
                Boolean decision = f.isOk (u);
                if (Boolean.TRUE.equals (decision)) {
                    break;
                }
                if (Boolean.FALSE.equals (decision)) {
                    errors.add(normalize(referrer, mappers) + referrerLocation + ": forbidden link: " + base);
                    //System.out.println("badurls ADD1 base:" + base);
                    badurls.add(base);
                    //System.out.println("badurls ADD1    u:" + u);
                    badurls.add(u);
                    return;
                }
            }
        }
        
        if (!checkexternal && !"file".equals(u.getScheme()) && !"jar".equals(u.getScheme()) && !"nbdocs".equals(u.getScheme())) {
            task.log("Skipping external link: " + base, Project.MSG_VERBOSE);
            cleanurls.add(base);
            okurls.add(base);
            okurls.add(u);
            return;
        }
        
         //Translate nbdocs protocol to jar protocol
        if ("nbdocs".equals(u.getScheme())) {
            //If called from CheckHelpSets following params are not set =>
            //we cannot check nbdocs URLs.
            if ((classLoaderMap == null) || (globalClassLoader == null)) {
                return;
            }
            //System.out.println("");
            //System.out.println("r:" + referrer);
            //System.out.println("u:" + u);
            //System.out.println("u.getScheme:" + u.getScheme());
            //System.out.println("u.getHost:" + u.getHost());
            //System.out.println("u.toURL.getHost:" + u.toURL().getHost());
            //System.out.println("u.getPath:" + u.getPath());
            //If no module base name is specified as host name check if given
            //resource is available in current module or globally.
            if (toURL(u).getHost().isEmpty()) {
                errors.add("Missing host in nbdocs protocol URL. URI: " + u);
                errors.add("Referrer: " + referrer);
                String name = u.getPath();
                //Strip leading "/" as findResource does not work when leading slash is present
                if (name.startsWith("/")) {
                    name = name.substring(1,name.length());
                    //System.out.println("name:" + name);
                }
                URL res;
                res = globalClassLoader.getResource(name);
                //System.out.println("res:" + res);
                if (res != null) {
                    try {
                        base = res.toURI();
                        u = base;
                        basepath = base.toString();
                        //System.out.println("base:" + base);
                    } catch (URISyntaxException ex) {
                        ex.printStackTrace();
                    }
                    //Try to find out module for link
                    for (Entry<String,URLClassLoader> e: classLoaderMap.entrySet()) {
                        URLClassLoader cl = e.getValue();
                        if (cl != null) {
                            URL moduleRes = cl.findResource(name);
                            if (moduleRes != null) {
                                task.log("INFO: Link found in module:" + e.getKey() + ". URI: " + u, Project.MSG_INFO);
                                task.log("INFO: Referrer: " + referrer, Project.MSG_INFO);
                                break;
                            }
                        }
                    }
                } else {
                    errors.add("Link not found globally. URI: " + u);
                    errors.add("Referrer: " + referrer);
                    return;
                } 
                //System.out.println("res:" + res);
            } else {
                String name = u.getPath();
                //Strip leading "/" as findResource does not work when leading slash is present
                if (name.startsWith("/")) {
                    name = name.substring(1,name.length());
                    //System.out.println("name:" + name);
                }
                URL res = null;
                URLClassLoader moduleClassLoader = classLoaderMap.get(toURL(u).getHost());
                //Log warning
                if (moduleClassLoader == null) {
                    errors.add("Module " + toURL(u).getHost() + " not found among modules containing helpsets. URI: " + u);
                    errors.add("Referrer: " + referrer);
                }
                if (moduleClassLoader != null) {
                    res = moduleClassLoader.findResource(name);
                    //System.out.println("res1:" + res);
                    if (res != null) {
                        try {
                            base = res.toURI();
                            u = base;
                            basepath = base.toString();
                            //System.out.println("base:" + base);
                        } catch (URISyntaxException ex) {
                            ex.printStackTrace();
                        }
                    }
                }
                if (res == null) {
                    if (moduleClassLoader != null) {
                        errors.add("Link not found in module " + toURL(u).getHost() + " URI: " + u);
                        errors.add("Referrer: " + referrer);
                    }
                    res = globalClassLoader.getResource(name);
                    //System.out.println("res2:" + res);
                    if (res != null) {
                        try {
                            base = res.toURI();
                            u = base;
                            basepath = base.toString();
                            //System.out.println("base:" + base);
                        } catch (URISyntaxException ex) {
                            ex.printStackTrace();
                        }
                        //Try to find out module for link
                        for (Entry<String,URLClassLoader> e: classLoaderMap.entrySet()) {
                            URLClassLoader cl = e.getValue();
                            if (cl != null) {
                                URL moduleRes = cl.findResource(name);
                                if (moduleRes != null) {
                                    task.log("INFO: Link found in module:" + e.getKey() + ". URI: " + u, Project.MSG_INFO);
                                    task.log("INFO: Referrer: " + referrer, Project.MSG_INFO);
                                    break;
                                }
                            }
                        }
                    } else {
                        errors.add("Link not found globally. URI: " + u);
                        errors.add("Referrer: " + referrer);
                        return;
                    } 
                }
            }
        }
        task.log("Checking " + u + " (recursion level " + recurse + ")", Project.MSG_VERBOSE);
        String content;
        String mimeType;
        try {
            // XXX for protocol 'file', could more efficiently use a memmapped char buffer
            URLConnection conn = toURL(base).openConnection();
            //System.out.println("CALL OF connect");
            conn.connect();
            mimeType = conn.getContentType ();
            InputStream is = conn.getInputStream ();
            String enc = conn.getContentEncoding();
            if (enc == null) {
                enc = "UTF-8";
            }
            try {
                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                int read;
                byte[] buf = new byte[4096];
                while ((read = is.read(buf)) != -1) {
                    baos.write(buf, 0, read);
                }
                content = baos.toString(enc);
            } finally {
                is.close();
            }
        } catch (IOException ioe) {
            errors.add(normalize(referrer, mappers) + referrerLocation + ": Broken link: " + base);
            task.log("WARNING: URI: " + u, Project.MSG_VERBOSE);
            task.log("ERROR: " + ioe, Project.MSG_VERBOSE);
            badurls.add(base);
            badurls.add(u);
            //Log exception stack trace only in verbose mode
            StringWriter sw = new StringWriter(500);
            PrintWriter pw = new PrintWriter(sw);
            ioe.printStackTrace(pw);
            task.log(sw.toString(),Project.MSG_VERBOSE);
            return;
        } catch (NullPointerException exc) {
            errors.add("NPE Link referred from: " + normalize(referrer, mappers) + referrerLocation + " Broken link: " + base);
            task.log("WARNING: URI: " + u);
            task.log("ERROR: " + exc, Project.MSG_WARN);
            badurls.add(base);
            badurls.add(u);
            //Log exception stack trace only in verbose mode
            StringWriter sw = new StringWriter(500);
            PrintWriter pw = new PrintWriter(sw);
            exc.printStackTrace(pw);
            task.log(sw.toString(),Project.MSG_WARN);
            return;
        }
        okurls.add(base);
        // map from other URIs (hrefs) to line/col info where they occur in this file (format: ":1:2")
        Map<URI,String> others = null;
        if (recurse > 0 && cleanurls.add(base)) {
            others = new HashMap<>(100);
        }
        if (recurse == 0 && frag == null) {
            // That is all we wanted to check.
            return;
        }
        if ("text/html".equals(mimeType)) {
            task.log("Parsing " + base, Project.MSG_VERBOSE);
            Matcher m = hrefOrAnchor.matcher(content);
            Set<String> names = new HashSet<>(100); // Set<String>
            while (m.find()) {
                // Get the stuff involved:
                String type = m.group(3);
                if (type.equalsIgnoreCase("name")) {
                    // We have an anchor, therefore refs to it are valid.
                    String name = unescape(m.group(4));
                    if (names.add(name)) {
                        try {
                            //URI does not handle jar:file: protocol
                            //okurls.add(new URI(base.getScheme(), base.getUserInfo(), base.getHost(), base.getPort(), base.getPath(), base.getQuery(), /*fragment*/name));
                            okurls.add(new URI(base + "#" + name.replaceAll(" ", "%20")));
                        } catch (URISyntaxException e) {
                            errors.add(normalize(basepath, mappers) + findLocation(content, m.start(4)) + ": bad anchor name: " + e.getMessage());
                        }
                    } else if (recurse == 1) {
                        errors.add(normalize(basepath, mappers) + findLocation(content, m.start(4)) + ": duplicate anchor name: " + name);
                    }
                } else {
                    // A link to some other document: href=, src=.

                    // check whether this URL is not commented out
                    int previousCommentStart = content.lastIndexOf ("<!--", m.start (0));
                    int previousCommentEnd = content.lastIndexOf ("-->", m.start (0));
                    boolean commentedOut = false;
                    if (previousCommentEnd < previousCommentStart) {
                        // comment start is there and end is before it
                        commentedOut = true;
                    }

                    if (others != null && !commentedOut) {
                        String otherbase = unescape(m.group(4));
                        String otheranchor = unescape(m.group(5));
                        String uri = (otheranchor == null) ? otherbase : otherbase + otheranchor;
                        String location = findLocation(content, m.start(4));
                        String fixedUri;
                        if (uri.indexOf(' ') != -1) {
                            fixedUri = uri.replaceAll(" ", "%20");
                            if (checkspaces) {
                                errors.add(normalize(basepath, mappers) + location + ": spaces in URIs should be encoded as \"%20\": " + uri);
                            }
                        } else {
                            fixedUri = uri;
                        }
                        try {
                            URI relUri = new URI(fixedUri);
                            if (!relUri.isOpaque()) {
                                URI o = base.resolve(relUri).normalize();
                                //task.log("href: " + o);
                                if (!others.containsKey(o)) {
                                    // Only keep location info for first reference.
                                    others.put(o, location);
                                }
                            } // else mailto: or similar
                        } catch (URISyntaxException e) {
                            // Message should contain the URI.
                            errors.add(normalize(basepath, mappers) + location + ": bad relative URI: " + e.getMessage());
                        }
                    } // else we are only checking that this one has right anchors
                }
            }
        } else {
            task.log("Not checking contents of " + base, Project.MSG_VERBOSE);
        }
        if (! okurls.contains(u)) {
            errors.add(normalize(referrer, mappers) + referrerLocation + ": broken link: " + u);
            badurls.add(u); // #97784
        }
        if (others != null) {
            for(Entry<URI,String> entry: others.entrySet()) {
                URI other = entry.getKey();
                String location = entry.getValue();
                //System.out.println("CALL OF scan basepath:" + basepath + " location:" + location + " other:" + other);
                scan(task, globalClassLoader, classLoaderMap,
                basepath, location, other, okurls, badurls, cleanurls, checkexternal, checkspaces, checkforbidden, recurse == 1 ? 0 : 2, mappers, filters, errors);
            }
        }
    }
    
    private static String normalize(String path, List<Mapper> mappers) throws IOException {
        try {
            for (Mapper m : mappers) {
                String[] nue = m.getImplementation().mapFileName(path);
                if (nue != null) {
                    for (int i = 0; i < nue.length; i++) {
                        File f = new File(nue[i]);
                        if (f.isFile()) {
                            return new File(f.toURI().normalize()).getAbsolutePath();
                        }
                    }
                }
            }
            return path;
        } catch (BuildException e) {
            throw new IOException(e.toString());
        }
    }
    
    private static String unescape(String text) {
        if (text == null) {
            return null;
        }
        int pos = 0;
        int search;
        while ((search = text.indexOf('&', pos)) != -1) {
            int semi = text.indexOf(';', search + 1);
            if (semi == -1) {
                // Unterminated &... leave rest as is??
                return text;
            }
            String entity = text.substring(search + 1, semi);
            String repl;
            if (entity.equals("amp")) {
                repl = "&";
            } else if (entity.equals("quot")) {
                repl = "\"";
            } else if (entity.equals("lt")) {
                repl = "<";
            } else if (entity.equals("gt")) {
                repl = ">";
            } else if (entity.equals("apos")) {
                repl = "'";
            } else {
                // ???
                pos = semi + 1;
                continue;
            }
            text = text.substring(0, search) + repl + text.substring(semi + 1);
            pos = search + repl.length();
        }
        return text;
    }
    
    private static String findLocation(CharSequence content, int pos) {
        Matcher lbm = lineBreak.matcher(content);
        int line = 0;
        int col = 1;
        while (lbm.find()) {
            if (lbm.start() <= pos) {
                line++;
                col = pos - lbm.start() + 1;
            } else {
                break;
            }
        }
        return ":" + line + ":" + col;
    }

    static final ThreadLocal<URLStreamHandlerFactory> handlerFactory = new ThreadLocal<URLStreamHandlerFactory>();
    static URL toURL(URI uri) throws MalformedURLException {
        URLStreamHandlerFactory f = handlerFactory.get();
        URLStreamHandler h = f != null && uri.getScheme() != null ? f.createURLStreamHandler(uri.getScheme()) : null;
        return h != null ? new URL(null, uri.toString(), h) : uri.toURL();
    }

    public final class Filter extends Object {
        private Boolean accept;
        private Pattern pattern;
        
        public void setAccept (boolean a) {
            accept = Boolean.valueOf (a);
        }
        
        public void setPattern (String s) {
            pattern = Pattern.compile (s, Pattern.CASE_INSENSITIVE);
        }
        
        /** Checks whether a URI is ok. 
         * @return null if not applicable, Boolean.TRUE if the URL is accepted, Boolean.FALSE if not
         */
        final Boolean isOk (URI u) throws BuildException {
            if (accept == null) {
                throw new BuildException ("Each filter must have accept attribute");
            }
            if (pattern == null) {
                throw new BuildException ("Each filter must have pattern attribute");
            }
            
            if (pattern.matcher (u.toString ()).matches ()) {
                log ("Matched " + u + " accepted: " + accept, org.apache.tools.ant.Project.MSG_VERBOSE);
                return accept;
            }
            return null;
        }
    }
}
