CmsCdnConfiguration.java

package com.cloudforgeci.api.core.topology;

import com.cloudforgeci.api.core.SystemContext;
import com.cloudforge.core.interfaces.CmsSpec;

import software.amazon.awscdk.Duration;
import software.amazon.awscdk.services.cloudfront.AllowedMethods;
import software.amazon.awscdk.services.cloudfront.BehaviorOptions;
import software.amazon.awscdk.services.cloudfront.CachePolicy;
import software.amazon.awscdk.services.cloudfront.CacheQueryStringBehavior;
import software.amazon.awscdk.services.cloudfront.CacheHeaderBehavior;
import software.amazon.awscdk.services.cloudfront.CacheCookieBehavior;
import software.amazon.awscdk.services.cloudfront.Distribution;
import software.amazon.awscdk.services.cloudfront.OriginProtocolPolicy;
import software.amazon.awscdk.services.cloudfront.OriginRequestPolicy;
import software.amazon.awscdk.services.cloudfront.PriceClass;
import software.amazon.awscdk.services.cloudfront.ViewerProtocolPolicy;
import software.amazon.awscdk.services.cloudfront.IOrigin;
import software.amazon.awscdk.services.cloudfront.origins.HttpOrigin;
import software.amazon.awscdk.services.cloudfront.origins.S3BucketOrigin;
import software.amazon.awscdk.services.route53.ARecord;
import software.amazon.awscdk.services.route53.AaaaRecord;
import software.amazon.awscdk.services.route53.RecordTarget;
import software.amazon.awscdk.services.route53.targets.CloudFrontTarget;
import software.amazon.awscdk.services.s3.Bucket;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * CloudFront CDN configuration for CMS platforms.
 *
 * <p>Configures CloudFront distributions for CMS static assets and media delivery,
 * including:</p>
 * <ul>
 *   <li>S3 origin for media files</li>
 *   <li>ALB origin for dynamic content</li>
 *   <li>Optimized cache behaviors per path</li>
 *   <li>SSL certificate integration</li>
 *   <li>Route53 alias records</li>
 * </ul>
 *
 * <h2>Cache Strategy:</h2>
 * <ul>
 *   <li><strong>Static assets</strong> (CSS, JS, images): Long TTL, cached at edge</li>
 *   <li><strong>Media uploads</strong>: Cached, served from S3</li>
 *   <li><strong>Dynamic content</strong>: No caching, forward all headers</li>
 *   <li><strong>Admin pages</strong>: No caching, bypass CDN</li>
 * </ul>
 *
 * @since 3.1.0
 */
public final class CmsCdnConfiguration {

    private CmsCdnConfiguration() {
        // Utility class
    }

    /**
     * Create CloudFront distribution for CMS with media bucket and ALB origins.
     *
     * @param ctx the SystemContext
     * @param spec the CMS specification
     * @param mediaBucket the S3 media bucket
     * @param albDnsName the ALB DNS name for dynamic content
     * @return CloudFront distribution
     */
    public static Distribution createCmsDistribution(
            SystemContext ctx,
            CmsSpec spec,
            Bucket mediaBucket,
            String albDnsName) {

        // S3 origin for media files
        IOrigin mediaOrigin = S3BucketOrigin.withOriginAccessControl(mediaBucket);

        // ALB origin for dynamic content
        HttpOrigin albOrigin = HttpOrigin.Builder.create(albDnsName)
            .protocolPolicy(OriginProtocolPolicy.HTTPS_ONLY)
            .connectionAttempts(3)
            .connectionTimeout(Duration.seconds(10))
            .readTimeout(Duration.seconds(30))
            .build();

        // Create cache policies
        CachePolicy staticAssetsCachePolicy = createStaticAssetsCachePolicy(ctx);
        CachePolicy mediaCachePolicy = createMediaCachePolicy(ctx);

        // Build behavior map based on CMS type
        Map<String, BehaviorOptions> additionalBehaviors = createCmsBehaviors(
            spec, mediaOrigin, albOrigin, staticAssetsCachePolicy, mediaCachePolicy);

        // Determine domain names
        List<String> domainNames = resolveDomainNames(ctx);

        var builder = Distribution.Builder.create(ctx, "CmsCdn")
            .defaultBehavior(BehaviorOptions.builder()
                .origin(albOrigin)
                .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
                .allowedMethods(AllowedMethods.ALLOW_ALL)
                .cachePolicy(CachePolicy.CACHING_DISABLED)
                .originRequestPolicy(OriginRequestPolicy.ALL_VIEWER)
                .build())
            .additionalBehaviors(additionalBehaviors)
            .priceClass(determinePriceClass(ctx))
            .enabled(true)
            .comment(String.format("CloudForge CDN for %s", spec.displayName()));

        // Add certificate if available
        ctx.cert.get().ifPresent(builder::certificate);

        // Add domain names if available
        if (!domainNames.isEmpty()) {
            builder.domainNames(domainNames);
        }

        return builder.build();
    }

