/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the "Elastic License
 * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
 * Public License v 1"; you may not use this file except in compliance with, at
 * your election, the "Elastic License 2.0", the "GNU Affero General Public
 * License v3.0 only", or the "Server Side Public License, v 1".
 */

package org.elasticsearch.search.crossproject;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.ElasticsearchException;
import org.elasticsearch.ElasticsearchSecurityException;
import org.elasticsearch.action.ResolvedIndexExpression;
import org.elasticsearch.action.ResolvedIndexExpressions;
import org.elasticsearch.action.support.IndicesOptions;
import org.elasticsearch.common.Strings;
import org.elasticsearch.core.Nullable;
import org.elasticsearch.index.IndexNotFoundException;
import org.elasticsearch.transport.RemoteClusterAware;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;

import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_NOT_VISIBLE;
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.CONCRETE_RESOURCE_UNAUTHORIZED;
import static org.elasticsearch.action.ResolvedIndexExpression.LocalIndexResolutionResult.SUCCESS;
import static org.elasticsearch.search.crossproject.CrossProjectIndexExpressionsRewriter.isExclusionExpression;

/**
 * Utility class for validating index resolution results in cross-project operations.
 * <p>
 * This class provides consistent error handling for scenarios where index resolution
 * spans multiple projects, taking into account the provided {@link IndicesOptions}.
 * It handles:
 * <ul>
 *   <li>Validation of index existence in both origin and linked projects based on IndicesOptions
 *       (ignoreUnavailable, allowNoIndices)</li>
 *   <li>Authorization issues during cross-project index resolution, returning appropriate
 *       {@link ElasticsearchSecurityException} responses</li>
 *   <li>Both flat (unqualified) and qualified index expressions (including "_origin:" prefixed indices)</li>
 *   <li>Wildcard index patterns that may resolve differently across projects</li>
 * </ul>
 * <p>
 * The validator examines both local and remote resolution results to determine the appropriate
 * error response, returning {@link IndexNotFoundException} for missing indices or
 * {@link ElasticsearchSecurityException} for authorization failures.
 */
public class CrossProjectIndexResolutionValidator {
    private static final Logger logger = LogManager.getLogger(CrossProjectIndexResolutionValidator.class);

