/*
 * 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.openjpa.slice.jdbc;

import java.lang.reflect.Method;
import java.security.AccessController;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import javax.sql.DataSource;
import javax.sql.XADataSource;

import org.apache.openjpa.conf.OpenJPAConfiguration;
import org.apache.openjpa.jdbc.conf.JDBCConfiguration;
import org.apache.openjpa.jdbc.conf.JDBCConfigurationImpl;
import org.apache.openjpa.jdbc.schema.DataSourceFactory;
import org.apache.openjpa.lib.conf.BooleanValue;
import org.apache.openjpa.lib.conf.ConfigurationProvider;
import org.apache.openjpa.lib.conf.PluginValue;
import org.apache.openjpa.lib.conf.StringListValue;
import org.apache.openjpa.lib.conf.StringValue;
import org.apache.openjpa.lib.jdbc.DecoratingDataSource;
import org.apache.openjpa.lib.jdbc.DelegatingDataSource;
import org.apache.openjpa.lib.log.Log;
import org.apache.openjpa.lib.log.LogFactory;
import org.apache.openjpa.lib.util.J2DoPrivHelper;
import org.apache.openjpa.lib.util.Localizer;
import org.apache.openjpa.meta.ClassMetaData;
import org.apache.openjpa.meta.MetaDataRepository;
import org.apache.openjpa.slice.DistributedBrokerImpl;
import org.apache.openjpa.slice.DistributionPolicy;
import org.apache.openjpa.slice.FinderTargetPolicy;
import org.apache.openjpa.slice.ProductDerivation;
import org.apache.openjpa.slice.QueryTargetPolicy;
import org.apache.openjpa.slice.ReplicationPolicy;
import org.apache.openjpa.slice.Slice;
import org.apache.openjpa.util.UserException;

/**
 * A specialized configuration embodies a set of Slice configurations.
 * The original configuration properties are analyzed to create a set of
 * Slice specific properties with defaulting rules.
 *
 * @author Pinaki Poddar
 *
 */
