/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2011, Red Hat, Inc., and individual contributors
 * as indicated by the @author tags. See the copyright.txt file in the
 * distribution for a full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software 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
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.jboss.as.webservices.deployer;

import java.io.IOException;
import java.net.InetAddress;
import java.net.URL;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.Semaphore;

import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.NameCallback;
import javax.security.auth.callback.PasswordCallback;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.sasl.RealmCallback;
import javax.security.sasl.RealmChoiceCallback;

import org.jboss.as.controller.PathAddress;
import org.jboss.as.controller.client.ModelControllerClient;
import org.jboss.as.controller.client.OperationBuilder;
import org.jboss.as.controller.client.helpers.standalone.DeploymentAction;
import org.jboss.as.controller.client.helpers.standalone.DeploymentPlan;
import org.jboss.as.controller.client.helpers.standalone.DeploymentPlanBuilder;
import org.jboss.as.controller.client.helpers.standalone.ServerDeploymentActionResult;
import org.jboss.as.controller.client.helpers.standalone.ServerDeploymentManager;
import org.jboss.as.controller.client.helpers.standalone.ServerDeploymentPlanResult;
import org.jboss.as.controller.operations.common.Util;
import org.jboss.as.security.Constants;
import org.jboss.dmr.ModelNode;
import org.jboss.logging.Logger;
import org.jboss.wsf.spi.deployer.Deployer;

import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.ADD;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.ALLOW_RESOURCE_SERVICE_RESTART;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.AUTHENTICATION;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.COMPOSITE;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.FAILURE_DESCRIPTION;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.NAME;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.OP;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.OPERATION_HEADERS;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.OP_ADDR;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.OUTCOME;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.READ_ATTRIBUTE_OPERATION;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.READ_RESOURCE_OPERATION;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.RELEASE_VERSION;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.REMOVE;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.REQUIRED;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.RESULT;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.ROLLBACK_ON_RUNTIME_FAILURE;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.SERVER_IDENTITY;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.SSL;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.STEPS;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.SUBSYSTEM;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.SUCCESS;
import static org.jboss.as.controller.descriptions.ModelDescriptionConstants.TRUSTSTORE;
import static org.jboss.as.security.Constants.CLASSIC;
import static org.jboss.as.security.Constants.CODE;
import static org.jboss.as.security.Constants.FLAG;
import static org.jboss.as.security.Constants.AUTH_MODULE;
import static org.jboss.as.security.Constants.JASPI;
import static org.jboss.as.security.Constants.LOGIN_MODULE;
import static org.jboss.as.security.Constants.LOGIN_MODULE_STACK;
import static org.jboss.as.security.Constants.LOGIN_MODULE_STACK_REF;
import static org.jboss.as.security.Constants.MODULE_OPTIONS;
import static org.jboss.as.security.Constants.SECURITY_DOMAIN;

/**
 * Remote deployer that uses AS7 client deployment API.
 *
 * @author <a href="mailto:ropalka@redhat.com">Richard Opalka</a>
 * @author <a href="mailto:alessio.soldano@jboss.com">Alessio Soldano</a>
 * @author <a href="mailto:ema@redhat.com">Jim Ma</a>
 */
public final class RemoteDeployer implements Deployer {

    private static final Logger LOGGER = Logger.getLogger(RemoteDeployer.class);
    private static final int DEFAULT_PORT = 9990;
    private static final String JBWS_DEPLOYER_HOST = "jbossws.deployer.host";
    private static final String JBWS_DEPLOYER_PORT = "jbossws.deployer.port";
    private static final String JBWS_DEPLOYER_PROTOCOL = "jbossws.deployer.protocol";
    private static final String JBWS_DEPLOYER_AUTH_USER = "jbossws.deployer.authentication.username";
    private static final String JBWS_DEPLOYER_AUTH_PWD = "jbossws.deployer.authentication.password";
    private static final String JBWS_DEPLOYER_HTTPS_LISTENER_NAME = "jbws-test-https-listener";
    private static final String JBWS_DEPLOYER_HTTPS_LISTENER_REALM_NAME = "jbws-test-https-realm";
    private static final CallbackHandler callbackHandler = getCallbackHandler();
    private static final int TIMEOUT = 60000;
    private static InetAddress address;
    private static Integer port;
    private static String protocol;
    private final Map<URL, String> url2Id = new HashMap<URL, String>();
    private final Map<String, Integer> securityDomainUsers = new HashMap<String, Integer>(1);
    private final Map<String, Integer> archiveCounters = new HashMap<String, Integer>();
    private final Semaphore httpsConnSemaphore = new Semaphore(1);