    /**
     * Validates the results of cross-project index resolution and returns appropriate exceptions based on the provided
     * {@link IndicesOptions}.
     * <p>
     * This method handles error scenarios when resolving indices across multiple projects:
     * <ul>
     *   <li>If both {@code ignoreUnavailable} and {@code allowNoIndices} are true, the method returns null without validation
     *       (lenient mode)</li>
     *   <li>For wildcard patterns that resolve to no indices, validates against {@code allowNoIndices}</li>
     *   <li>For concrete indices that don't exist, validates against {@code ignoreUnavailable}</li>
     *   <li>For indices with authorization issues, returns security exceptions</li>
     * </ul>
     * <p>
     * The method considers both flat (unqualified) and qualified index expressions, as well as
     * local and linked project resolution results when determining the appropriate error response.
     *
     * @param indicesOptions            Controls error behavior for missing indices
     * @param projectRouting            The project routing string from the request, can be null if request does not specify it
     * @param localResolvedExpressions  Resolution results from the origin project
     * @param remoteResolvedExpressions Resolution results from linked projects
     * @return a {@link ElasticsearchException} if validation fails, null if validation passes
     */
    public static ElasticsearchException validate(
        IndicesOptions indicesOptions,
        @Nullable String projectRouting,
        ResolvedIndexExpressions localResolvedExpressions,
        Map<String, ResolvedIndexExpressions> remoteResolvedExpressions
    ) {
        if (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable()) {
            logger.debug("Skipping index existence check in lenient mode");
            return null;
        }

        // For each unauthorized expression, we report 403 for the first project if the expression is unqualified.
        // Otherwise, we report 403 for all projects where the expression is unauthorized.
        Map<String, ElasticsearchSecurityException> remoteAuthorizationExceptions = null;
        Map<String, List<String>> remoteUnauthorizedIndices = null;
        ElasticsearchSecurityException localAuthorizationException = null;
        List<String> localUnauthorizedIndices = null;

        // We report only the first 404 error when there is no 403 error
        IndexNotFoundException notFoundException = null;

        final boolean hasProjectRouting = Strings.isEmpty(projectRouting) == false;
        logger.debug(
            "Checking index existence for [{}] and [{}] with indices options [{}]{}",
            localResolvedExpressions,
            remoteResolvedExpressions,
            indicesOptions,
            hasProjectRouting ? " and project routing [" + projectRouting + "]" : ""
        );

        for (ResolvedIndexExpression localResolvedIndices : localResolvedExpressions.expressions()) {
            String originalExpression = localResolvedIndices.original();
            logger.debug("Checking replaced expression for original expression [{}]", originalExpression);

            // Check if this is a qualified resource (project:index pattern)
            boolean isQualifiedExpression = RemoteClusterAware.isRemoteIndexName(originalExpression);

            // Sort expressions to ensure behaviour is deterministic
            // TODO consider sorting during index re-writing, to avoid sorting here
            var remoteExpressions = localResolvedIndices.remoteExpressions().stream().sorted().toList();
            ResolvedIndexExpression.LocalExpressions localExpressions = localResolvedIndices.localExpressions();
            ResolvedIndexExpression.LocalIndexResolutionResult result = localExpressions.localIndexResolutionResult();
            ElasticsearchException localException = checkResolutionFailure(localExpressions, result, originalExpression, indicesOptions);

            if (isQualifiedExpression) {
                if (localException != null) {
                    if (localException instanceof ElasticsearchSecurityException securityException) {
                        if (localAuthorizationException == null) {
                            localAuthorizationException = securityException;
                            localUnauthorizedIndices = new ArrayList<>();
                        }
                        localUnauthorizedIndices.add(originalExpression);
                    } else {
                        if (notFoundException == null) notFoundException = (IndexNotFoundException) localException;
                    }
                }
                // qualified linked project expression
                for (String remoteExpression : remoteExpressions) {
                    String[] splitResource = splitQualifiedResource(remoteExpression);
                    var projectAlias = splitResource[0];
                    var resource = splitResource[1];

                    ElasticsearchException remoteException = checkSingleRemoteExpression(
                        remoteResolvedExpressions,
                        projectAlias,
                        resource,
                        remoteExpression,
                        indicesOptions
                    );
                    if (remoteException != null) {
                        if (remoteException instanceof ElasticsearchSecurityException securityException) {
                            if (remoteAuthorizationExceptions == null) {
                                remoteAuthorizationExceptions = new LinkedHashMap<>();
                                remoteUnauthorizedIndices = new HashMap<>();
                            }
                            remoteAuthorizationExceptions.putIfAbsent(projectAlias, securityException);
                            remoteUnauthorizedIndices.computeIfAbsent(projectAlias, k -> new ArrayList<>()).add(remoteExpression);
                        } else {
                            if (notFoundException == null) notFoundException = (IndexNotFoundException) remoteException;
                        }
                    }
                }
            } else {
                if (localException == null && localExpressions != ResolvedIndexExpression.LocalExpressions.NONE) {
                    // found locally, continue to next expression
                    continue;
                }
                assert localExpressions != ResolvedIndexExpression.LocalExpressions.NONE || false == remoteExpressions.isEmpty()
                    : "both local expression and remote expressions are empty which should have errored earlier at index rewriting time";
                ElasticsearchSecurityException currentExpressionSecurityException = null;
                if (localException instanceof ElasticsearchSecurityException securityException) {
                    currentExpressionSecurityException = securityException;
                }

                boolean foundFlat = false;
                BiConsumer<
                    Map<String, ElasticsearchSecurityException>,
                    Map<String, List<String>>> populateRemoteSecurityExceptionAndIndices = null;
                // checking if flat expression matched remotely
                for (String remoteExpression : remoteExpressions) {
                    String[] splitResource = splitQualifiedResource(remoteExpression);
                    var projectAlias = splitResource[0];
                    var resource = splitResource[1];

                    ElasticsearchException remoteException = checkSingleRemoteExpression(
                        remoteResolvedExpressions,
                        projectAlias,
                        resource,
                        remoteExpression,
                        indicesOptions
                    );
                    if (remoteException == null) {
                        // found flat expression somewhere
                        foundFlat = true;
                        break;
                    }
                    if (currentExpressionSecurityException == null
                        && remoteException instanceof ElasticsearchSecurityException securityException) {
                        currentExpressionSecurityException = securityException;
                        // It is possible that the expression is found on a later linked project. So we defer its exception propagation
                        // with a lambda so that it is executed only when the expression is not found anywhere.
                        assert populateRemoteSecurityExceptionAndIndices == null;
                        populateRemoteSecurityExceptionAndIndices = (exceptionsMap, unauthorizedIndicesMap) -> {
                            exceptionsMap.putIfAbsent(projectAlias, securityException);
                            unauthorizedIndicesMap.computeIfAbsent(projectAlias, k -> new ArrayList<>()).add(remoteExpression);
                        };
                    }
                }
                if (foundFlat) {
                    continue;
                }
                if (populateRemoteSecurityExceptionAndIndices != null) {
                    assert localException instanceof ElasticsearchSecurityException == false;
                    if (remoteAuthorizationExceptions == null) {
                        remoteAuthorizationExceptions = new HashMap<>();
                        remoteUnauthorizedIndices = new HashMap<>();
                    }
                    populateRemoteSecurityExceptionAndIndices.accept(remoteAuthorizationExceptions, remoteUnauthorizedIndices);
                }
                if (currentExpressionSecurityException != null && currentExpressionSecurityException == localException) {
                    // We have a local security exception for this unqualified expression. That's what we want to report, i.e.
                    // we are no longer interested in whether any linked project also returns a security exception.
                    if (localAuthorizationException == null) {
                        localAuthorizationException = currentExpressionSecurityException;
                        localUnauthorizedIndices = new ArrayList<>();
                    }
                    localUnauthorizedIndices.add(originalExpression);
                } else if (localException != null) {
                    // We have a local 404 for this unqualified expression which takes priority over any remote 404
                    assert localException instanceof IndexNotFoundException
                        : "Expected local exception to be IndexNotFoundException, but found: " + localException;
                    if (notFoundException == null) {
                        notFoundException = (IndexNotFoundException) localException;
                    }
                } else {
                    // Local project is excluded and 404 from all remotes, we report 404 for the first remote project
                    assert localExpressions == ResolvedIndexExpression.LocalExpressions.NONE : localExpressions;
                    assert false == remoteExpressions.isEmpty() : "expected remote expressions to be non-empty";
                    if (notFoundException == null) {
                        notFoundException = new IndexNotFoundException(remoteExpressions.getFirst());
                    }
                }
            }
        }

        if (localAuthorizationException == null && remoteAuthorizationExceptions == null) {
            return notFoundException;
        } else {
            var firstException = localAuthorizationException != null
                ? formatAuthorizationException(localAuthorizationException, localUnauthorizedIndices)
                : null;

            if (remoteAuthorizationExceptions != null) {
                for (var e : remoteAuthorizationExceptions.entrySet()) {
                    final var unauthorizedIndices = remoteUnauthorizedIndices.get(e.getKey());
                    assert unauthorizedIndices.isEmpty() == false;

                    var exception = formatAuthorizationException(e.getValue(), unauthorizedIndices);
                    if (firstException == null) {
                        firstException = exception;
                    } else {
                        firstException.addSuppressed(exception);
                    }
                }
            }

            return firstException;
        }
    }

