import fs from "fs-extra";
import { Util } from "@serverless-stack/core";
const { print, buildSchema } = await Util.weakImport("graphql");
const { mergeTypeDefs } = await Util.weakImport("@graphql-tools/merge");
import { Construct } from "constructs";
import * as appsync from "@aws-cdk/aws-appsync-alpha";
import * as appSyncApiDomain from "./util/appSyncApiDomain.js";
import { getFunctionRef, isCDKConstruct } from "./Construct.js";
import { Function as Fn, } from "./Function.js";
import { State } from "@serverless-stack/core";
/////////////////////
// Construct
/////////////////////
/**
 *
 * The `AppSyncApi` construct is a higher level CDK construct that makes it easy to create an AppSync GraphQL API.
 *
 * @example
 *
 * ```js
 * import { AppSyncApi } from "@serverless-stack/resources";
 *
 * new AppSyncApi(stack, "GraphqlApi", {
 *   schema: "graphql/schema.graphql",
 *   dataSources: {
 *     notesDS: "src/notes.main",
 *   },
 *   resolvers: {
 *     "Query    listNotes": "notesDS",
 *     "Query    getNoteById": "notesDS",
 *     "Mutation createNote": "notesDS",
 *     "Mutation updateNote": "notesDS",
 *     "Mutation deleteNote": "notesDS",
 *   },
 * });
 * ```
 */