    private static final String SERVER_IDENTITY_SSL = SERVER_IDENTITY + "." + SSL + ".";
    private static final String AUTHENTICATION_TRUSTORE = AUTHENTICATION + "." + TRUSTSTORE + ".";

    static {
        try {
            final String host = System.getProperty(JBWS_DEPLOYER_HOST);
            address = host != null ? InetAddress.getByName(host) : InetAddress.getByName("localhost");
            port = Integer.getInteger(JBWS_DEPLOYER_PORT, DEFAULT_PORT);
            protocol = System.getProperty(JBWS_DEPLOYER_PROTOCOL, "http-remoting");
        } catch (final IOException e) {
            LOGGER.fatal(e.getMessage(), e);
            System.exit(1);
        }
    }

    @Override
    public void deploy(final URL archiveURL) throws Exception {
        synchronized (archiveCounters) {
            String k = archiveURL.toString();
            if (archiveCounters.containsKey(k)) {
                int count = archiveCounters.get(k);
                archiveCounters.put(k, (count + 1));
                return;
            } else {
                archiveCounters.put(k, 1);
            }
            final ModelControllerClient client = newModelControllerClient();
            final ServerDeploymentManager deploymentManager = newDeploymentManager(client);
            final DeploymentPlanBuilder builder = deploymentManager.newDeploymentPlan().add(archiveURL).andDeploy();
            final DeploymentPlan plan = builder.build();
            final DeploymentAction deployAction = builder.getLastAction();
            final String uniqueId = deployAction.getDeploymentUnitUniqueName();
            executeDeploymentPlan(plan, deployAction, client, deploymentManager);
            url2Id.put(archiveURL, uniqueId);
        }
    }

    @Override
    public void undeploy(final URL archiveURL) throws Exception {
        synchronized (archiveCounters) {
            String k = archiveURL.toString();
            if (archiveCounters.containsKey(k)) {
                int count = archiveCounters.get(k);
                if (count > 1) {
                    archiveCounters.put(k, (count - 1));
                    return;
                } else {
                    archiveCounters.remove(k);
                }
            } else {
                LOGGER.warn("Trying to undeploy archive " + archiveURL + " which is not currently deployed!");
                return;
            }

            final String uniqueName = url2Id.get(archiveURL);
            if (uniqueName != null) {
                final ModelControllerClient client = newModelControllerClient();
                final ServerDeploymentManager deploymentManager = newDeploymentManager(client);
                final DeploymentPlanBuilder builder = deploymentManager.newDeploymentPlan();
                final DeploymentPlan plan = builder.undeploy(uniqueName).remove(uniqueName).build();
                final DeploymentAction deployAction = builder.getLastAction();
                try {
                    executeDeploymentPlan(plan, deployAction, client, deploymentManager);
                } finally {
                    url2Id.remove(archiveURL);
                }
            }
        }
    }

    private void executeDeploymentPlan(final DeploymentPlan plan, final DeploymentAction deployAction,
            final ModelControllerClient client, final ServerDeploymentManager deploymentManager) throws Exception {
        try {
            final ServerDeploymentPlanResult planResult = deploymentManager.execute(plan).get();

            if (deployAction != null) {
                final ServerDeploymentActionResult actionResult = planResult.getDeploymentActionResult(deployAction.getId());
                if (actionResult != null) {
                    final Exception deploymentException = (Exception) actionResult.getDeploymentException();
                    if (deploymentException != null)
                        throw deploymentException;
                }
            }
        } catch (final Exception e) {
            LOGGER.fatal(e.getMessage(), e);
            throw e;
        } finally {
            client.close();
            deploymentManager.close();
        }
    }

    public String getServerVersion() throws Exception {
        final ModelNode request = new ModelNode();
        request.get(OP).set(READ_ATTRIBUTE_OPERATION);
        request.get(OP_ADDR).setEmptyList();
        request.get(NAME).set(RELEASE_VERSION);

        final ModelNode response = applyUpdate(request);
        return response.get(RESULT).asString();
    }

