/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed 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 com.android.server.appsearch.appsindexer;

import android.annotation.NonNull;
import android.app.appsearch.AppSearchSchema;
import android.app.appsearch.AppSearchSchema.BooleanPropertyConfig;
import android.app.appsearch.AppSearchSchema.LongPropertyConfig;
import android.app.appsearch.AppSearchSchema.PropertyConfig;
import android.app.appsearch.AppSearchSchema.StringPropertyConfig;
import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;

import com.android.server.appsearch.appsindexer.appsearchtypes.AppFunctionDocument;
import com.android.server.appsearch.appsindexer.appsearchtypes.AppFunctionStaticMetadata;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;

import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * This class parses the XSD file from an app's assets and creates AppSearch schemas from document
 * types.
 *
 * <p>The generated {@link AppSearchSchema} objects are used to set the schema under the {@link
 * AppSearchHelper#APP_DATABASE} database. Within the database, each {@link AppSearchSchema} is
 * named dynamically to be unique to the app package name.
 *
 * <p>Note: The XSD file should be generated by the App Functions SDK and always define
 * AppFunctionStaticMetadata document type which will have {@link
 * AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA} as parent type.
 */
public class AppFunctionSchemaParser {
    private static final String TAG = "AppSearchSchemaParser";
    private static final String XML_TAG_DOCUMENT_TYPE = "xs:documentType";
    private static final String XML_TAG_ELEMENT = "xs:element";
    private static final String XML_APPFN_NAMESPACE_PREFIX = "appfn:";
    private static final String XML_TAG_STRING_TYPE = "xs:string";
    private static final String XML_TAG_LONG_TYPE = "xs:long";
    private static final String XML_TAG_INT_TYPE = "xs:int";
    private static final String XML_TAG_BOOLEAN_TYPE = "xs:boolean";
    private static final String XML_ATTRIBUTE_INDEXING_TYPE = "indexingType";
    private static final String XML_ATTRIBUTE_TOKENIZER_TYPE = "tokenizerType";
    private static final String XML_ATTRIBUTE_JOINABLE_VALUE_TYPE = "joinableValueType";
    private static final String XML_ATTRIBUTE_CARDINALITY = "cardinality";
    private static final String XML_ATTRIBUTE_SHOULD_INDEX_NESTED_PROPERTIES =
            "shouldIndexNestedProperties";
    private static final String XML_ATTRIBUTE_NAME = "name";
    private static final String XML_ATTRIBUTE_TYPE = "type";

    /**
     * The maximum number of document types allowed in the XSD file. This is to prevent malicious
     * apps from creating too many schema types in AppSearch by modifying the XSD file defined in
     * App Functions SDK.
     */
    private final int mMaxAllowedDocumentType;

    /**
     * @param maxAllowedDocumentType The maximum number of document types allowed in the XSD file.
     *     This is to prevent malicious apps from creating too many schema types in AppSearch by
     *     modifying the XSD file defined in App Functions SDK.
     */
    public AppFunctionSchemaParser(int maxAllowedDocumentType) {
        mMaxAllowedDocumentType = maxAllowedDocumentType;
    }

    private static boolean getAttributeBoolOrDefault(
            @NonNull XmlPullParser parser, @NonNull String attributeName, boolean defaultValue) {
        Objects.requireNonNull(parser);
        Objects.requireNonNull(attributeName);

        String value = parser.getAttributeValue(/* namespace= */ null, attributeName);
        return value == null ? defaultValue : Boolean.parseBoolean(value);
    }

    private static int getAttributeIntOrDefault(
            @NonNull XmlPullParser parser, @NonNull String attributeName, int defaultValue) {
        Objects.requireNonNull(parser);
        Objects.requireNonNull(attributeName);

        String value = parser.getAttributeValue(/* namespace= */ null, attributeName);
        return value == null ? defaultValue : Integer.parseInt(value);
    }

    /**
     * Parses the XSD and create AppSearch schemas from document types.
     *
     * <p>The schema output isn't guaranteed to have valid dependencies, which can be caught during
     * a {@link SyncAppSearchSession#setSchema} call.
     *
     * @param packageManager The PackageManager used to access app resources.
     * @param packageName The package name of the app whose assets contain the XSD file.
     * @param assetFilePath The path to the XSD file within the app's assets.
     * @return A mapping of schema types to their corresponding {@link AppSearchSchema} objects, or
     *     an empty map if there's an error during parsing or if the AppFunctionStaticMetadata
     *     document type is not found.
     */
    @NonNull
    public Map<String, AppSearchSchema> parseAndCreateSchemas(
            @NonNull PackageManager packageManager,
            @NonNull String packageName,
            @NonNull String assetFilePath) {
        Objects.requireNonNull(packageManager);
        Objects.requireNonNull(packageName);
        Objects.requireNonNull(assetFilePath);

        // Keep track of all nested schema types  to validate root schema later.
        Set<String> nestedSchemaTypes = new ArraySet<>();

        try {
            AssetManager assetManager =
                    packageManager.getResourcesForApplication(packageName).getAssets();
            InputStream xsdInputStream = assetManager.open(assetFilePath);
            Map<String, AppSearchSchema> schemas =
                    parseDocumentTypeAndCreateSchemas(
                            packageName, xsdInputStream, nestedSchemaTypes);
            return getValidatedSchemas(schemas, packageName, nestedSchemaTypes);
        } catch (Exception ex) {
            // The code parses an XSD file from another app's assets, using a broad try-catch to
            // handle potential errors since the XML structure might be unpredictable.
            Log.e(
                    TAG,
                    String.format(
                            "Failed to parse XSD from package '%s', asset file '%s'",
                            packageName, assetFilePath),
                    ex);
        }
        return Collections.emptyMap();
    }

    /**
     * Parses the XSD and create AppSearch schemas from document types.
     *
     * @param packageName The package for which schemas are being parsed.
     * @param xsdInputStream The input stream to the XSD file within the app's assets.
     * @param nestedSchemaTypes A set to maintain all the nested schema types encountered when
     *     processing a root tag. This will be later used to validate that correct schema
     *     definitions exist for each nested type.
     * @return A mapping of schema types to their corresponding {@link AppSearchSchema} objects, or
     *     an empty map if there's an error during parsing or if the AppFunctionStaticMetadata
     *     document type is not found.
     */
    private Map<String, AppSearchSchema> parseDocumentTypeAndCreateSchemas(
            @NonNull String packageName,
            @NonNull InputStream xsdInputStream,
            @NonNull Set<String> nestedSchemaTypes)
            throws XmlPullParserException, IOException, InvalidAppFunctionSchemaException {
        Objects.requireNonNull(packageName);
        Objects.requireNonNull(xsdInputStream);
        Objects.requireNonNull(nestedSchemaTypes);

        Map<String, AppSearchSchema> schemas = new ArrayMap<>();
        XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
        XmlPullParser parser = factory.newPullParser();
        parser.setInput(xsdInputStream, null);

        AppSearchSchema.Builder schemaBuilder = null;

        while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
            switch (parser.getEventType()) {
                case XmlPullParser.START_TAG:
                    if (XML_TAG_DOCUMENT_TYPE.equals(parser.getName())) {
                        if (schemas.size() >= mMaxAllowedDocumentType) {
                            throw new IllegalStateException(
                                    "Exceeded max allowed document types: "
                                            + mMaxAllowedDocumentType);
                        }

                        String documentTypeName =
                                parser.getAttributeValue(null, XML_ATTRIBUTE_NAME);
                        if (documentTypeName != null) {
                            schemaBuilder =
                                    new AppSearchSchema.Builder(
                                            AppFunctionDocument.getSchemaNameForPackage(
                                                    packageName, documentTypeName));

                            // All AppFunctionStaticMetadata schemas defined in packages will
                            // inherit from AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA.
                            if (documentTypeName.equals(AppFunctionStaticMetadata.SCHEMA_TYPE)
                                    && AppFunctionStaticMetadata.shouldSetParentType()) {
                                schemaBuilder.addParentType(AppFunctionStaticMetadata.SCHEMA_TYPE);
                            }
                        }
                    } else if (XML_TAG_ELEMENT.equals(parser.getName()) && schemaBuilder != null) {
                        AppSearchSchema.PropertyConfig propertyConfig =
                                computePropertyConfigFromXsdType(
                                        parser, packageName, nestedSchemaTypes);
                        if (propertyConfig != null) schemaBuilder.addProperty(propertyConfig);
                    }
                    break;

                case XmlPullParser.END_TAG:
                    if (XML_TAG_DOCUMENT_TYPE.equals(parser.getName())) {
                        if (schemaBuilder != null) {
                            AppSearchSchema schema = schemaBuilder.build();
                            schemas.put(schema.getSchemaType(), schema);
                            schemaBuilder = null;
                        }
                    }
                    break;
            }
            parser.next();
        }

        return schemas;
    }

    private PropertyConfig computePropertyConfigFromXsdType(
            @NonNull XmlPullParser parser,
            @NonNull String packageName,
            @NonNull Set<String> nestedSchemaTypes)
            throws InvalidAppFunctionSchemaException {
        Objects.requireNonNull(parser);
        Objects.requireNonNull(packageName);
        Objects.requireNonNull(nestedSchemaTypes);

        String name = parser.getAttributeValue(null, XML_ATTRIBUTE_NAME);
        validatePropertyName(name);
        String type = parser.getAttributeValue(null, XML_ATTRIBUTE_TYPE);

        if (name == null || type == null) return null;

        int cardinality =
                getAttributeIntOrDefault(
                        parser, XML_ATTRIBUTE_CARDINALITY, PropertyConfig.CARDINALITY_OPTIONAL);

        switch (type) {
            case XML_TAG_STRING_TYPE:
                return new StringPropertyConfig.Builder(name)
                        .setCardinality(cardinality)
                        .setIndexingType(
                                getAttributeIntOrDefault(
                                        parser,
                                        XML_ATTRIBUTE_INDEXING_TYPE,
                                        StringPropertyConfig.INDEXING_TYPE_NONE))
                        .setTokenizerType(
                                getAttributeIntOrDefault(
                                        parser,
                                        XML_ATTRIBUTE_TOKENIZER_TYPE,
                                        StringPropertyConfig.TOKENIZER_TYPE_NONE))
                        .setJoinableValueType(
                                getAttributeIntOrDefault(
                                        parser,
                                        XML_ATTRIBUTE_JOINABLE_VALUE_TYPE,
                                        StringPropertyConfig.JOINABLE_VALUE_TYPE_NONE))
                        .build();
            case XML_TAG_LONG_TYPE:
            case XML_TAG_INT_TYPE:
                return new LongPropertyConfig.Builder(name)
                        .setCardinality(cardinality)
                        .setIndexingType(
                                getAttributeIntOrDefault(
                                        parser,
                                        XML_ATTRIBUTE_INDEXING_TYPE,
                                        LongPropertyConfig.INDEXING_TYPE_NONE))
                        .build();
            case XML_TAG_BOOLEAN_TYPE:
                return new BooleanPropertyConfig.Builder(name).setCardinality(cardinality).build();
            default:
                if (type.contains(XML_APPFN_NAMESPACE_PREFIX)) {
                    String localType = type.substring(type.indexOf(':') + 1);
                    String schemaType =
                            AppFunctionDocument.getSchemaNameForPackage(packageName, localType);
                    nestedSchemaTypes.add(schemaType);
                    return new AppSearchSchema.DocumentPropertyConfig.Builder(name, schemaType)
                            .setCardinality(cardinality)
                            .setShouldIndexNestedProperties(
                                    getAttributeBoolOrDefault(
                                            parser,
                                            XML_ATTRIBUTE_SHOULD_INDEX_NESTED_PROPERTIES,
                                            false))
                            .build();
                }
                throw new IllegalArgumentException("Unsupported type: " + type);
        }
    }

    /**
     * Validates the given property name to ensure it is not null, empty, and contains only
     * alphanumeric characters (letters and digits).
     *
     * @param name the property name to validate
     * @throws InvalidAppFunctionSchemaException if the name is empty, or contains non-alphanumeric
     *     characters
     */
    private static void validatePropertyName(@NonNull String name)
            throws InvalidAppFunctionSchemaException {
        Objects.requireNonNull(name);
        if (name.isEmpty()) {
            throw new InvalidAppFunctionSchemaException(
                    "Property name in the schema cannot be null or empty.");
        }
        for (int i = 0; i < name.length(); i++) {
            char c = name.charAt(i);
            if (!Character.isLetterOrDigit(c)) {
                throw new InvalidAppFunctionSchemaException(
                        "Property name must contain only alphanumeric characters: " + name);
            }
        }
    }

    /**
     * Validates and returns the provided schema map for a given package.
     *
     * <p>This method ensures that the required schemas and properties are present in the schema
     * map. Specifically:
     *
     * <ul>
     *   <li>It verifies that the schema for {@link AppFunctionStaticMetadata} exists for the given
     *       package and checks for the presence of required properties from {@link
     *       AppFunctionStaticMetadata#PARENT_TYPE_APPSEARCH_SCHEMA}.
     *   <li>It checks for any missing schema definitions for nested schema types.
     * </ul>
     *
     * @param schemaMap A map where the key is the schema type name, and the value is the
     *     corresponding {@link AppSearchSchema}.
     * @param packageName The name of the package for which the schemas are being validated.
     * @param nestedSchemaTypes A set of all the nested schema types used to validate that correct
     *     schema definitions exist for each nested type.
     * @return The validated schema map.
     * @throws InvalidAppFunctionSchemaException If any required schema or properties are missing.
     */
    private Map<String, AppSearchSchema> getValidatedSchemas(
            @NonNull Map<String, AppSearchSchema> schemaMap,
            @NonNull String packageName,
            @NonNull Set<String> nestedSchemaTypes)
            throws InvalidAppFunctionSchemaException {
        Objects.requireNonNull(schemaMap);
        Objects.requireNonNull(packageName);
        Objects.requireNonNull(nestedSchemaTypes);

        String appFunctionStaticMetadataSchemaType =
                AppFunctionDocument.getSchemaNameForPackage(
                        packageName, AppFunctionStaticMetadata.SCHEMA_TYPE);
        if (!schemaMap.containsKey(appFunctionStaticMetadataSchemaType)) {
            throw new InvalidAppFunctionSchemaException(
                    "Missing schema definition for AppFunctionStaticMetadata in package: "
                            + packageName);
        }

        Set<String> undefinedSchemas = new ArraySet<>(nestedSchemaTypes);
        undefinedSchemas.removeAll(schemaMap.keySet());
        if (!undefinedSchemas.isEmpty()) {
            throw new InvalidAppFunctionSchemaException(
                    "In package: "
                            + packageName
                            + ", missing schema definitions for following nested schema types: "
                            + undefinedSchemas);
        }

        return schemaMap;
    }

    /** Exception thrown when the parsed schema fails any of the validations. */
    private static class InvalidAppFunctionSchemaException extends Exception {
        InvalidAppFunctionSchemaException(String message) {
            super(message);
        }
    }
}