export class AppSyncApi extends Construct {
    constructor(scope, id, props) {
        super(scope, id);
        this.props = props || {};
        this.cdk = {};
        this.functionsByDsKey = {};
        this.dataSourcesByDsKey = {};
        this.resolversByResKey = {};
        this.dsKeysByResKey = {};
        this.permissionsAttachedForAllFunctions = [];
        this.createGraphApi();
        // Configure data sources
        if (props?.dataSources) {
            for (const key of Object.keys(props.dataSources)) {
                this.addDataSource(this, key, props.dataSources[key]);
            }
        }
        // Configure resolvers
        if (props?.resolvers) {
            for (const key of Object.keys(props.resolvers)) {
                this.addResolver(this, key, props.resolvers[key]);
            }
        }
    }
    /**
     * The Id of the internally created AppSync GraphQL API.
     */
    get apiId() {
        return this.cdk.graphqlApi.apiId;
    }
    /**
     * The ARN of the internally created AppSync GraphQL API.
     */
    get apiArn() {
        return this.cdk.graphqlApi.arn;
    }
    /**
     * The name of the internally created AppSync GraphQL API.
     */
    get apiName() {
        return this.cdk.graphqlApi.name;
    }
    /**
     * The AWS generated URL of the Api.
     */
    get url() {
        return this.cdk.graphqlApi.graphqlUrl;
    }
    /**
     * If custom domain is enabled, this is the custom domain URL of the Api.
     */
    get customDomainUrl() {
        return this._customDomainUrl;
    }
    /**
     * Add data sources after the construct has been created
     *
     * @example
     * ```js
     * api.addDataSources(stack, {
     *   billingDS: "src/billing.main",
     * });
     * ```
     */
    addDataSources(scope, dataSources) {
        Object.keys(dataSources).forEach((key) => {
            // add data source
            const fn = this.addDataSource(scope, key, dataSources[key]);
            // attached existing permissions
            if (fn) {
                this.permissionsAttachedForAllFunctions.forEach((permissions) => fn.attachPermissions(permissions));
            }
        });
    }
    /**
     * Add resolvers the construct has been created
     *
     * @example
     * ```js
     * api.addResolvers(stack, {
     *   "Mutation charge": "billingDS",
     * });
     * ```
     */
    addResolvers(scope, resolvers) {
        Object.keys(resolvers).forEach((key) => {
            // add resolver
            const fn = this.addResolver(scope, key, resolvers[key]);
            // attached existing permissions
            if (fn) {
                this.permissionsAttachedForAllFunctions.forEach((permissions) => fn.attachPermissions(permissions));
            }
        });
    }
    /**
     * Get the instance of the internally created Function, for a given resolver.
     *
     * @example
     * ```js
     * const func = api.getFunction("Mutation charge");
     * ```
     */
    getFunction(key) {
        let fn = this.functionsByDsKey[key];
        if (!fn) {
            const resKey = this.normalizeResolverKey(key);
            const dsKey = this.dsKeysByResKey[resKey];
            fn = this.functionsByDsKey[dsKey];
        }
        return fn;
    }
    /**
     * Get a datasource by name
     * @example
     * ```js
     * api.getDataSource("billingDS");
     * ```
     */
    getDataSource(key) {
        let ds = this.dataSourcesByDsKey[key];
        if (!ds) {
            const resKey = this.normalizeResolverKey(key);
            const dsKey = this.dsKeysByResKey[resKey];
            ds = this.dataSourcesByDsKey[dsKey];
        }
        return ds;
    }
    /**
     * Get a resolver
     *
     * @example
     * ```js
     * api.getResolver("Mutation charge");
     * ```
     */
    getResolver(key) {
        const resKey = this.normalizeResolverKey(key);
        return this.resolversByResKey[resKey];
    }
    /**
     * Attaches the given list of permissions to all function datasources
     *
     * @example
     *
     * ```js
     * api.attachPermissions(["s3"]);
     * ```
     */
    attachPermissions(permissions) {
        Object.values(this.functionsByDsKey).forEach((fn) => fn.attachPermissions(permissions));
        this.permissionsAttachedForAllFunctions.push(permissions);
    }
    /**
     * Attaches the given list of permissions to a specific function datasource. This allows that function to access other AWS resources.
     *
     * @example
     * api.attachPermissionsToRoute("Mutation charge", ["s3"]);
     * ```
     */
    attachPermissionsToDataSource(key, permissions) {
        const fn = this.getFunction(key);
        if (!fn) {
            throw new Error(`Failed to attach permissions. Function does not exist for key "${key}".`);
        }
        fn.attachPermissions(permissions);
    }
    getConstructMetadata() {
        return {
            type: "AppSync",
            data: {
                url: this.cdk.graphqlApi.graphqlUrl,
                appSyncApiId: this.cdk.graphqlApi.apiId,
                appSyncApiKey: this.cdk.graphqlApi.apiKey,
                customDomainUrl: this._customDomainUrl,
                dataSources: Object.entries(this.dataSourcesByDsKey).map(([key]) => ({
                    name: key,
                    fn: getFunctionRef(this.functionsByDsKey[key]),
                })),
            },
        };
    }
    createGraphApi() {
        const { schema, customDomain, cdk } = this.props;
        const id = this.node.id;
        const app = this.node.root;
        if (isCDKConstruct(cdk?.graphqlApi)) {
            if (customDomain !== undefined) {
                throw new Error(`Cannot configure the "customDomain" when "graphqlApi" is a construct`);
            }
            this.cdk.graphqlApi = cdk?.graphqlApi;
        }
        else {
            const graphqlApiProps = (cdk?.graphqlApi ||
                {});
            // build schema
            let mainSchema;
            if (typeof schema === "string") {
                mainSchema = appsync.Schema.fromAsset(schema);
            }
            else if (Array.isArray(schema)) {
                if (schema.length > 0) {
                    // merge schema files
                    const mergedSchema = mergeTypeDefs(schema
                        .map((file) => fs.readFileSync(file).toString())
                        .map(buildSchema));
                    const filePath = State.resolve(app.appPath, `appsyncapi-${id}-${this.node.addr}.graphql`);
                    fs.writeFileSync(filePath, print(mergedSchema));
                    mainSchema = appsync.Schema.fromAsset(filePath);
                }
            }
            // build domain
            const domainData = appSyncApiDomain.buildCustomDomainData(this, customDomain);
            this._customDomainUrl =
                domainData && `https://${domainData.domainName}/graphql`;
            this.cdk.graphqlApi = new appsync.GraphqlApi(this, "Api", {
                name: app.logicalPrefixedName(id),
                xrayEnabled: true,
                schema: mainSchema,
                domainName: domainData,
                ...graphqlApiProps,
            });
            this.cdk.certificate = domainData?.certificate;
            // note: As of CDK 2.20.0, the "AWS::AppSync::DomainNameApiAssociation" resource
            //       is not dependent on the "AWS::AppSync::DomainName" resource. This leads
            //       CloudFormation deploy error if DomainNameApiAssociation is created before
            //       DomainName is created.
            //       https://github.com/aws/aws-cdk/issues/18395#issuecomment-1099455502
            //       To workaround this issue, we need to add a dependency manually.
            if (domainData) {
                this._cfnDomainName = this.cdk.graphqlApi.node.children.find((child) => child.cfnResourceType ===
                    "AWS::AppSync::DomainName");
                const cfnDomainNameApiAssociation = this.cdk.graphqlApi.node.children.find((child) => child
                    .cfnResourceType === "AWS::AppSync::DomainNameApiAssociation");
                if (this._cfnDomainName && cfnDomainNameApiAssociation) {
                    cfnDomainNameApiAssociation.node.addDependency(this._cfnDomainName);
                }
            }
        }
    }
    addDataSource(scope, dsKey, dsValue) {
        let dataSource;
        let lambda;
        // Lambda function
        if (Fn.isInlineDefinition(dsValue)) {
            lambda = Fn.fromDefinition(scope, `Lambda_${dsKey}`, dsValue, this.props.defaults?.function, `Cannot define defaults.function when a Function is passed in to the "${dsKey} data source`);
            dataSource = this.cdk.graphqlApi.addLambdaDataSource(dsKey, lambda);
        }
        // DynamoDb ds
        else if (dsValue.type === "dynamodb") {
            dataSource = this.cdk.graphqlApi.addDynamoDbDataSource(dsKey, dsValue.table
                ? dsValue.table.cdk.table
                : dsValue.cdk?.dataSource?.table, {
                name: dsValue.name,
                description: dsValue.description,
            });
        }
        // Rds ds
        else if (dsValue.type === "rds") {
            dataSource = this.cdk.graphqlApi.addRdsDataSource(dsKey, dsValue.rds
                ? dsValue.rds.cdk.cluster
                : dsValue.cdk?.dataSource?.serverlessCluster, dsValue.rds
                ? dsValue.rds.cdk.cluster.secret
                : dsValue.cdk?.dataSource?.secretStore, dsValue.rds
                ? dsValue.databaseName || dsValue.rds.defaultDatabaseName
                : dsValue.cdk?.dataSource?.databaseName, {
                name: dsValue.name,
                description: dsValue.description,
            });
        }
        // Http ds
        else if (dsValue.type === "http") {
            dataSource = this.cdk.graphqlApi.addHttpDataSource(dsKey, dsValue.endpoint, {
                name: dsValue.name,
                description: dsValue.description,
            });
        }
        // Http ds
        else if (dsValue.type === "none") {
            dataSource = this.cdk.graphqlApi.addNoneDataSource(dsKey, {
                name: dsValue.name,
                description: dsValue.description,
            });
        }
        // Lambda ds
        else {
            lambda = Fn.fromDefinition(scope, `Lambda_${dsKey}`, dsValue.function, this.props.defaults?.function, `Cannot define defaults.function when a Function is passed in to the "${dsKey} data source`);
            dataSource = this.cdk.graphqlApi.addLambdaDataSource(dsKey, lambda, {
                name: dsValue.name,
                description: dsValue.description,
            });
        }
        this.dataSourcesByDsKey[dsKey] = dataSource;
        if (lambda) {
            this.functionsByDsKey[dsKey] = lambda;
        }
        return lambda;
    }
    addResolver(scope, resKey, resValue) {
        // Normalize resKey
        resKey = this.normalizeResolverKey(resKey);
        // Get type and field
        const resolverKeyParts = resKey.split(" ");
        if (resolverKeyParts.length !== 2) {
            throw new Error(`Invalid resolver ${resKey}`);
        }
        const [typeName, fieldName] = resolverKeyParts;
        if (fieldName.length === 0) {
            throw new Error(`Invalid field defined for "${resKey}"`);
        }
        ///////////////////
        // Create data source if not created before
        ///////////////////
        let lambda;
        let dataSource;
        let dataSourceKey;
        let resolverProps;
        // DataSource key
        if (typeof resValue === "string" &&
            Object.keys(this.dataSourcesByDsKey).includes(resValue)) {
            dataSourceKey = resValue;
            dataSource = this.dataSourcesByDsKey[resValue];
            resolverProps = {};
        }
        // DataSource key not exist (string does not have a dot, assume it is referencing a data store)
        else if (typeof resValue === "string" && resValue.indexOf(".") === -1) {
            throw new Error(`Failed to create resolver "${resKey}". Data source "${resValue}" does not exist.`);
        }
        // Lambda resolver
        else if (this.isLambdaResolverProps(resValue)) {
            resValue = resValue;
            lambda = Fn.fromDefinition(scope, `Lambda_${typeName}_${fieldName}`, resValue.function, this.props.defaults?.function, `Cannot define defaults.function when a Function is passed in to the "${resKey} resolver`);
            dataSourceKey = this.buildDataSourceKey(typeName, fieldName);
            dataSource = this.cdk.graphqlApi.addLambdaDataSource(dataSourceKey, lambda);
            resolverProps = {
                requestMappingTemplate: this.buildMappingTemplate(resValue.requestMapping),
                responseMappingTemplate: this.buildMappingTemplate(resValue.responseMapping),
                ...resValue.cdk?.resolver,
            };
        }
        // DataSource resolver
        else if (this.isDataSourceResolverProps(resValue)) {
            resValue = resValue;
            dataSourceKey = resValue.dataSource;
            dataSource = this.dataSourcesByDsKey[dataSourceKey];
            resolverProps = {
                requestMappingTemplate: this.buildMappingTemplate(resValue.requestMapping),
                responseMappingTemplate: this.buildMappingTemplate(resValue.responseMapping),
                ...resValue.cdk?.resolver,
            };
        }
        // Lambda function
        else {
            resValue = resValue;
            lambda = Fn.fromDefinition(scope, `Lambda_${typeName}_${fieldName}`, resValue, this.props.defaults?.function, `Cannot define defaults.function when a Function is passed in to the "${resKey} resolver`);
            dataSourceKey = this.buildDataSourceKey(typeName, fieldName);
            dataSource = this.cdk.graphqlApi.addLambdaDataSource(dataSourceKey, lambda);
            resolverProps = {};
        }
        // Store new data source created
        if (lambda) {
            this.dataSourcesByDsKey[dataSourceKey] = dataSource;
            this.functionsByDsKey[dataSourceKey] = lambda;
        }
        this.dsKeysByResKey[resKey] = dataSourceKey;
        ///////////////////
        // Create resolver
        ///////////////////
        const resolver = this.cdk.graphqlApi.createResolver({
            dataSource,
            typeName,
            fieldName,
            ...resolverProps,
        });
        this.resolversByResKey[resKey] = resolver;
        return lambda;
    }
    isLambdaResolverProps(object) {
        return object.function !== undefined;
    }
    isDataSourceResolverProps(object) {
        return object.dataSource !== undefined;
    }
    normalizeResolverKey(resolverKey) {
        // remove extra spaces in the key
        return resolverKey.split(/\s+/).join(" ");
    }
    buildMappingTemplate(mapping) {
        if (!mapping) {
            return undefined;
        }
        if (mapping.file) {
            return appsync.MappingTemplate.fromFile(mapping.file);
        }
        return appsync.MappingTemplate.fromString(mapping.inline);
    }
    buildDataSourceKey(typeName, fieldName) {
        return `LambdaDS_${typeName}_${fieldName}`;
    }
}