    @Override
    public void addSecurityDomain(String name, Map<String, String> authenticationOptions) throws Exception {
        synchronized (securityDomainUsers) {
            if (securityDomainUsers.containsKey(name)) {
                int count = securityDomainUsers.get(name);
                securityDomainUsers.put(name, (count + 1));
                return;
            } else {
                securityDomainUsers.put(name, 1);
            }

            final List<ModelNode> updates = new ArrayList<ModelNode>();

            final ModelNode compositeOp = new ModelNode();
            compositeOp.get(OP).set(COMPOSITE);
            compositeOp.get(OP_ADDR).setEmptyList();
            compositeOp.get(OPERATION_HEADERS, ALLOW_RESOURCE_SERVICE_RESTART).set(true);

            ModelNode steps = compositeOp.get(STEPS);
            PathAddress address = PathAddress.pathAddress()
                    .append(SUBSYSTEM, "security")
                    .append(SECURITY_DOMAIN, name);
            steps.add(Util.createAddOperation(address));
            address = address.append(Constants.AUTHENTICATION, CLASSIC);
            steps.add(Util.createAddOperation(address));
            ModelNode loginModule = Util.createAddOperation(address.append(LOGIN_MODULE, "UsersRoles"));
            loginModule.get(CODE).set("UsersRoles");
            loginModule.get(FLAG).set(REQUIRED);
            loginModule.get(OPERATION_HEADERS).get(ALLOW_RESOURCE_SERVICE_RESTART).set(true);
            final ModelNode moduleOptions = loginModule.get(MODULE_OPTIONS);
            if (authenticationOptions != null) {
                for (final String k : authenticationOptions.keySet()) {
                    moduleOptions.add(k, authenticationOptions.get(k));
                }
            }
            steps.add(loginModule);
            updates.add(compositeOp);

            applyUpdates(updates);
        }
    }

    public void addJaspiSecurityDomain(String name, String loginModuleStackName, Map<String, String> loginModuleOptions,
          String authModuleName, Map<String, String> authModuleOptions) throws Exception {
      synchronized (securityDomainUsers) {
          if (securityDomainUsers.containsKey(name)) {
              int count = securityDomainUsers.get(name);
              securityDomainUsers.put(name, (count + 1));
              return;
          } else {
              securityDomainUsers.put(name, 1);
          }

          final List<ModelNode> updates = new ArrayList<ModelNode>();

          final ModelNode compositeOp = new ModelNode();
          compositeOp.get(OP).set(COMPOSITE);
          compositeOp.get(OP_ADDR).setEmptyList();
          compositeOp.get(OPERATION_HEADERS, ALLOW_RESOURCE_SERVICE_RESTART).set(true);

          ModelNode steps = compositeOp.get(STEPS);
          PathAddress address = PathAddress.pathAddress().append(SUBSYSTEM, "security").append(SECURITY_DOMAIN, name);
          steps.add(Util.createAddOperation(address));

          PathAddress parentAddress = address.append(AUTHENTICATION, JASPI);
          steps.add(Util.createAddOperation(parentAddress));

          // step 3
          PathAddress loignStackAddress = parentAddress.append(LOGIN_MODULE_STACK, loginModuleStackName);
          ModelNode loginStack = Util.createAddOperation(loignStackAddress);
          steps.add(loginStack);

          // step 4
          ModelNode loginModule = Util.createAddOperation(loignStackAddress.append(LOGIN_MODULE, "UsersRoles"));
          loginModule.get(CODE).set("UsersRoles");
          loginModule.get(FLAG).set(REQUIRED);
          loginModule.get(OPERATION_HEADERS).get(ALLOW_RESOURCE_SERVICE_RESTART).set(true);
          final ModelNode moduleOptions = loginModule.get(MODULE_OPTIONS);
          if (loginModuleOptions != null) {
              for (final String k : loginModuleOptions.keySet()) {
                  moduleOptions.add(k, loginModuleOptions.get(k));
              }
          }
          steps.add(loginModule);

          PathAddress authModule = parentAddress.append(AUTH_MODULE, authModuleName);
          ModelNode authModuleNode = Util.createAddOperation(authModule);
          authModuleNode.get(LOGIN_MODULE_STACK_REF).set(loginModuleStackName);
          authModuleNode.get(CODE).set(authModuleName);
          ModelNode options = authModuleNode.get(MODULE_OPTIONS);
          if (authModuleOptions != null) {
              for (final String k : authModuleOptions.keySet()) {
                  options.add(k, authModuleOptions.get(k));
              }
          }
          steps.add(authModuleNode);

          updates.add(compositeOp);

          applyUpdates(updates);
      }
  }