    /**
     * Create CloudFront distribution for media-only CDN (no ALB).
     *
     * @param ctx the SystemContext
     * @param spec the CMS specification
     * @param mediaBucket the S3 media bucket
     * @return CloudFront distribution for media
     */
    public static Distribution createMediaOnlyDistribution(
            SystemContext ctx,
            CmsSpec spec,
            Bucket mediaBucket) {

        IOrigin mediaOrigin = S3BucketOrigin.withOriginAccessControl(mediaBucket);
        CachePolicy mediaCachePolicy = createMediaCachePolicy(ctx);

        List<String> domainNames = resolveMediaDomainNames(ctx);

        var builder = Distribution.Builder.create(ctx, "CmsMediaCdn")
            .defaultBehavior(BehaviorOptions.builder()
                .origin(mediaOrigin)
                .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
                .cachePolicy(mediaCachePolicy)
                .build())
            .priceClass(determinePriceClass(ctx))
            .enabled(true)
            .comment(String.format("CloudForge Media CDN for %s", spec.displayName()));

        ctx.cert.get().ifPresent(builder::certificate);

        if (!domainNames.isEmpty()) {
            builder.domainNames(domainNames);
        }

        return builder.build();
    }

    /**
     * Create cache policy optimized for static assets.
     *
     * @param ctx the SystemContext
     * @return cache policy for static assets
     */
    public static CachePolicy createStaticAssetsCachePolicy(SystemContext ctx) {
        return CachePolicy.Builder.create(ctx, "CmsStaticAssetsCachePolicy")
            .cachePolicyName("CloudForge-CMS-StaticAssets")
            .comment("Cache policy for CMS static assets (CSS, JS, fonts)")
            .defaultTtl(Duration.days(1))
            .maxTtl(Duration.days(365))
            .minTtl(Duration.seconds(1))
            .enableAcceptEncodingGzip(true)
            .enableAcceptEncodingBrotli(true)
            .headerBehavior(CacheHeaderBehavior.none())
            .cookieBehavior(CacheCookieBehavior.none())
            .queryStringBehavior(CacheQueryStringBehavior.none())
            .build();
    }

    /**
     * Create cache policy optimized for media files.
     *
     * @param ctx the SystemContext
     * @return cache policy for media files
     */
    public static CachePolicy createMediaCachePolicy(SystemContext ctx) {
        return CachePolicy.Builder.create(ctx, "CmsMediaCachePolicy")
            .cachePolicyName("CloudForge-CMS-Media")
            .comment("Cache policy for CMS media uploads (images, videos)")
            .defaultTtl(Duration.days(7))
            .maxTtl(Duration.days(365))
            .minTtl(Duration.hours(1))
            .enableAcceptEncodingGzip(true)
            .enableAcceptEncodingBrotli(true)
            .headerBehavior(CacheHeaderBehavior.none())
            .cookieBehavior(CacheCookieBehavior.none())
            .queryStringBehavior(CacheQueryStringBehavior.allowList("v", "ver", "version"))
            .build();
    }

