/*
 *  Copyright (c) 2021, WSO2 Inc. (http://www.wso2.org) All Rights Reserved.
 *
 *  WSO2 Inc. 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.synapse.api;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.SwaggerParseResult;
import org.apache.axis2.Constants;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpStatus;
import org.apache.synapse.MessageContext;
import org.apache.synapse.SynapseConstants;
import org.apache.synapse.SynapseException;
import org.apache.synapse.api.version.ContextVersionStrategy;
import org.apache.synapse.api.version.DefaultStrategy;
import org.apache.synapse.api.version.URLBasedVersionStrategy;
import org.apache.synapse.core.axis2.Axis2MessageContext;
import org.apache.synapse.rest.RESTConstants;
import org.apache.synapse.api.dispatch.DefaultDispatcher;
import org.apache.synapse.api.dispatch.RESTDispatcher;
import org.apache.synapse.api.dispatch.URITemplateBasedDispatcher;
import org.apache.synapse.api.dispatch.URLMappingBasedDispatcher;
import org.apache.synapse.transport.nhttp.NhttpConstants;

import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class ApiUtils {

    private static final Log log = LogFactory.getLog(ApiUtils.class);

    private static final List<RESTDispatcher> dispatchers = new ArrayList<RESTDispatcher>();

    static {
        dispatchers.add(new URLMappingBasedDispatcher());
        dispatchers.add(new URITemplateBasedDispatcher());
        dispatchers.add(new DefaultDispatcher());
    }

    public static String trimSlashes(String url) {
        if (url.startsWith("/")) {
            url = url.substring(1);
        }
        if (url.startsWith("/")) {
            url = url.substring(0, url.length() - 1);
        }
        return url;
    }

    public static String trimTrailingSlashes(String url) {
        while (url.endsWith("/")) {
            url = url.substring(0, url.length() - 1);
        }
        return url;
    }

    public static String getFullRequestPath(MessageContext synCtx) {
        Object obj = synCtx.getProperty(RESTConstants.REST_FULL_REQUEST_PATH);
        if (obj != null) {
            return (String) obj;
        }
        org.apache.axis2.context.MessageContext msgCtx = ((Axis2MessageContext) synCtx).
                getAxis2MessageContext();
        String url = (String) msgCtx.getProperty(Constants.Configuration.TRANSPORT_IN_URL);
        if (url == null) {
            url = (String) synCtx.getProperty(NhttpConstants.SERVICE_PREFIX);
        }

        if (url.startsWith("http://") || url.startsWith("https://")) {
            try {
                url = new URL(url).getFile();
            } catch (MalformedURLException e) {
                handleException("Request URL: " + url + " is malformed", e);
            }
        } else if (url.startsWith("ws://") || url.startsWith("wss://")) {
            try {
                URI uri = new URI(url);
                String query = uri.getQuery();
                if (query == null) {
                    url = uri.getPath();
                } else {
                    url = uri.getPath() + "?" + query;
                }
            } catch (URISyntaxException e) {
                handleException("Request URL: " + url + " is malformed", e);
            }
        }
        synCtx.setProperty(RESTConstants.REST_FULL_REQUEST_PATH, url);
        return url;
    }

    /**
     * Populate Message context properties for the query parameters extracted from the url
     *
     * @param synCtx MessageContext of the request
     */
    public static void populateQueryParamsToMessageContext(MessageContext synCtx) {

        String path = getFullRequestPath(synCtx);
        String method = (String) synCtx.getProperty(RESTConstants.REST_METHOD);

        int queryIndex = path.indexOf('?');
        if (queryIndex != -1) {
            String query = path.substring(queryIndex + 1);
            String[] entries = query.split(RESTConstants.QUERY_PARAM_DELIMITER);
            String name = null;
            String value;
            for (String entry : entries) {
                int index = entry.indexOf('=');
                if (index != -1) {
                    try {
                        name = entry.substring(0, index);
                        value = URLDecoder.decode(entry.substring(index + 1),
                                RESTConstants.DEFAULT_ENCODING);
                        synCtx.setProperty(RESTConstants.REST_QUERY_PARAM_PREFIX + name, value);
                    } catch (UnsupportedEncodingException uee) {
                        handleException("Error processing " + method + " request for : " + path, uee);
                    } catch (IllegalArgumentException e) {
                        String errorMessage = "Error processing " + method + " request for : " + path
                                + " due to an error in the request sent by the client";
                        synCtx.setProperty(SynapseConstants.ERROR_CODE, HttpStatus.SC_BAD_REQUEST);
                        synCtx.setProperty(SynapseConstants.ERROR_MESSAGE, errorMessage);
                        org.apache.axis2.context.MessageContext inAxisMsgCtx =
                                ((Axis2MessageContext) synCtx).getAxis2MessageContext();
                        inAxisMsgCtx.setProperty(SynapseConstants.HTTP_SC, HttpStatus.SC_BAD_REQUEST);
                        handleException(errorMessage, e);
                    }
                } else {
                    // If '=' sign isn't present in the entry means that the '&' character is part of
                    // the query parameter value. If so query parameter value should be updated appending
                    // the remaining characters.
                    String existingValue = (String) synCtx.getProperty(RESTConstants.REST_QUERY_PARAM_PREFIX + name);
                    value = RESTConstants.QUERY_PARAM_DELIMITER + entry;
                    synCtx.setProperty(RESTConstants.REST_QUERY_PARAM_PREFIX + name, existingValue + value);
                }
            }
        }
    }

    public static String getSubRequestPath(MessageContext synCtx) {
        return (String) synCtx.getProperty(RESTConstants.REST_SUB_REQUEST_PATH);
    }

    /**
     * Checks whether the provided resource is capable of processing the message from the provided message context.
     * The resource becomes capable to do this when the it contains either the name of the api caller,
     * or {@value ApiConstants#DEFAULT_BINDING_ENDPOINT_NAME}, in its binds-to.
     *
     * @param resource  Resource object
     * @param synCtx    MessageContext object
     * @return          Whether the provided resource is bound to the provided message context
     */
    public static boolean isBound(Resource resource, MessageContext synCtx) {
        Collection<String> bindings = resource.getBindsTo();
        Object apiCaller = synCtx.getProperty(ApiConstants.API_CALLER);
        if (apiCaller != null) {
            return bindings.contains(apiCaller.toString());
        }
        return bindings.contains(ApiConstants.DEFAULT_BINDING_ENDPOINT_NAME);
    }

    public static Set<Resource> getAcceptableResources(Map<String, Resource> resources, MessageContext synCtx) {
        List<Resource> acceptableResourcesList = new LinkedList<>();
        List<Resource> optionsResourcesList = new LinkedList<>();
        for (Resource r : resources.values()) {
            if (isBound(r, synCtx) && r.canProcess(synCtx)) {
                List<String> methods = Arrays.asList(r.getMethods());
                if (methods.size() == 1 && methods.contains(RESTConstants.METHOD_OPTIONS)) {
                    optionsResourcesList.add(r);
                } else {
                    acceptableResourcesList.add(r);
                }
            }
        }
        LinkedHashSet<Resource> result = new LinkedHashSet<>();
        result.addAll(optionsResourcesList);
        result.addAll(acceptableResourcesList);
        return result;
    }

    /**
     * This method is used to locate the specific API that has been invoked from the collection of all APIs.
     *
     * @param synCtx MessageContext of the request
     * @return Selected API
     */
    public static API getSelectedAPI(MessageContext synCtx) {
        //getting the API collection from the synapse configuration to find the invoked API
        Collection<API> apiSet = synCtx.getEnvironment().getSynapseConfiguration().getAPIs();
        //Since swapping elements are not possible with sets, Collection is converted to a List
        List<API> defaultStrategyApiSet = new ArrayList<API>(apiSet);
        //To avoid apiSet being modified concurrently
        List<API> duplicateApiSet = new ArrayList<>(apiSet);
        //identify the api using canProcess method
        for (API api : duplicateApiSet) {
            if (identifySelectedAPI(api, synCtx, defaultStrategyApiSet)) {
                return api;
            }
        }
        for (API api : defaultStrategyApiSet) {
            api.setLogSetterValue();
            if (api.canProcess(synCtx)) {
                if (log.isDebugEnabled()) {
                    log.debug("Located specific API: " + api.getName() + " for processing message");
                }
                return api;
            }
        }
        return null;
    }

    private static boolean identifySelectedAPI(API api, MessageContext synCtx, List defaultStrategyApiSet) {
        API defaultAPI = null;
        api.setLogSetterValue();
        if ("/".equals(api.getContext())) {
            defaultAPI = api;
        } else if (api.getVersionStrategy().getClass().getName().equals(DefaultStrategy.class.getName())) {
            //APIs whose VersionStrategy is bound to an instance of DefaultStrategy, should be skipped and processed at
            // last.Otherwise they will be always chosen to process the request without matching the version.
            defaultStrategyApiSet.add(api);
        } else if (api.getVersionStrategy().getClass().getName().equals(ContextVersionStrategy.class.getName())
                || api.getVersionStrategy().getClass().getName().equals(URLBasedVersionStrategy.class.getName())) {
            api.setLogSetterValue();
            if (api.canProcess(synCtx)) {
                if (log.isDebugEnabled()) {
                    log.debug("Located specific API: " + api.getName() + " for processing message");
                }
                return true;
            }
        } else if (api.canProcess(synCtx)) {
            if (log.isDebugEnabled()) {
                log.debug("Located specific API: " + api.getName() + " for processing message");
            }
            return true;
        }
        return false;
    }

    public static List<RESTDispatcher> getDispatchers() {
        return dispatchers;
    }

    private static void handleException(String msg, Throwable t) {
        log.error(msg, t);
        throw new SynapseException(msg, t);
    }

    /**
     * Identify the API by matching the context of the invoking api
     * with the path of each api in the api list.
     *
     * @param path    request path
     * @param context API context
     * @return true if the invoking api context matches with the path
     * and false if the two values don't match
     */
    public static boolean matchApiPath(String path, String context) {
        if (!path.startsWith(context + "/") && !path.startsWith(context + "?")
                && !context.equals(path) && !"/".equals(context)) {
            return false;
        }
        return true;
    }

    /**
     * Parses a raw Swagger/OpenAPI string definition and attaches the resulting model to the API object.
     * <p>
     * This method utilizes the {@code OpenAPIV3Parser} to convert a string-based definition
     * (JSON or YAML) into a structured {@link OpenAPI} object. If the parsing is successful,
     * the model is set on the provided {@code api} instance for subsequent authorization
     * and scope validation logic.
     * </p>
     *
     * @param api               The {@link API} metadata object to be updated with the parsed model.
     * @param swaggerDefinition The raw string containing the OpenAPI/Swagger specification.
     */
    public static void loadOpenAPIFromDefinition(API api, String swaggerDefinition) {

        SwaggerParseResult result = new OpenAPIV3Parser().readContents(swaggerDefinition, null, null);

        // Get the parsed OpenAPI object
        OpenAPI openAPI = result.getOpenAPI();

        if (openAPI != null) {
            api.setOpenAPI(openAPI);
        } else if (log.isDebugEnabled()) {
            log.debug("Failed to parse OpenAPI definition for API: " + api.getName()
                    + ". Errors: " + result.getMessages());
        }
    }

    /**
     * Build the lookup key used in the RESOURCE_SCOPE_MAP for a given operation.
     *
     * <p>Key format: {@code <HTTP_METHOD>:<path>} (for example: {@code GET:/testResource}).
     * This centralizes the key format so other code can reuse the exact same representation.
     *
     * @param httpMethod the HTTP method from the OpenAPI PathItem (must not be null)
     * @param path the operation path from the OpenAPI object
     * @return the normalized lookup key (never null)
     * @throws IllegalArgumentException if {@code httpMethod} is null
     */
    public static String getResourceScopeKey(String httpMethod, String path) {
        if (httpMethod == null) {
            throw new IllegalArgumentException("HTTP method cannot be null");
        }
        if (path == null) {
            path = "";
        }
        return httpMethod + ":" + path;
    }

    /**
     * Scans the OpenAPI model to extract and map OAuth2 scopes to specific API resources.
     *
     * <p>Example key mapping:
     * <pre>
     *   GET:/testResource -> ["scope1", "scope2"]
     * </pre>
     *
     * @param resourceScopeMap map that will be populated with resource-to-scope mappings,
     *                         keyed by {@code <HTTP_METHOD>:<path>}
     * @param openAPI the OpenAPI instance to scan for resource scopes
     */
    public static void populateResourceScopesFromOpenApi(Map<String, List<String>> resourceScopeMap, OpenAPI openAPI) {
        if (openAPI == null || openAPI.getPaths() == null) return;

        openAPI.getPaths().forEach((path, pathItem) -> {
            pathItem.readOperationsMap().forEach((method, operation) -> {
                List<SecurityRequirement> security = operation.getSecurity();

                // Fallback to global security if operation-level is null
                if (security == null) {
                    security = openAPI.getSecurity();
                }

                if (security != null) {
                    String key = ApiUtils.getResourceScopeKey(method.name(), path);
                    List<String> scopes = new ArrayList<>();

                    for (SecurityRequirement req : security) {
                        for (Map.Entry<String, List<String>> entry : req.entrySet()) {
                            if (RESTConstants.OAUTH2_SCOPE_VALIDATION.equalsIgnoreCase(entry.getKey())) {
                                scopes.addAll(entry.getValue());
                            }
                        }
                    }
                    if (!scopes.isEmpty()) {
                        resourceScopeMap.put(key, scopes);
                    }
                }
            });
        });
    }
}
