/*
 *  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.apache.groovy.groovysh.commands

import jline.console.completer.Completer
import org.apache.groovy.groovysh.CommandSupport
import org.apache.groovy.groovysh.Groovysh

/**
 * The 'doc' command.
 *
 * @since 2.2.0
 */
class DocCommand extends CommandSupport {

    public static final String COMMAND_NAME = ':doc'

    private static final String ENV_BROWSER = 'BROWSER'
    private static final String ENV_BROWSER_GROOVYSH = 'GROOVYSH_BROWSER'
    private static final List<String> PRIMITIVES = ['boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double']

    private static final int TIMEOUT_CONN = 5 * 1000 // ms
    private static final int TIMEOUT_READ = 5 * 1000 // ms

    // indicates support for java.awt.Desktop#browse on the current platform
    private static boolean hasAWTDesktopPlatformSupport
    private static desktop

    /**
     * Check for java.awt.Desktop#browse platform support
     */
    static {
        try {
            def desktopClass = Class.forName('java.awt.Desktop')
            desktop = desktopClass.desktopSupported ? desktopClass.desktop : null

            hasAWTDesktopPlatformSupport =
                    desktop != null &&
                            desktop.isSupported(desktopClass.declaredClasses.find { it.simpleName == 'Action' }.BROWSE)

        } catch (Exception e) {
            hasAWTDesktopPlatformSupport = false
            desktop = null
        }
    }

    DocCommand(final Groovysh shell) {
        super(shell, COMMAND_NAME, ':D')
    }

    @Override
    protected List<Completer> createCompleters() {
        return [new ImportCompleter(shell.packageHelper, shell.interp, false),
                null]
    }

    @Override
    Object execute(final List<String> args) {
        if (args?.size() == 1) {
            doc(args[0])
            return
        }
        if (args?.size() == 2) {
            doc(args[1], args[0])
            return
        }
        fail(messages.format('error.unexpected_args', args ? args.join(' ') : 'no arguments'))
    }

    void doc(String className, String module = null) {
        def normalizedClassName = normalizeClassName(className)
        def normalizedModule = normalizeClassName(module ?: '')
        def urls = urlsFor(normalizedClassName, normalizedModule)
        if (urls.empty) {
            fail("Documentation for \"${normalizedClassName}\" could not be found.")
        }

        // Print the URLs.
        // It is useful especially when the browsing fails.
        urls.each { url -> io.out.println url }

        browse(urls)
    }

    protected String normalizeClassName(String className) {
        className.replace('"', '').replace("'", '').replace('[', '%5B').replace(']', '%5D')
    }

    protected void browse(List urls) {
        def browser = browserEnvironmentVariable

        // fallback to java.awt.Desktop in case of missing env variable(s)
        if (browser) {
            browseWithNativeBrowser(browser, urls)
        } else if (hasAWTDesktopPlatformSupport) {
            browseWithAWT(urls)
        } else {
            fail 'Browser could not be opened due to missing platform support for "java.awt.Desktop". Please set ' +
                    "a $ENV_BROWSER_GROOVYSH or $ENV_BROWSER environment variable referring to the browser binary to " +
                    'solve this issue.'
        }
    }

    protected String getBrowserEnvironmentVariable() {
        System.getenv(ENV_BROWSER_GROOVYSH) ?: System.getenv(ENV_BROWSER)
    }

    protected void browseWithAWT(List urls) {
        try {
            urls.each { url -> desktop.browse(url.toURI()) }
        } catch (Exception e) {
            fail "Browser could not be opened, an unexpected error occured (${e}). You can add a " +
                    "$ENV_BROWSER_GROOVYSH or $ENV_BROWSER environment variable to explicitly specify a browser binary."
        }
    }

    protected void browseWithNativeBrowser(String browser, List urls) {
        try {
            "$browser ${urls.join(' ')}".execute()
        } catch (Exception e) {
            // we could be here caused by a IOException, SecurityException or NP Exception
            fail "Browser could not be opened (${e}). Please check the $ENV_BROWSER_GROOVYSH or $ENV_BROWSER " +
                    "environment variable."
        }
    }

    protected List urlsFor(String className, String module = '') {
        String groovyVersion = GroovySystem.version
        String path = className.replace('.', '/') + '.html'

        def url
        def urls = []
        if (!module && className.matches(/^(groovy|org\.codehaus\.groovy|org\.apache\.groovy|)\..+/)) {
            if (sendHEADRequest(url = new URL("https://docs.groovy-lang.org/$groovyVersion/html/gapi/$path"), path)) {
                urls << url
            }
        }
        // Don't specify package names to not depend on a specific version of Java SE.
        // Java SE includes non-java(x) packages such as org.w3m.*, org.omg.*. org.xml.* for now
        // and new packages might be added in the future.
        if (sendHEADRequest(url = new URL("https://docs.oracle.com/${versionPrefix(module)}/$path"), path) ||
            sendHEADRequest(url = new URL("https://download.java.net/java/early_access/${versionPrefix(module, true)}/$path"), path)) {
            urls << url
        } else if (!module) {
            // if no module specified, fall back to JDK8 if java.base url wasn't found
            if (sendHEADRequest(url = new URL("https://docs.oracle.com/javase/8/docs/api/$path"), path)) {
                urls << url
            }
        }
        // make accessing enhancements for e.g. int[] or double[][] easier
        if (PRIMITIVES.any{path.startsWith(it) }) {
            path = "primitives-and-primitive-arrays/$path"
        }

        if (sendHEADRequest(url = new URL("https://docs.groovy-lang.org/$groovyVersion/html/groovy-jdk/$path"), path)) {
            urls << url
        }

        urls
    }

    private static versionPrefix(String module, boolean ea = false) {
        String javaVersion = System.getProperty('java.version')
        if (javaVersion.startsWith('1.')) {
            'javase/' + javaVersion.split(/\./)[1] + '/docs/api'
        } else {
            // java 9 and above
            def mod = module ?: 'java.base'
            def ver = javaVersion.replaceAll(/-.*/, '').split(/\./)[0]
            "${(ea ? 'jdk' : 'en/java/javase/')}$ver/docs/api/$mod"
        }
    }

    protected boolean sendHEADRequest(URL url, String path = null) {
        IOException ioe
        // try at most 3 times
        for (int i = 0; i < 3; i++) {
            try {
                return doSendHEADRequest(url, path)
            } catch (SocketTimeoutException e) {
                ioe = e
            } catch (IOException e) {
                ioe = e
                break
            }
        }

        if (ioe) fail "Sending a HEAD request to $url failed (${ioe}). Please check your network settings."

        return false
    }

    private boolean doSendHEADRequest(URL url, String path = null) {
        HttpURLConnection conn = null
        try {
            conn = url.openConnection() as HttpURLConnection
            conn.requestMethod = 'HEAD'
            conn.connectTimeout = TIMEOUT_CONN
            conn.readTimeout = TIMEOUT_READ
            conn.instanceFollowRedirects = true
            def code = conn.responseCode
            // if not found, redirects to search page, which we don't count as successful
            // if no path given (legacy calls from third parties), treat all redirects as suspicious
            boolean successfulRedirect = path ? conn.URL.toString().endsWith(path) : url.toString() == conn.URL.toString()
            return code == 200 && successfulRedirect
        } finally {
            conn?.disconnect()
        }
    }
}