    private static ElasticsearchSecurityException formatAuthorizationException(
        ElasticsearchException exceptionWithPlaceholder,
        List<String> unauthorizedIndices
    ) {
        return new ElasticsearchSecurityException(
            Strings.replace(exceptionWithPlaceholder.getMessage(), "-*", Strings.collectionToCommaDelimitedString(unauthorizedIndices)),
            exceptionWithPlaceholder.status()
        );
    }

    public static IndicesOptions indicesOptionsForCrossProjectFanout(IndicesOptions indicesOptions) {
        return IndicesOptions.builder(indicesOptions)
            .concreteTargetOptions(new IndicesOptions.ConcreteTargetOptions(true))
            .wildcardOptions(IndicesOptions.WildcardOptions.builder(indicesOptions.wildcardOptions()).allowEmptyExpressions(true).build())
            .crossProjectModeOptions(IndicesOptions.CrossProjectModeOptions.DEFAULT)
            .build();
    }

    private static ElasticsearchException checkSingleRemoteExpression(
        Map<String, ResolvedIndexExpressions> remoteResolvedExpressions,
        String projectAlias,
        String resource,
        String remoteExpression,
        IndicesOptions indicesOptions
    ) {
        if (isExclusionExpression(projectAlias) || isExclusionExpression(resource)) {
            logger.debug("Skipping check for excluded remote expression [{}:{}]", projectAlias, resource);
            return null;
        }

        ResolvedIndexExpressions resolvedExpressionsInProject = remoteResolvedExpressions.get(projectAlias);
        /*
         * We look for an index in the linked projects only after we've ascertained that it does not exist
         * on the origin. However, if we couldn't find a valid entry for the same index in the resolved
         * expressions `Map<K,V>` from the linked projects, it could mean that we did not hear back from
         * the linked project due to some error that occurred on it. In such case, the scenario effectively
         * is identical to the one where we could not find an index anywhere.
         */
        if (resolvedExpressionsInProject == null) {
            return new IndexNotFoundException(remoteExpression);
        }

        ResolvedIndexExpression.LocalExpressions matchingExpression = findMatchingExpression(resolvedExpressionsInProject, resource);
        if (matchingExpression == null) {
            assert false : "Expected to find matching expression [" + resource + "] in project [" + projectAlias + "]";
            return new IndexNotFoundException(remoteExpression);
        }

        return checkResolutionFailure(
            matchingExpression,
            matchingExpression.localIndexResolutionResult(),
            remoteExpression,
            indicesOptions
        );
    }