    /**
     * Create CMS-specific cache behaviors driven by the spec's declared path lists.
     *
     * <p>Uses three path groups from the spec (no hardcoded CMS IDs):</p>
     * <ul>
     *   <li>{@link CmsSpec#cdnMediaPaths()} → S3 origin, media cache policy</li>
     *   <li>{@link CmsSpec#cdnStaticPaths()} → ALB origin, static cache policy (long TTL)</li>
     *   <li>{@link CmsSpec#cdnAdminPaths()} → ALB origin, caching disabled, all headers forwarded</li>
     * </ul>
     *
     * <p>If no paths are declared at all (a CMS has not overridden any of the three methods),
     * generic fallback patterns are applied so CDN still functions for unknown plugins.</p>
     *
     * @param spec         the CMS specification
     * @param mediaOrigin  S3 origin for user-uploaded media
     * @param albOrigin    ALB origin for dynamic content
     * @param staticPolicy cache policy for static assets
     * @param mediaPolicy  cache policy for media files
     * @return map of path patterns to behavior options
     */
    private static Map<String, BehaviorOptions> createCmsBehaviors(
            CmsSpec spec,
            IOrigin mediaOrigin,
            HttpOrigin albOrigin,
            CachePolicy staticPolicy,
            CachePolicy mediaPolicy) {

        Map<String, BehaviorOptions> behaviors = new HashMap<>();

        // --- S3 media origin (user uploads, product images, etc.) ---
        List<String> mediaPaths = spec.cdnMediaPaths();
        if (mediaPaths.isEmpty() && spec.supportsS3MediaStorage()) {
            // Fallback for specs that support S3 but haven't declared paths yet
            mediaPaths = List.of("/uploads/*", "/media/*");
        }
        for (String path : mediaPaths) {
            behaviors.put(path, BehaviorOptions.builder()
                .origin(mediaOrigin)
                .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
                .cachePolicy(mediaPolicy)
                .build());
        }

        // --- ALB origin, long-TTL static assets (themes, JS, CSS, fonts) ---
        List<String> staticPaths = spec.cdnStaticPaths();
        if (staticPaths.isEmpty() && !spec.cdnMediaPaths().isEmpty()) {
            // Fallback generic static assets
            staticPaths = List.of("/assets/*", "/static/*");
        }
        for (String path : staticPaths) {
            // Skip if already claimed as a media path (e.g., a CMS that mixes paths)
            if (!behaviors.containsKey(path)) {
                behaviors.put(path, BehaviorOptions.builder()
                    .origin(albOrigin)
                    .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
                    .cachePolicy(staticPolicy)
                    .build());
            }
        }

        // --- ALB origin, caching disabled — admin / back-office areas ---
        for (String path : spec.cdnAdminPaths()) {
            behaviors.put(path, BehaviorOptions.builder()
                .origin(albOrigin)
                .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
                .allowedMethods(AllowedMethods.ALLOW_ALL)
                .cachePolicy(CachePolicy.CACHING_DISABLED)
                .originRequestPolicy(OriginRequestPolicy.ALL_VIEWER)
                .build());
        }

        return behaviors;
    }

    /**
     * Resolve domain names from context.
     *
     * @param ctx the SystemContext
     * @return list of domain names
     */
    private static List<String> resolveDomainNames(SystemContext ctx) {
        if (ctx.cfc.fqdn() != null && !ctx.cfc.fqdn().isBlank()) {
            return List.of(ctx.cfc.fqdn());
        }

        if (ctx.cfc.subdomain() != null && ctx.cfc.domain() != null) {
            return List.of(ctx.cfc.subdomain() + "." + ctx.cfc.domain());
        }

        return List.of();
    }

    /**
     * Resolve media-specific domain names.
     *
     * @param ctx the SystemContext
     * @return list of media domain names
     */
    private static List<String> resolveMediaDomainNames(SystemContext ctx) {
        String baseDomain = ctx.cfc.domain();
        if (baseDomain == null || baseDomain.isBlank()) {
            return List.of();
        }

        return List.of("media." + baseDomain);
    }

    /**
     * Determine price class based on context.
     *
     * @param ctx the SystemContext
     * @return CloudFront price class
     */
    private static PriceClass determinePriceClass(SystemContext ctx) {
        // Use all edge locations for production
        if (ctx.cfc.securityProfile() == com.cloudforge.core.enums.SecurityProfile.PRODUCTION) {
            return PriceClass.PRICE_CLASS_ALL;
        }
        // Use only US/EU/Asia for staging/dev to reduce costs
        return PriceClass.PRICE_CLASS_100;
    }

    /**
     * Create Route53 alias records for CloudFront distribution.
     *
     * @param ctx the SystemContext
     * @param distribution the CloudFront distribution
     */
    public static void createDnsRecords(SystemContext ctx, Distribution distribution) {
        ctx.zone.get().ifPresent(zone -> {
            String recordName = ctx.cfc.subdomain() != null
                ? ctx.cfc.subdomain()
                : "";

            // A record (IPv4)
            ARecord.Builder.create(ctx, "CmsCdnARecord")
                .zone(zone)
                .recordName(recordName)
                .target(RecordTarget.fromAlias(new CloudFrontTarget(distribution)))
                .build();

            // AAAA record (IPv6)
            AaaaRecord.Builder.create(ctx, "CmsCdnAaaaRecord")
                .zone(zone)
                .recordName(recordName)
                .target(RecordTarget.fromAlias(new CloudFrontTarget(distribution)))
                .build();
        });
    }
}