/*
 * 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.dubbo.metadata;

import org.apache.dubbo.common.URL;
import org.apache.dubbo.common.compiler.support.ClassUtils;
import org.apache.dubbo.common.extension.ExtensionLoader;
import org.apache.dubbo.common.url.component.URLParam;
import org.apache.dubbo.common.utils.ArrayUtils;
import org.apache.dubbo.common.utils.CollectionUtils;
import org.apache.dubbo.common.utils.StringUtils;

import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;

import static org.apache.dubbo.common.constants.CommonConstants.DOT_SEPARATOR;
import static org.apache.dubbo.common.constants.CommonConstants.GROUP_CHAR_SEPARATOR;
import static org.apache.dubbo.common.constants.CommonConstants.METHODS_KEY;
import static org.apache.dubbo.common.constants.CommonConstants.MONITOR_KEY;
import static org.apache.dubbo.common.constants.CommonConstants.PID_KEY;
import static org.apache.dubbo.common.constants.CommonConstants.TIMESTAMP_KEY;
import static org.apache.dubbo.common.constants.FilterConstants.VALIDATION_KEY;
import static org.apache.dubbo.common.constants.QosConstants.ACCEPT_FOREIGN_IP;
import static org.apache.dubbo.common.constants.QosConstants.QOS_ENABLE;
import static org.apache.dubbo.common.constants.QosConstants.QOS_HOST;
import static org.apache.dubbo.common.constants.QosConstants.QOS_PORT;
import static org.apache.dubbo.remoting.Constants.BIND_IP_KEY;
import static org.apache.dubbo.remoting.Constants.BIND_PORT_KEY;
import static org.apache.dubbo.rpc.Constants.INTERFACES;

public class MetadataInfo implements Serializable {
    public static final MetadataInfo EMPTY = new MetadataInfo();

    private String app;
    private String revision;
    private Map<String, ServiceInfo> services;

    // used at runtime
    private transient Map<String, String> extendParams;
    private transient AtomicBoolean reported = new AtomicBoolean(false);

    public MetadataInfo() {
        this(null);
    }

    public MetadataInfo(String app) {
        this(app, null, null);
    }

    public MetadataInfo(String app, String revision, Map<String, ServiceInfo> services) {
        this.app = app;
        this.revision = revision;
        this.services = services == null ? new HashMap<>() : services;
        this.extendParams = new HashMap<>();
    }

    public void addService(ServiceInfo serviceInfo) {
        if (serviceInfo == null) {
            return;
        }
        this.services.put(serviceInfo.getMatchKey(), serviceInfo);
        markChanged();
    }

    public void removeService(ServiceInfo serviceInfo) {
        if (serviceInfo == null) {
            return;
        }
        this.services.remove(serviceInfo.getMatchKey());
        markChanged();
    }

    public void removeService(String key) {
        if (key == null) {
            return;
        }
        this.services.remove(key);
        markChanged();
    }

    public String calAndGetRevision() {
        if (revision != null && hasReported()) {
            return revision;
        }

        if (CollectionUtils.isEmptyMap(services)) {
            this.revision = RevisionResolver.getEmptyRevision(app);
        } else {
            StringBuilder sb = new StringBuilder();
            sb.append(app);
            for (Map.Entry<String, ServiceInfo> entry : new TreeMap<>(services).entrySet()) {
                sb.append(entry.getValue().toDescString());
            }
            this.revision = RevisionResolver.calRevision(sb.toString());
        }
        return revision;
    }

    public void setRevision(String revision) {
        this.revision = revision;
    }

    public boolean hasReported() {
        return reported.get();
    }

    public void markReported() {
        reported.compareAndSet(false, true);
    }

    public void markChanged() {
        reported.compareAndSet(true, false);
    }

    public String getApp() {
        return app;
    }

    public void setApp(String app) {
        this.app = app;
    }

    public Map<String, ServiceInfo> getServices() {
        return services;
    }

    public void setServices(Map<String, ServiceInfo> services) {
        this.services = services;
    }

    public ServiceInfo getServiceInfo(String serviceKey) {
        return services.get(serviceKey);
    }

    public Map<String, String> getExtendParams() {
        return extendParams;
    }

    public String getParameter(String key, String serviceKey) {
        ServiceInfo serviceInfo = services.get(serviceKey);
        if (serviceInfo == null) {
            return null;
        }
        return serviceInfo.getParameter(key);
    }

    public Map<String, String> getParameters(String serviceKey) {
        ServiceInfo serviceInfo = services.get(serviceKey);
        if (serviceInfo == null) {
            return Collections.emptyMap();
        }
        return serviceInfo.getAllParams();
    }

    public String getServiceString(String protocolServiceKey) {
        if (protocolServiceKey == null) {
            return null;
        }

        ServiceInfo serviceInfo = services.get(protocolServiceKey);
        if (serviceInfo == null) {
            return null;
        }
        return serviceInfo.toString();
    }

    @Override
    public String toString() {
        return "metadata{" +
                "app='" + app + "'," +
                "revision='" + revision + "'," +
                "services=" + services +
                "}";
    }

    public static class ServiceInfo implements Serializable {
        private static ExtensionLoader<MetadataParamsFilter> loader = ExtensionLoader.getExtensionLoader(MetadataParamsFilter.class);
        private String name;
        private String group;
        private String version;
        private String protocol;
        private String path; // most of the time, path is the same with the interface name.
        private Map<String, String> params;

        // params configured on consumer side,
        private transient Map<String, String> consumerParams;
        // cached method params
        private transient Map<String, Map<String, String>> methodParams;
        private transient Map<String, Map<String, String>> consumerMethodParams;
        // cached numbers
        private transient Map<String, Number> numbers;
        private transient Map<String, Map<String, Number>> methodNumbers;
        // service + group + version
        private transient String serviceKey;
        // service + group + version + protocol
        private transient String matchKey;

        private transient URL url;

        private final static String[] KEYS_TO_REMOVE = {MONITOR_KEY, BIND_IP_KEY, BIND_PORT_KEY, QOS_ENABLE,
            QOS_HOST, QOS_PORT, ACCEPT_FOREIGN_IP, VALIDATION_KEY, INTERFACES, PID_KEY, TIMESTAMP_KEY};

        public ServiceInfo() {
        }

        public ServiceInfo(URL url) {
            this(url.getServiceInterface(), url.getGroup(), url.getVersion(), url.getProtocol(), url.getPath(), null);

            this.url = url;
            Map<String, String> params = new HashMap<>();
            List<MetadataParamsFilter> filters = loader.getActivateExtension(url, "params-filter");
            if (filters.size() == 0) {
                params.putAll(url.getParameters());
                for (String key : KEYS_TO_REMOVE) {
                    params.remove(key);
                }
            }
            for (MetadataParamsFilter filter : filters) {
                String[] paramsIncluded = filter.serviceParamsIncluded();
                if (ArrayUtils.isNotEmpty(paramsIncluded)) {
                    for (String p : paramsIncluded) {
                        String value = url.getParameter(p);
                        if (StringUtils.isNotEmpty(value) && params.get(p) == null) {
                            params.put(p, value);
                        }
                        String[] methods = url.getParameter(METHODS_KEY, (String[]) null);
                        if (methods != null) {
                            for (String method : methods) {
                                String mValue = url.getMethodParameterStrict(method, p);
                                if (StringUtils.isNotEmpty(mValue)) {
                                    params.put(method + DOT_SEPARATOR + p, mValue);
                                }
                            }
                        }
                    }
                }
            }
            this.params = params;
        }

        public ServiceInfo(String name, String group, String version, String protocol, String path, Map<String, String> params) {
            this.name = name;
            this.group = group;
            this.version = version;
            this.protocol = protocol;
            this.path = path;
            this.params = params == null ? new HashMap<>() : params;

            this.serviceKey = URL.buildKey(name, group, version);
            this.matchKey = buildMatchKey();
        }

        public String getMatchKey() {
            if (matchKey != null) {
                return matchKey;
            }
            buildMatchKey();
            return matchKey;
        }

        private String buildMatchKey() {
            matchKey = getServiceKey();
            if (StringUtils.isNotEmpty(protocol)) {
                matchKey = getServiceKey() + GROUP_CHAR_SEPARATOR + protocol;
            }
            return matchKey;
        }

        public String getServiceKey() {
            if (serviceKey != null) {
                return serviceKey;
            }
            this.serviceKey = URL.buildKey(name, group, version);
            return serviceKey;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public String getGroup() {
            return group;
        }

        public void setGroup(String group) {
            this.group = group;
        }

        public String getVersion() {
            return version;
        }

        public void setVersion(String version) {
            this.version = version;
        }

        public String getPath() {
            return path;
        }

        public void setPath(String path) {
            this.path = path;
        }

        public String getProtocol() {
            return protocol;
        }

        public void setProtocol(String protocol) {
            this.protocol = protocol;
        }

        public Map<String, String> getParams() {
            if (params == null) {
                return Collections.emptyMap();
            }
            return params;
        }

        public void setParams(Map<String, String> params) {
            this.params = params;
        }

        public Map<String, String> getAllParams() {
            if (consumerParams != null) {
                Map<String, String> allParams = new HashMap<>((int) ((params.size() + consumerParams.size()) / 0.75f + 1));
                allParams.putAll(params);
                allParams.putAll(consumerParams);
                return allParams;
            }
            return params;
        }

        public String getParameter(String key) {
            if (consumerParams != null) {
                String value = consumerParams.get(key);
                if (value != null) {
                    return value;
                }
            }
            return params.get(key);
        }

        public String getMethodParameter(String method, String key, String defaultValue) {
            if (methodParams == null) {
                methodParams = URLParam.initMethodParameters(params);
                consumerMethodParams = URLParam.initMethodParameters(consumerParams);
            }

            String value = getMethodParameter(method, key, consumerMethodParams);
            if (value != null) {
                return value;
            }
            value = getMethodParameter(method, key, methodParams);
            return value == null ? defaultValue : value;
        }

        private String getMethodParameter(String method, String key, Map<String, Map<String, String>> map) {
            String value = null;
            if (map != null) {
                Map<String, String> keyMap = map.get(method);
                if (keyMap != null) {
                    value = keyMap.get(key);
                }
            }
            return value;
        }

        public boolean hasMethodParameter(String method, String key) {
            String value = this.getMethodParameter(method, key, (String) null);
            return StringUtils.isNotEmpty(value);
        }

        public boolean hasMethodParameter(String method) {
            if (methodParams == null) {
                methodParams = URLParam.initMethodParameters(params);
                consumerMethodParams = URLParam.initMethodParameters(consumerParams);
            }

            return consumerMethodParams.containsKey(method) || methodParams.containsKey(method);
        }

        public String toDescString() {
            return this.getMatchKey() + getMethodSignaturesString() + new TreeMap<>(getParams());
        }

        private String getMethodSignaturesString() {
            SortedSet<String> methodStrings = new TreeSet();

            Method[] methods = ClassUtils.forName(name).getMethods();
            for (Method method : methods) {
                methodStrings.add(method.toString());
            }
            return methodStrings.toString();
        }

        public void addParameter(String key, String value) {
            if (consumerParams != null) {
                this.consumerParams.put(key, value);
            }
        }

        public void addParameterIfAbsent(String key, String value) {
            if (consumerParams != null) {
                this.consumerParams.putIfAbsent(key, value);
            }
        }

        public void addConsumerParams(Map<String, String> params) {
            // copy once for one service subscription
            if (consumerParams == null) {
                consumerParams = new HashMap<>(params);
            }
        }

        public Map<String, Number> getNumbers() {
            // concurrent initialization is tolerant
            if (numbers == null) {
                numbers = new ConcurrentHashMap<>();
            }
            return numbers;
        }

        public Map<String, Map<String, Number>> getMethodNumbers() {
            if (methodNumbers == null) { // concurrent initialization is tolerant
                methodNumbers = new ConcurrentHashMap<>();
            }
            return methodNumbers;
        }

        public URL getUrl() {
            return url;
        }

        @Override
        public boolean equals(Object obj) {
            if (obj == null) {
                return false;
            }
            if (!(obj instanceof ServiceInfo)) {
                return false;
            }

            ServiceInfo serviceInfo = (ServiceInfo) obj;
//            return this.getMatchKey().equals(serviceInfo.getMatchKey()) && this.getParams().equals(serviceInfo.getParams());
            // Please check ServiceInstancesChangedListener.localServiceToRevisions before changing this behaviour.
            // equals to Objects.equals(this.getMatchKey(), serviceInfo.getMatchKey()), but match key will not get initialized
            // on json deserialization.
            return Objects.equals(this.getVersion(), serviceInfo.getVersion())
                && Objects.equals(this.getGroup(), serviceInfo.getGroup())
                && Objects.equals(this.getName(), serviceInfo.getName())
                && Objects.equals(this.getProtocol(), serviceInfo.getProtocol());
        }

        @Override
        public int hashCode() {
//            return Objects.hash(getMatchKey(), getParams());
            return Objects.hash(getVersion(), getGroup(), getName(), getProtocol());

        }

        @Override
        public String toString() {
            return "service{" +
                    "name='" + name + "'," +
                    "group='" + group + "'," +
                    "version='" + version + "'," +
                    "protocol='" + protocol + "'," +
                    "params=" + params + "," +
                    "consumerParams=" + consumerParams +
                    "}";
        }
    }
}