    @Override
    public void removeSecurityDomain(String name) throws Exception {
        synchronized (securityDomainUsers) {
            int count = securityDomainUsers.get(name);
            if (count > 1) {
                securityDomainUsers.put(name, (count - 1));
                return;
            } else {
                securityDomainUsers.remove(name);
            }

            final ModelNode op = new ModelNode();
            op.get(OP).set(REMOVE);
            op.get(OP_ADDR).add(SUBSYSTEM, "security");
            op.get(OP_ADDR).add(SECURITY_DOMAIN, name);
            op.get(OPERATION_HEADERS, ALLOW_RESOURCE_SERVICE_RESTART).set(true);
            op.get(OPERATION_HEADERS, ROLLBACK_ON_RUNTIME_FAILURE).set(false);

            applyUpdate(op);
        }
    }

    public void addHttpsConnector(Map<String, String> options) throws Exception {
        final Map<String, String> sslOptionsMap = new HashMap<String, String>();
        final Map<String, String> truststoreOptionsMap = new HashMap<String, String>();
        if (options != null) {
            for (final Entry<String, String> entry : options.entrySet()) {
                final String k = entry.getKey();
                if (k.startsWith(SERVER_IDENTITY_SSL)) {
                    final String key = k.substring(SERVER_IDENTITY_SSL.length());
                    sslOptionsMap.put(key, entry.getValue());
                } else if (k.startsWith(AUTHENTICATION_TRUSTORE)) {
                    final String key = k.substring(AUTHENTICATION_TRUSTORE.length());
                    truststoreOptionsMap.put(key, entry.getValue());
                }
            }
        }
        httpsConnSemaphore.acquire();
        try {
            addSecurityRealm(JBWS_DEPLOYER_HTTPS_LISTENER_REALM_NAME, sslOptionsMap, truststoreOptionsMap);
            final ModelNode composite = Util.getEmptyOperation(COMPOSITE, new ModelNode());
            final ModelNode steps = composite.get(STEPS);
            ModelNode op = createOpNode("subsystem=undertow/server=default-server/https-listener=" + JBWS_DEPLOYER_HTTPS_LISTENER_NAME, "add");
            op.get("socket-binding").set("https");
            op.get("security-realm").set(JBWS_DEPLOYER_HTTPS_LISTENER_REALM_NAME);
            final String verifyClient = "verify-client";
            if (options != null && options.containsKey(verifyClient)) {
                op.get(verifyClient).set(options.get(verifyClient));
            }
            steps.add(op);
            applyUpdate(composite);
        } catch (Exception e) {
            httpsConnSemaphore.release();
            throw e;
        }
    }

    private static void addSecurityRealm(String realm, Map<String, String> sslOptions, Map<String, String> truststoreOptions) throws Exception {
        final ModelNode composite = Util.getEmptyOperation(COMPOSITE, new ModelNode());
        final ModelNode steps = composite.get(STEPS);
        ModelNode op = createOpNode("core-service=management/security-realm=" + realm, ADD);
        steps.add(op);
        if (!sslOptions.isEmpty()) {
            ModelNode ssl = createOpNode("core-service=management/security-realm=" + realm + "/server-identity=ssl", ADD);
            for (final Entry<String, String> entry : sslOptions.entrySet()) {
                ssl.get(entry.getKey()).set(entry.getValue());
            }
            steps.add(ssl);
        }

        if (!truststoreOptions.isEmpty()) {
            ModelNode truststore = createOpNode("core-service=management/security-realm=" + realm + "/authentication=truststore", ADD);
            for (final Entry<String, String> entry : truststoreOptions.entrySet()) {
                truststore.get(entry.getKey()).set(entry.getValue());
            }
            steps.add(truststore);
        }
        applyUpdate(composite);
    }

    public void removeHttpsConnector() throws Exception {
        try {
            ModelNode op = createOpNode("subsystem=undertow/server=default-server/https-listener=" + JBWS_DEPLOYER_HTTPS_LISTENER_NAME, REMOVE);
            op.get(OPERATION_HEADERS, ALLOW_RESOURCE_SERVICE_RESTART).set(true);
            applyUpdate(op);
            op = createOpNode("core-service=management/security-realm=" + JBWS_DEPLOYER_HTTPS_LISTENER_REALM_NAME, REMOVE);
            op.get(OPERATION_HEADERS, ALLOW_RESOURCE_SERVICE_RESTART).set(true);
            applyUpdate(op);
        } finally {
            httpsConnSemaphore.release();
        }
    }