public class DistributedJDBCConfigurationImpl extends JDBCConfigurationImpl
        implements DistributedJDBCConfiguration {

    private final List<Slice> _slices = new ArrayList<>();
    private Slice _master;

    private DistributedDataSource virtualDataSource;

    protected BooleanValue lenientPlugin;
    protected StringValue masterPlugin;
    protected StringListValue namesPlugin;
    public PluginValue distributionPolicyPlugin;
    public PluginValue replicationPolicyPlugin;
    public PluginValue queryTargetPolicyPlugin;
    public PluginValue finderTargetPolicyPlugin;
    public StringListValue replicatedTypesPlugin;

    private ReplicatedTypeRepository _replicationRepos;

    public static final String DOT = ".";
    public static final String REGEX_DOT = "\\.";
    public static final String PREFIX_SLICE = ProductDerivation.PREFIX_SLICE + DOT;
    public static final String PREFIX_OPENJPA = "openjpa.";
    private static Localizer _loc = Localizer.forPackage(DistributedJDBCConfigurationImpl.class);

    /**
     * Create a configuration and declare the plug-ins.
     */
    public DistributedJDBCConfigurationImpl() {
        super(true,   // load derivations
              false); // load globals
        brokerPlugin.setString(DistributedBrokerImpl.class.getName());

        distributionPolicyPlugin = addPlugin(PREFIX_SLICE + "DistributionPolicy", true);
        distributionPolicyPlugin.setAlias("random", DistributionPolicy.Default.class.getName());
        distributionPolicyPlugin.setDefault("random");
        distributionPolicyPlugin.setString("random");
        distributionPolicyPlugin.setDynamic(true);

        replicationPolicyPlugin = addPlugin(PREFIX_SLICE + "ReplicationPolicy", true);
        replicationPolicyPlugin.setAlias("all", ReplicationPolicy.Default.class.getName());
        replicationPolicyPlugin.setDefault("all");
        replicationPolicyPlugin.setString("all");
        replicationPolicyPlugin.setDynamic(true);

        queryTargetPolicyPlugin = addPlugin(PREFIX_SLICE + "QueryTargetPolicy", true);
        queryTargetPolicyPlugin.setDynamic(true);

        finderTargetPolicyPlugin = addPlugin(PREFIX_SLICE + "FinderTargetPolicy", true);
        finderTargetPolicyPlugin.setDynamic(true);

        replicatedTypesPlugin = new StringListValue(PREFIX_SLICE + "ReplicatedTypes");
        addValue(replicatedTypesPlugin);

        lenientPlugin = addBoolean(PREFIX_SLICE + "Lenient");
        lenientPlugin.setDefault("true");

        masterPlugin  = addString(PREFIX_SLICE + "Master");
        namesPlugin   = addStringList(PREFIX_SLICE + "Names");
    }

    /**
     * Configure itself as well as underlying slices.
     *
     */
    public DistributedJDBCConfigurationImpl(ConfigurationProvider cp) {
        this();
        cp.setInto(this);
        setDiagnosticContext(this);
    }

    private void setDiagnosticContext(OpenJPAConfiguration conf) {
        LogFactory logFactory = conf.getLogFactory();
        try {
            Method setter = AccessController.doPrivileged(J2DoPrivHelper.
                    getDeclaredMethodAction(logFactory.getClass(),
                    "setDiagnosticContext", new Class[]{String.class}));
            setter.invoke(logFactory, conf.getId());
        } catch (Throwable t) {
            // no contextual logging
        }
    }

    /**
     * Gets the name of the active slices.
     */
    @Override
    public List<String> getActiveSliceNames() {
        List<String> result = new ArrayList<>();
        for (Slice slice : _slices) {
           if (slice.isActive() && !result.contains(slice.getName()))
              result.add(slice.getName());
        }
        return result;
    }

    /**
     * Gets the name of the available slices.
     */
    @Override
    public List<String> getAvailableSliceNames() {
        List<String> result = new ArrayList<>();
        for (Slice slice : _slices)
            result.add(slice.getName());
        return result;
    }

    /**
     * Gets the slices of given status. Null returns all irrespective of status.
     */
    @Override
    public List<Slice> getSlices(Slice.Status...statuses) {
        if (statuses == null)
            return _slices == null ? Collections.EMPTY_LIST : Collections.unmodifiableList(_slices);
        List<Slice> result = new ArrayList<>();
        for (Slice slice:_slices) {
            for (Slice.Status status:statuses)
                if (slice.getStatus().equals(status))
                    result.add(slice);
        }
        return result;
    }


    @Override
    public Slice getSlice(String name) {
        return getSlice(name, false);
    }

    /**
     * Get the Slice of the given slice.
     *
     * @param mustExist if true an exception if raised if the given slice name
     * is not a valid slice.
     */
    public Slice getSlice(String name, boolean mustExist) {
        for (Slice slice : _slices)
            if (slice.getName().equals(name))
                return slice;
        if (mustExist) {
            throw new UserException(_loc.get("slice-not-found", name,
                    getActiveSliceNames()));
        }
        return null;
    }

    @Override
    public DistributionPolicy getDistributionPolicyInstance() {
        if (distributionPolicyPlugin.get() == null) {
            distributionPolicyPlugin.instantiate(DistributionPolicy.class,
                    this, true);
        }
        return (DistributionPolicy) distributionPolicyPlugin.get();
    }

    @Override
    public String getDistributionPolicy() {
        if (distributionPolicyPlugin.get() == null) {
            distributionPolicyPlugin.instantiate(DistributionPolicy.class,
                    this, true);
        }
        return distributionPolicyPlugin.getString();
    }

    @Override
    public void setDistributionPolicyInstance(DistributionPolicy policy) {
        distributionPolicyPlugin.set(policy);
    }

    @Override
    public void setDistributionPolicy(String policy) {
        distributionPolicyPlugin.setString(policy);
    }

    @Override
    public ReplicationPolicy getReplicationPolicyInstance() {
        if (replicationPolicyPlugin.get() == null) {
            replicationPolicyPlugin.instantiate(ReplicationPolicy.class,
                    this, true);
        }
        return (ReplicationPolicy) replicationPolicyPlugin.get();
    }

    @Override
    public String getReplicationPolicy() {
        if (replicationPolicyPlugin.get() == null) {
            replicationPolicyPlugin.instantiate(ReplicationPolicy.class,
                    this, true);
        }
        return replicationPolicyPlugin.getString();
    }

    @Override
    public void setReplicationPolicyInstance(ReplicationPolicy policy) {
        replicationPolicyPlugin.set(policy);
    }

    @Override
    public void setReplicationPolicy(String policy) {
        replicationPolicyPlugin.setString(policy);
    }

    @Override
    public QueryTargetPolicy getQueryTargetPolicyInstance() {
        if (queryTargetPolicyPlugin.get() == null) {
            queryTargetPolicyPlugin.instantiate(QueryTargetPolicy.class,
                    this, true);
        }
        return (QueryTargetPolicy) queryTargetPolicyPlugin.get();
    }

    @Override
    public String getQueryTargetPolicy() {
        if (queryTargetPolicyPlugin.get() == null) {
            queryTargetPolicyPlugin.instantiate(QueryTargetPolicy.class,
                    this, true);
        }
        return queryTargetPolicyPlugin.getString();
    }

    @Override
    public void setQueryTargetPolicyInstance(QueryTargetPolicy policy) {
        queryTargetPolicyPlugin.set(policy);
    }

    @Override
    public void setQueryTargetPolicy(String policy) {
        queryTargetPolicyPlugin.setString(policy);
    }

    @Override
    public FinderTargetPolicy getFinderTargetPolicyInstance() {
        if (finderTargetPolicyPlugin.get() == null) {
            finderTargetPolicyPlugin.instantiate(FinderTargetPolicy.class,
                    this, true);
        }
        return (FinderTargetPolicy) finderTargetPolicyPlugin.get();
    }

    @Override
    public String getFinderTargetPolicy() {
        if (finderTargetPolicyPlugin.get() == null) {
            finderTargetPolicyPlugin.instantiate(FinderTargetPolicy.class,
                    this, true);
        }
        return finderTargetPolicyPlugin.getString();
    }

    @Override
    public void setFinderTargetPolicyInstance(FinderTargetPolicy policy) {
        finderTargetPolicyPlugin.set(policy);
    }

    @Override
    public void setFinderTargetPolicy(String policy) {
        finderTargetPolicyPlugin.setString(policy);
    }

    @Override
    public DistributedDataSource getConnectionFactory() {
        if (virtualDataSource == null) {
            virtualDataSource = createDistributedDataStore();
            DataSourceFactory.installDBDictionary(
                getDBDictionaryInstance(), virtualDataSource, this, false);
        }
        return virtualDataSource;
    }

    public boolean isLenient() {
        return lenientPlugin.get();
    }

    public void setLenient(boolean lenient) {
        lenientPlugin.set(lenient);
    }

    public void setMaster(String master) {
        masterPlugin.set(master);
    }

    /**
     * Gets the master slice.
     */
    @Override
    public Slice getMasterSlice() {
        if (_master == null) {
            String value = masterPlugin.get();
            if (value == null) {
                _master = _slices.get(0);
            } else {
                _master = getSlice(value, true);
            }
        }
        return _master;
    }

    /**
     * Create a virtual DistributedDataSource as a composite of individual
     * slices as per configuration, optionally ignoring slices that can not be
     * connected.
     */
    private DistributedDataSource createDistributedDataStore() {
        List<DataSource> dataSources = new ArrayList<>();
        boolean isXA = true;
        for (Slice slice : _slices) {
            try {
                DataSource ds = createDataSource(slice);
                dataSources.add(ds);
                isXA &= isXACompliant(ds);
            } catch (Throwable ex) {
                handleBadConnection(isLenient(), slice, ex);
            }
        }
        if (dataSources.isEmpty())
            throw new UserException(_loc.get("no-slice"));
        DistributedDataSource result = new DistributedDataSource(dataSources);
        return result;
    }

    DataSource createDataSource(Slice slice) throws Exception {
        JDBCConfiguration conf = (JDBCConfiguration)slice.getConfiguration();
        DataSource ds = (DataSource)conf.getConnectionFactory();
        if (ds == null) {
            Log log = conf.getConfigurationLog();
            String url = getConnectionInfo(conf);
            if (log.isInfoEnabled())
                log.info(_loc.get("slice-connect", slice, url));
            ds = DataSourceFactory.newDataSource(conf, false);
            DecoratingDataSource dds = new DecoratingDataSource(ds);
            ds = DataSourceFactory.installDBDictionary(
                    conf.getDBDictionaryInstance(), dds, conf, false);
        }
        verifyDataSource(slice, ds, conf);

        return ds;
    }

    String getConnectionInfo(OpenJPAConfiguration conf) {
        String result = conf.getConnectionURL();
        if (result == null) {
            result = conf.getConnectionDriverName();
            String props = conf.getConnectionProperties();
            if (props != null)
                result += "(" + props + ")";
        }
        return result;
    }

    boolean isXACompliant(DataSource ds) {
        if (ds instanceof DelegatingDataSource)
            return ((DelegatingDataSource) ds).getInnermostDelegate()
               instanceof XADataSource;
        return ds instanceof XADataSource;
    }

    /**
     * Verify that a connection can be established to the given slice. If
     * connection can not be established then slice is set to INACTIVE state.
     */
    private boolean verifyDataSource(Slice slice, DataSource ds,
    		JDBCConfiguration conf) {
        Connection con = null;
        try {
            con = ds.getConnection(conf.getConnectionUserName(),
            		conf.getConnectionPassword());
            slice.setStatus(Slice.Status.ACTIVE);
            if (con == null) {
                slice.setStatus(Slice.Status.INACTIVE);
                return false;
            }
            return true;
        } catch (SQLException ex) {
            slice.setStatus(Slice.Status.INACTIVE);
            return false;
        } finally {
            if (con != null)
                try {
                    con.close();
                } catch (SQLException ex) {
                    // ignore
                }
        }
    }

    /**
     * Either throw a user exception or add the configuration to the given list,
     * based on <code>isLenient</code>.
     */
    private void handleBadConnection(boolean isLenient, Slice slice, Throwable ex) {
        OpenJPAConfiguration conf = slice.getConfiguration();
        String url = conf.getConnectionURL();
        Log log = conf.getConfigurationLog();
        if (isLenient) {
            if (ex != null) {
                log.warn(_loc.get("slice-connect-known-warn", slice, url, ex.getCause()));
            } else {
                log.warn(_loc.get("slice-connect-warn", slice, url));
            }
        } else if (ex != null) {
            throw new UserException(_loc.get("slice-connect-known-error", slice, url, ex), ex.getCause());
        } else {
            throw new UserException(_loc.get("slice-connect-error", slice, url));
        }
    }

    /**
     * Create a new Slice of given name and given properties.
     *
     * @param key name of the slice to be created
     * @param original a set of properties.
     * @return a newly configured slice
     */
    private Slice newSlice(String key, Map original) {
        JDBCConfiguration child = new JDBCConfigurationImpl();
        child.fromProperties(createSliceProperties(original, key));
        child.setId(getId()+DOT+key);
        setDiagnosticContext(child);
        child.setMappingDefaults(this.getMappingDefaultsInstance());
        child.setDataCacheManager(this.getDataCacheManagerInstance());
        child.setMetaDataRepository(this.getMetaDataRepositoryInstance());
        Slice slice = new Slice(key, child);
        Log log = getConfigurationLog();
        if (log.isTraceEnabled())
            log.trace(_loc.get("slice-configuration", key, child
                    .toProperties(false)));
        return slice;
    }

    /**
     * Finds the slices. If <code>openjpa.slice.Names</code> property is
     * specified then the slices are ordered in the way they are listed.
     * Otherwise scans all available slices by looking for property of the form
     * <code>openjpa.slice.XYZ.abc</code> where <code>XYZ</code> is the slice
     * identifier and <code>abc</code> is any openjpa property name. The slices
     * are then ordered alphabetically by their identifier.
     */
    private List<String> findSlices(Map p) {
        List<String> sliceNames = new ArrayList<>();

        Log log = getConfigurationLog();
        String key = namesPlugin.getProperty();
        boolean explicit = p.containsKey(key);
        if (explicit) {
            String[] values = p.get(key).toString().split("\\,");
            for (String name:values)
                if (!sliceNames.contains(name.trim()))
                    sliceNames.add(name.trim());
        } else {
            if (log.isWarnEnabled())
                log.warn(_loc.get("no-slice-names", key));
            sliceNames = scanForSliceNames(p);
            Collections.sort(sliceNames);
        }
        if (log.isInfoEnabled()) {
            log.info(_loc.get("slice-available", sliceNames));
        }
        return sliceNames;
    }

    /**
     * Scan the given map for slice-specific property of the form
     * <code>openjpa.slice.XYZ.abc</code> (while ignoring
     * <code>openjpa.slice.XYZ</code> as they refer to slice-wide property)
     * to determine the names of all available slices.
     */
    private List<String> scanForSliceNames(Map p) {
        List<String> sliceNames = new ArrayList<>();
        for (Object o : p.keySet()) {
            String key = o.toString();
            if (key.startsWith(PREFIX_SLICE) && getPartCount(key) > 3) {
                String sliceName =
                    chopTail(chopHead(o.toString(), PREFIX_SLICE), DOT);
                if (!sliceNames.contains(sliceName))
                    sliceNames.add(sliceName);
            }
        }
        return sliceNames;
    }

    private static int getPartCount(String s) {
        return (s == null) ? 0 : s.split(REGEX_DOT).length;
    }

    private static String chopHead(String s, String head) {
        if (s.startsWith(head))
            return s.substring(head.length());
        return s;
    }

    private static String chopTail(String s, String tail) {
        int i = s.lastIndexOf(tail);
        if (i == -1)
            return s;
        return s.substring(0, i);
    }

    /**
     * Creates given <code>slice</code> specific configuration properties from
     * given <code>original</code> key-value map. The rules are
     * <LI> if key begins with <code>"openjpa.slice.XXX."</code> where
     * <code>XXX</code> is the given slice name, then replace
     * <code>"openjpa.slice.XXX.</code> with <code>openjpa.</code>.
     * <LI>if key begins with <code>"openjpa.slice."</code> but not with
     * <code>"openjpa.slice.XXX."</code>, then ignore i.e. any property of other
     * slices or global slice property e.g.
     * <code>openjpa.slice.DistributionPolicy</code>
     * <li>if key starts with <code>"openjpa."</code> and a corresponding
     * <code>"openjpa.slice.XXX."</code> property does not exist, then use this as
     * default property
     * <li>property with any other prefix is simply copied
     *
     */
    Map createSliceProperties(Map original, String slice) {
        Map result = new Properties();
        String prefix = PREFIX_SLICE + slice + DOT;
        for (Object o : original.keySet()) {
            String key = o.toString();
            Object value = original.get(key);
            if (value == null)
                continue;
            if (key.startsWith(prefix)) {
                String newKey = PREFIX_OPENJPA + key.substring(prefix.length());
                result.put(newKey, value);
            } else if (key.startsWith(PREFIX_SLICE)) {
                // ignore keys that are in 'slice.' namespace but not this slice
            } else if (key.startsWith(PREFIX_OPENJPA)) {
                String newKey = prefix + key.substring(PREFIX_OPENJPA.length());
                if (!original.containsKey(newKey))
                    result.put(key, value);
            } else { // keys that are neither "openjpa" nor "slice" namespace
                result.put(key, value);
            }
        }
        return result;
    }

    Slice addSlice(String name, Map newProps) {
        String prefix = PREFIX_SLICE + DOT + name + DOT;
        for (Object key : newProps.keySet()) {
            if (!String.class.isInstance(key)
             && key.toString().startsWith(prefix))
                throw new UserException(_loc.get("slice-add-wrong-key", key));
        }
        Slice slice = getSlice(name);
        if (slice != null)
            throw new UserException(_loc.get("slice-exists", name));
        Map<String,String> original = super.toProperties(true);
        original.putAll(newProps);
         slice = newSlice(name, original);
        _slices.add(slice);
        try {
            getConnectionFactory().addDataSource(createDataSource(slice));
        } catch (Exception ex) {
            handleBadConnection(false, slice, ex);
            return null;
        }
        return slice;
    }

    /**
     * Given the properties, creates a set of individual configurations.
     */
    @Override
    public void fromProperties(Map original) {
        super.fromProperties(original);
        setDiagnosticContext(this);
        List<String> sliceNames = findSlices(original);
        for (String name : sliceNames) {
            Slice slice = newSlice(name, original);
            _slices.add(slice);
        }
    }

    @Override
    public DecoratingDataSource createConnectionFactory() {
        if (virtualDataSource == null) {
            virtualDataSource = createDistributedDataStore();
        }
        return virtualDataSource;
    }

    @Override
    public boolean isReplicated(Class<?> cls) {
        if (_replicationRepos == null) {
            _replicationRepos = new ReplicatedTypeRepository(getMetaDataRepositoryInstance(),
                    Arrays.asList(replicatedTypesPlugin.get()));
        }
        return _replicationRepos.contains(cls);
    }


    /**
     * A private repository of replicated types.
     *
     * @author Pinaki Poddar
     *
     */
    private static class ReplicatedTypeRepository {
        private Set<Class<?>> _replicatedTypes = new HashSet<>();
        private Set<Class<?>> _nonreplicatedTypes = new HashSet<>();


        List<String> names;
        MetaDataRepository repos;

        ReplicatedTypeRepository(MetaDataRepository repos, List<String> given) {
            names = given;
            this.repos = repos;
        }

        boolean contains(Class<?> cls) {
            if (_replicatedTypes.contains(cls))
                return true;
            if (_nonreplicatedTypes.contains(cls))
                return false;
            ClassMetaData meta = repos.getMetaData(cls, null, false);
            if (meta == null) {
                _nonreplicatedTypes.add(cls);
                return false;
            }
            boolean replicated = names.contains(meta.getDescribedType().getName());
            if (replicated) {
                _replicatedTypes.add(cls);
            } else {
                _nonreplicatedTypes.add(cls);
            }
            return replicated;
        }
    }
}