    public static String[] splitQualifiedResource(String resource) {
        String[] splitResource = RemoteClusterAware.splitIndexName(resource);
        assert splitResource.length == 2
            : "Expected two strings (project and indexExpression) for a qualified resource ["
                + resource
                + "], but found ["
                + splitResource.length
                + "]";
        return splitResource;
    }

    // TODO optimize with a precomputed Map<String, ResolvedIndexExpression.LocalExpressions> instead
    private static ResolvedIndexExpression.LocalExpressions findMatchingExpression(
        ResolvedIndexExpressions projectExpressions,
        String resource
    ) {
        return projectExpressions.expressions()
            .stream()
            .filter(expr -> expr.original().equals(resource))
            .map(ResolvedIndexExpression::localExpressions)
            .findFirst()
            .orElse(null);
    }

    private static ElasticsearchException checkResolutionFailure(
        ResolvedIndexExpression.LocalExpressions localExpressions,
        ResolvedIndexExpression.LocalIndexResolutionResult result,
        String expression,
        IndicesOptions indicesOptions
    ) {
        assert false == (indicesOptions.allowNoIndices() && indicesOptions.ignoreUnavailable())
            : "Should not be checking index existence in lenient mode";

        if (indicesOptions.ignoreUnavailable() == false) {
            if (result == CONCRETE_RESOURCE_NOT_VISIBLE) {
                return new IndexNotFoundException(expression);
            } else if (result == CONCRETE_RESOURCE_UNAUTHORIZED) {
                assert localExpressions.exception() != null
                    : "ResolvedIndexExpression should have exception set when concrete index is unauthorized";

                return localExpressions.exception();
            }
        }

        if (indicesOptions.allowNoIndices() == false && result == SUCCESS && localExpressions.indices().isEmpty()) {
            return new IndexNotFoundException(expression);
        }

        return null;
    }
}