    public String setSystemProperty(String propName, String propValue) throws Exception {
        if (propName == null || propName.trim().length() == 0) {
            throw new IllegalArgumentException("Empty system property name specified!");
        }
        ModelNode op = createOpNode("system-property=" + propName, READ_RESOURCE_OPERATION);
        String previousValue = null;
        try {
            ModelNode response = applyUpdate(op);
            String rawResult = response.get(RESULT).asString();
            previousValue = rawResult.substring(13, rawResult.length() - 2); //{"value" => "xyz"}
        } catch (Exception e) {
            if (!e.getMessage().contains("WFLYCTL0216")) {
                throw e;
            }
        }
        if (previousValue != null) {
            op = createOpNode("system-property=" + propName, REMOVE);
            applyUpdate(op);
        }
        if (propValue != null) {
            op = createOpNode("system-property=" + propName, ADD);
            op.get("value").set(propValue);
            applyUpdate(op);
        }
        return previousValue;
    }

    private static void applyUpdates(final List<ModelNode> updates) throws Exception {
        for (final ModelNode update : updates) {
            applyUpdate(update);
        }
    }

    private static ModelNode applyUpdate(final ModelNode update) throws Exception {
        final ModelControllerClient client = newModelControllerClient();
        try {
            final ModelNode result = client.execute(new OperationBuilder(update).build());
            checkResult(result);
            return result;
        } finally {
            client.close();
        }
    }

    private static void checkResult(final ModelNode result) throws Exception {
        if (result.hasDefined(OUTCOME) && SUCCESS.equals(result.get(OUTCOME).asString())) {
            if (result.hasDefined(RESULT)) {
                LOGGER.info(result.get(RESULT));
            }
        } else if (result.hasDefined(FAILURE_DESCRIPTION)) {
            throw new Exception(result.get(FAILURE_DESCRIPTION).toString());
        } else {
            throw new Exception("Operation not successful; outcome = " + result.get(OUTCOME));
        }
    }

    private static CallbackHandler getCallbackHandler() {
        final String username = getSystemProperty(JBWS_DEPLOYER_AUTH_USER, null);
        if (username == null || ("${" + JBWS_DEPLOYER_AUTH_USER + "}").equals(username)) {
            return null;
        }
        String pwd = getSystemProperty(JBWS_DEPLOYER_AUTH_PWD, null);
        if (("${" + JBWS_DEPLOYER_AUTH_PWD + "}").equals(pwd)) {
            pwd = null;
        }
        final String password = pwd;
        return new CallbackHandler() {
            public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
                for (Callback current : callbacks) {
                    if (current instanceof NameCallback) {
                        NameCallback ncb = (NameCallback) current;
                        ncb.setName(username);
                    } else if (current instanceof PasswordCallback) {
                        PasswordCallback pcb = (PasswordCallback) current;
                        pcb.setPassword(password.toCharArray());
                    } else if (current instanceof RealmCallback) {
                        RealmCallback rcb = (RealmCallback) current;
                        rcb.setText(rcb.getDefaultText());
                    } else if (current instanceof RealmChoiceCallback) {
                        // Ignored but not rejected.
                    } else {
                        throw new UnsupportedCallbackException(current);
                    }
                }
            }
        };
    }

    public static ModelNode createOpNode(String address, String operation) {
        ModelNode op = new ModelNode();
        // set address
        ModelNode list = op.get("address").setEmptyList();
        if (address != null) {
            String[] pathSegments = address.split("/");
            for (String segment : pathSegments) {
                String[] elements = segment.split("=");
                list.add(elements[0], elements[1]);
            }
        }
        op.get("operation").set(operation);
        return op;
    }

    private static String getSystemProperty(final String name, final String defaultValue) {
        PrivilegedAction<String> action = new PrivilegedAction<String>() {
            public String run() {
                return System.getProperty(name, defaultValue);
            }
        };
        return AccessController.doPrivileged(action);
    }

    private static ModelControllerClient newModelControllerClient() throws Exception {
        return ModelControllerClient.Factory.create(protocol, address.getHostAddress(), port, callbackHandler, null, TIMEOUT);
    }

    private static ServerDeploymentManager newDeploymentManager(ModelControllerClient client) throws Exception {
        return ServerDeploymentManager.Factory.create(client);
    }
}
