AWSTemplateFormatVersion: '2010-09-09'
Description: >
  API Gateway Cost Review Remediator.
  Review-only module that inventories API Gateway REST APIs, HTTP APIs,
  WebSocket APIs, stages, methods/routes, custom domains, API mappings,
  usage plans, API keys, VPC links, authorizers, and logging settings
  across enabled Regions. This module intentionally does not delete or modify
  API Gateway resources.

Parameters:
  ProjectName:
    Type: String
    Default: api-gateway-cost-review
    Description: Short base name used for created resources.

  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include detailed API Gateway inventory in Lambda response.

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include tags where supported.

  MaxRestApisPerRegion:
    Type: Number
    Default: 100
    MinValue: 1
    Description: Maximum REST APIs to inspect per Region.

  MaxHttpWebSocketApisPerRegion:
    Type: Number
    Default: 100
    MinValue: 1
    Description: Maximum API Gateway v2 APIs to inspect per Region.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated Regions to skip.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  ReviewRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ApiGatewayCostReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ApiGatewayRestRead
                Effect: Allow
                Action:
                  - apigateway:GET
                Resource: '*'
              - Sid: ApiGatewayV2Read
                Effect: Allow
                Action:
                  - apigateway:GET
                  - apigateway:GETV2
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for API Gateway cost visibility.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          INCLUDE_DETAILS: !Ref IncludeDetails
          INCLUDE_TAGS: !Ref IncludeTags
          MAX_REST_APIS_PER_REGION: !Ref MaxRestApisPerRegion
          MAX_HTTP_WEBSOCKET_APIS_PER_REGION: !Ref MaxHttpWebSocketApisPerRegion
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
      Code:
        ZipFile: |
          import json
          import os
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError, EndpointConnectionError

          def utc_now():
              return datetime.now(timezone.utc)

          def env_bool(name, default=False):
              return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "y")

          def parse_bool(value, default=False):
              if value is None:
                  return default
              return str(value).strip().lower() in ("1", "true", "yes", "y")

          def csv_list(value):
              raw = value or ""
              return [x.strip() for x in raw.split(",") if x.strip()]

          def error_record(service, operation, err):
              if isinstance(err, ClientError):
                  return {
                      "service": service,
                      "operation": operation,
                      "error_code": err.response.get("Error", {}).get("Code", "Unknown"),
                      "error_message": err.response.get("Error", {}).get("Message", str(err))
                  }
              return {
                  "service": service,
                  "operation": operation,
                  "error_code": type(err).__name__,
                  "error_message": str(err)
              }

          def is_access_denied(err):
              text = str(err).lower()
              if isinstance(err, ClientError):
                  code = err.response.get("Error", {}).get("Code", "").lower()
                  msg = err.response.get("Error", {}).get("Message", "").lower()
                  text = f"{code} {msg}"
              return "accessdenied" in text or "not authorized" in text or "explicit deny" in text or "service control policy" in text

          def safe_call(region_result, service_name, operation_name, func, default):
              try:
                  return func()
              except EndpointConnectionError as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except ClientError as e:
                  if is_access_denied(e):
                      region_result["access_denied"] += 1
                  else:
                      region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except Exception as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default

          def get_enabled_regions(excluded):
              ec2 = boto3.client("ec2", region_name="us-east-1")
              response = ec2.describe_regions(AllRegions=True)
              regions = []
              for r in response.get("Regions", []):
                  status = r.get("OptInStatus")
                  name = r.get("RegionName")
                  if status in (None, "opt-in-not-required", "opted-in") and name not in excluded:
                      regions.append(name)
              return sorted(regions)

          def paginate(client, operation, result_key, kwargs=None):
              kwargs = dict(kwargs or {})
              try:
                  paginator = client.get_paginator(operation)
                  items = []
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception as paginator_error:
                  if "Operation cannot be paginated" not in str(paginator_error):
                      raise

              method = getattr(client, operation)
              items = []
              position = None
              while True:
                  call_kwargs = dict(kwargs)
                  if position:
                      call_kwargs["position"] = position
                  response = method(**call_kwargs)
                  items.extend(response.get(result_key, []) or [])
                  position = response.get("position")
                  if not position:
                      break
              return items

          def paginate_v2(client, operation, result_key, kwargs=None):
              kwargs = dict(kwargs or {})
              try:
                  paginator = client.get_paginator(operation)
                  items = []
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception as paginator_error:
                  if "Operation cannot be paginated" not in str(paginator_error):
                      raise

              method = getattr(client, operation)
              items = []
              token = None
              while True:
                  call_kwargs = dict(kwargs)
                  if token:
                      call_kwargs["NextToken"] = token
                  response = method(**call_kwargs)
                  items.extend(response.get(result_key, []) or [])
                  token = response.get("NextToken")
                  if not token:
                      break
              return items

          def count_logging_enabled_rest(stage):
              method_settings = stage.get("methodSettings", {}) or {}
              execution_logging_enabled = False
              metrics_enabled = False

              for settings in method_settings.values():
                  level = settings.get("loggingLevel")
                  if level and level != "OFF":
                      execution_logging_enabled = True
                  if settings.get("metricsEnabled"):
                      metrics_enabled = True

              access_log_enabled = bool((stage.get("accessLogSettings") or {}).get("destinationArn"))
              cache_cluster_enabled = bool(stage.get("cacheClusterEnabled"))

              return access_log_enabled, execution_logging_enabled, metrics_enabled, cache_cluster_enabled

          def rest_api_item(api, stages, resources, authorizers, include_tags):
              stage_items = []
              for s in stages:
                  access_log, execution_log, metrics_enabled, cache_enabled = count_logging_enabled_rest(s)
                  stage_items.append({
                      "stage_name": s.get("stageName"),
                      "deployment_id": s.get("deploymentId"),
                      "created_date": s.get("createdDate"),
                      "last_updated_date": s.get("lastUpdatedDate"),
                      "cache_cluster_enabled": cache_enabled,
                      "cache_cluster_size": s.get("cacheClusterSize"),
                      "access_logging_enabled": access_log,
                      "execution_logging_enabled": execution_log,
                      "metrics_enabled": metrics_enabled,
                      "xray_tracing_enabled": s.get("tracingEnabled"),
                      "web_acl_arn": s.get("webAclArn")
                  })

              item = {
                  "id": api.get("id"),
                  "name": api.get("name"),
                  "description": api.get("description"),
                  "created_date": api.get("createdDate"),
                  "api_key_source": api.get("apiKeySource"),
                  "endpoint_types": (api.get("endpointConfiguration") or {}).get("types", []),
                  "minimum_compression_size": api.get("minimumCompressionSize"),
                  "disable_execute_api_endpoint": api.get("disableExecuteApiEndpoint"),
                  "stage_count": len(stages),
                  "resource_count": len(resources),
                  "authorizer_count": len(authorizers),
                  "stages": stage_items
              }
              if include_tags:
                  item["tags"] = api.get("tags", {}) or {}
              return item

          def v2_api_item(api, stages, routes, integrations, authorizers, include_tags):
              stage_items = []
              for s in stages:
                  access_log_enabled = bool((s.get("AccessLogSettings") or {}).get("DestinationArn"))
                  stage_items.append({
                      "stage_name": s.get("StageName"),
                      "deployment_id": s.get("DeploymentId"),
                      "auto_deploy": s.get("AutoDeploy"),
                      "created_date": s.get("CreatedDate"),
                      "last_updated_date": s.get("LastUpdatedDate"),
                      "access_logging_enabled": access_log_enabled,
                      "default_route_settings": s.get("DefaultRouteSettings"),
                      "route_settings_count": len(s.get("RouteSettings", {}) or {})
                  })

              item = {
                  "api_id": api.get("ApiId"),
                  "name": api.get("Name"),
                  "protocol_type": api.get("ProtocolType"),
                  "api_endpoint": api.get("ApiEndpoint"),
                  "created_date": api.get("CreatedDate"),
                  "route_selection_expression": api.get("RouteSelectionExpression"),
                  "disable_execute_api_endpoint": api.get("DisableExecuteApiEndpoint"),
                  "stage_count": len(stages),
                  "route_count": len(routes),
                  "integration_count": len(integrations),
                  "authorizer_count": len(authorizers),
                  "stages": stage_items
              }
              if include_tags:
                  item["tags"] = api.get("Tags", {}) or {}
              return item

          def scan_region(region, settings):
              rr = {
                  "region": region,
                  "rest_apis": 0,
                  "rest_api_stages": 0,
                  "rest_api_resources": 0,
                  "rest_api_methods": 0,
                  "rest_api_authorizers": 0,
                  "rest_api_stages_with_access_logging": 0,
                  "rest_api_stages_with_execution_logging": 0,
                  "rest_api_stages_with_cache_enabled": 0,
                  "rest_api_stages_with_xray_enabled": 0,
                  "http_apis": 0,
                  "websocket_apis": 0,
                  "v2_api_stages": 0,
                  "v2_routes": 0,
                  "v2_integrations": 0,
                  "v2_authorizers": 0,
                  "v2_stages_with_access_logging": 0,
                  "api_keys": 0,
                  "usage_plans": 0,
                  "usage_plan_keys": 0,
                  "rest_custom_domain_names": 0,
                  "v2_custom_domain_names": 0,
                  "api_mappings": 0,
                  "rest_vpc_links": 0,
                  "v2_vpc_links": 0,
                  "regional_apis": 0,
                  "edge_optimized_apis": 0,
                  "private_apis": 0,
                  "review_flags": [],
                  "rest_api_inventory": [],
                  "v2_api_inventory": [],
                  "rest_domain_inventory": [],
                  "v2_domain_inventory": [],
                  "rest_vpc_link_inventory": [],
                  "v2_vpc_link_inventory": [],
                  "usage_plan_inventory": [],
                  "api_key_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              rest = boto3.client("apigateway", region_name=region)
              v2 = boto3.client("apigatewayv2", region_name=region)

              rest_apis = safe_call(
                  rr,
                  "APIGateway",
                  "GetRestApis",
                  lambda: paginate(rest, "get_rest_apis", "items"),
                  []
              )[:settings["max_rest_apis_per_region"]]

              api_keys = safe_call(
                  rr,
                  "APIGateway",
                  "GetApiKeys",
                  lambda: paginate(rest, "get_api_keys", "items", {"includeValues": False}),
                  []
              )

              usage_plans = safe_call(
                  rr,
                  "APIGateway",
                  "GetUsagePlans",
                  lambda: paginate(rest, "get_usage_plans", "items"),
                  []
              )

              rest_domains = safe_call(
                  rr,
                  "APIGateway",
                  "GetDomainNames",
                  lambda: paginate(rest, "get_domain_names", "items"),
                  []
              )

              rest_vpc_links = safe_call(
                  rr,
                  "APIGateway",
                  "GetVpcLinks",
                  lambda: paginate(rest, "get_vpc_links", "items"),
                  []
              )

              rr["rest_apis"] = len(rest_apis)
              rr["api_keys"] = len(api_keys)
              rr["usage_plans"] = len(usage_plans)
              rr["rest_custom_domain_names"] = len(rest_domains)
              rr["rest_vpc_links"] = len(rest_vpc_links)

              for api in rest_apis:
                  endpoint_types = (api.get("endpointConfiguration") or {}).get("types", []) or []
                  if "REGIONAL" in endpoint_types:
                      rr["regional_apis"] += 1
                  if "EDGE" in endpoint_types:
                      rr["edge_optimized_apis"] += 1
                  if "PRIVATE" in endpoint_types:
                      rr["private_apis"] += 1

                  api_id = api.get("id")
                  stages = []
                  resources = []
                  authorizers = []

                  if api_id:
                      stages = safe_call(
                          rr,
                          "APIGateway",
                          "GetStages",
                          lambda aid=api_id: rest.get_stages(restApiId=aid).get("item", []),
                          []
                      )
                      resources = safe_call(
                          rr,
                          "APIGateway",
                          "GetResources",
                          lambda aid=api_id: paginate(rest, "get_resources", "items", {"restApiId": aid}),
                          []
                      )
                      authorizers = safe_call(
                          rr,
                          "APIGateway",
                          "GetAuthorizers",
                          lambda aid=api_id: paginate(rest, "get_authorizers", "items", {"restApiId": aid}),
                          []
                      )

                  rr["rest_api_stages"] += len(stages)
                  rr["rest_api_resources"] += len(resources)
                  rr["rest_api_authorizers"] += len(authorizers)

                  for res in resources:
                      rr["rest_api_methods"] += len(res.get("resourceMethods", {}) or {})

                  for s in stages:
                      access_log, execution_log, metrics_enabled, cache_enabled = count_logging_enabled_rest(s)
                      if access_log:
                          rr["rest_api_stages_with_access_logging"] += 1
                      if execution_log:
                          rr["rest_api_stages_with_execution_logging"] += 1
                      if cache_enabled:
                          rr["rest_api_stages_with_cache_enabled"] += 1
                      if s.get("tracingEnabled"):
                          rr["rest_api_stages_with_xray_enabled"] += 1

                  if settings["include_details"]:
                      rr["rest_api_inventory"].append(
                          rest_api_item(api, stages, resources, authorizers, settings["include_tags"])
                      )

              for plan in usage_plans:
                  plan_id = plan.get("id")
                  keys = []
                  if plan_id:
                      keys = safe_call(
                          rr,
                          "APIGateway",
                          "GetUsagePlanKeys",
                          lambda pid=plan_id: paginate(rest, "get_usage_plan_keys", "items", {"usagePlanId": pid}),
                          []
                      )
                  rr["usage_plan_keys"] += len(keys)
                  if settings["include_details"]:
                      rr["usage_plan_inventory"].append({
                          "id": plan.get("id"),
                          "name": plan.get("name"),
                          "description": plan.get("description"),
                          "api_stages": plan.get("apiStages"),
                          "throttle": plan.get("throttle"),
                          "quota": plan.get("quota"),
                          "usage_plan_key_count": len(keys),
                          "tags": plan.get("tags", {}) if settings["include_tags"] else None
                      })

              if settings["include_details"]:
                  for key in api_keys:
                      rr["api_key_inventory"].append({
                          "id": key.get("id"),
                          "name": key.get("name"),
                          "enabled": key.get("enabled"),
                          "created_date": key.get("createdDate"),
                          "last_updated_date": key.get("lastUpdatedDate"),
                          "stage_keys_count": len(key.get("stageKeys", []) or []),
                          "tags": key.get("tags", {}) if settings["include_tags"] else None
                      })

                  for domain in rest_domains:
                      rr["rest_domain_inventory"].append({
                          "domain_name": domain.get("domainName"),
                          "regional_domain_name": domain.get("regionalDomainName"),
                          "distribution_domain_name": domain.get("distributionDomainName"),
                          "endpoint_types": (domain.get("endpointConfiguration") or {}).get("types"),
                          "security_policy": domain.get("securityPolicy"),
                          "tags": domain.get("tags", {}) if settings["include_tags"] else None
                      })

                  for link in rest_vpc_links:
                      rr["rest_vpc_link_inventory"].append({
                          "id": link.get("id"),
                          "name": link.get("name"),
                          "description": link.get("description"),
                          "target_arns": link.get("targetArns"),
                          "status": link.get("status"),
                          "status_message": link.get("statusMessage"),
                          "tags": link.get("tags", {}) if settings["include_tags"] else None
                      })

              v2_apis = safe_call(
                  rr,
                  "APIGatewayV2",
                  "GetApis",
                  lambda: paginate_v2(v2, "get_apis", "Items"),
                  []
              )[:settings["max_http_websocket_apis_per_region"]]

              v2_domains = safe_call(
                  rr,
                  "APIGatewayV2",
                  "GetDomainNames",
                  lambda: paginate_v2(v2, "get_domain_names", "Items"),
                  []
              )

              v2_vpc_links = safe_call(
                  rr,
                  "APIGatewayV2",
                  "GetVpcLinks",
                  lambda: paginate_v2(v2, "get_vpc_links", "Items"),
                  []
              )

              rr["v2_custom_domain_names"] = len(v2_domains)
              rr["v2_vpc_links"] = len(v2_vpc_links)

              for api in v2_apis:
                  api_id = api.get("ApiId")
                  protocol = api.get("ProtocolType")
                  if protocol == "HTTP":
                      rr["http_apis"] += 1
                  elif protocol == "WEBSOCKET":
                      rr["websocket_apis"] += 1

                  stages = []
                  routes = []
                  integrations = []
                  authorizers = []

                  if api_id:
                      stages = safe_call(
                          rr,
                          "APIGatewayV2",
                          "GetStages",
                          lambda aid=api_id: paginate_v2(v2, "get_stages", "Items", {"ApiId": aid}),
                          []
                      )
                      routes = safe_call(
                          rr,
                          "APIGatewayV2",
                          "GetRoutes",
                          lambda aid=api_id: paginate_v2(v2, "get_routes", "Items", {"ApiId": aid}),
                          []
                      )
                      integrations = safe_call(
                          rr,
                          "APIGatewayV2",
                          "GetIntegrations",
                          lambda aid=api_id: paginate_v2(v2, "get_integrations", "Items", {"ApiId": aid}),
                          []
                      )
                      authorizers = safe_call(
                          rr,
                          "APIGatewayV2",
                          "GetAuthorizers",
                          lambda aid=api_id: paginate_v2(v2, "get_authorizers", "Items", {"ApiId": aid}),
                          []
                      )

                  rr["v2_api_stages"] += len(stages)
                  rr["v2_routes"] += len(routes)
                  rr["v2_integrations"] += len(integrations)
                  rr["v2_authorizers"] += len(authorizers)

                  for s in stages:
                      if (s.get("AccessLogSettings") or {}).get("DestinationArn"):
                          rr["v2_stages_with_access_logging"] += 1

                  if settings["include_details"]:
                      rr["v2_api_inventory"].append(
                          v2_api_item(api, stages, routes, integrations, authorizers, settings["include_tags"])
                      )

              for domain in v2_domains:
                  domain_name = domain.get("DomainName")
                  mappings = []
                  if domain_name:
                      mappings = safe_call(
                          rr,
                          "APIGatewayV2",
                          "GetApiMappings",
                          lambda dn=domain_name: paginate_v2(v2, "get_api_mappings", "Items", {"DomainName": dn}),
                          []
                      )
                  rr["api_mappings"] += len(mappings)

                  if settings["include_details"]:
                      rr["v2_domain_inventory"].append({
                          "domain_name": domain_name,
                          "domain_name_configurations": domain.get("DomainNameConfigurations"),
                          "api_mapping_count": len(mappings),
                          "mutual_tls_authentication": domain.get("MutualTlsAuthentication"),
                          "tags": domain.get("Tags", {}) if settings["include_tags"] else None
                      })

              if settings["include_details"]:
                  for link in v2_vpc_links:
                      rr["v2_vpc_link_inventory"].append({
                          "vpc_link_id": link.get("VpcLinkId"),
                          "name": link.get("Name"),
                          "security_group_ids": link.get("SecurityGroupIds"),
                          "subnet_ids": link.get("SubnetIds"),
                          "vpc_link_status": link.get("VpcLinkStatus"),
                          "vpc_link_status_message": link.get("VpcLinkStatusMessage"),
                          "created_date": link.get("CreatedDate"),
                          "tags": link.get("Tags", {}) if settings["include_tags"] else None
                      })

              if rr["rest_apis"] > 0:
                  rr["review_flags"].append("rest_apis_present")
              if rr["http_apis"] > 0:
                  rr["review_flags"].append("http_apis_present")
              if rr["websocket_apis"] > 0:
                  rr["review_flags"].append("websocket_apis_present")
              if rr["rest_vpc_links"] > 0 or rr["v2_vpc_links"] > 0:
                  rr["review_flags"].append("vpc_links_present")
              if rr["rest_custom_domain_names"] > 0 or rr["v2_custom_domain_names"] > 0:
                  rr["review_flags"].append("custom_domains_present")
              if rr["api_keys"] > 0 or rr["usage_plans"] > 0:
                  rr["review_flags"].append("api_keys_or_usage_plans_present")
              if rr["rest_api_stages_with_cache_enabled"] > 0:
                  rr["review_flags"].append("rest_stage_cache_enabled")
              if rr["rest_api_stages_with_access_logging"] > 0 or rr["v2_stages_with_access_logging"] > 0:
                  rr["review_flags"].append("access_logging_enabled")

              return rr

          def handler(event, context):
              event = event or {}
              excluded = set(csv_list(event.get("excluded_regions_csv") or os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))
              settings = {
                  "include_details": parse_bool(event.get("include_details"), env_bool("INCLUDE_DETAILS", True)),
                  "include_tags": parse_bool(event.get("include_tags"), env_bool("INCLUDE_TAGS", True)),
                  "max_rest_apis_per_region": int(event.get("max_rest_apis_per_region") or os.environ.get("MAX_REST_APIS_PER_REGION", "100")),
                  "max_http_websocket_apis_per_region": int(event.get("max_http_websocket_apis_per_region") or os.environ.get("MAX_HTTP_WEBSOCKET_APIS_PER_REGION", "100"))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "review_only": True,
                  "include_details": settings["include_details"],
                  "include_tags": settings["include_tags"],
                  "excluded_regions": sorted(list(excluded)),
                  "totals": {
                      "regions_scanned": 0,
                      "rest_apis": 0,
                      "rest_api_stages": 0,
                      "rest_api_resources": 0,
                      "rest_api_methods": 0,
                      "rest_api_authorizers": 0,
                      "rest_api_stages_with_access_logging": 0,
                      "rest_api_stages_with_execution_logging": 0,
                      "rest_api_stages_with_cache_enabled": 0,
                      "rest_api_stages_with_xray_enabled": 0,
                      "http_apis": 0,
                      "websocket_apis": 0,
                      "v2_api_stages": 0,
                      "v2_routes": 0,
                      "v2_integrations": 0,
                      "v2_authorizers": 0,
                      "v2_stages_with_access_logging": 0,
                      "api_keys": 0,
                      "usage_plans": 0,
                      "usage_plan_keys": 0,
                      "rest_custom_domain_names": 0,
                      "v2_custom_domain_names": 0,
                      "api_mappings": 0,
                      "rest_vpc_links": 0,
                      "v2_vpc_links": 0,
                      "regional_apis": 0,
                      "edge_optimized_apis": 0,
                      "private_apis": 0,
                      "regions_with_api_gateway_resources": 0,
                      "regions_with_review_flags": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "region_results": [],
                  "failures": []
              }

              try:
                  regions = get_enabled_regions(excluded)
              except Exception as e:
                  summary["totals"]["failed"] += 1
                  summary["failures"].append(error_record("EC2", "DescribeRegions", e))
                  summary["ended_at"] = utc_now().isoformat()
                  print(json.dumps(summary, indent=2, default=str))
                  return json.loads(json.dumps(summary, default=str))

              total_keys = [
                  k for k in summary["totals"].keys()
                  if k not in ("regions_scanned", "regions_with_api_gateway_resources", "regions_with_review_flags")
              ]

              for region in regions:
                  rr = scan_region(region, settings)

                  for key in total_keys:
                      summary["totals"][key] += int(rr.get(key, 0) or 0)

                  if (
                      rr.get("rest_apis", 0) > 0 or
                      rr.get("http_apis", 0) > 0 or
                      rr.get("websocket_apis", 0) > 0 or
                      rr.get("rest_custom_domain_names", 0) > 0 or
                      rr.get("v2_custom_domain_names", 0) > 0 or
                      rr.get("rest_vpc_links", 0) > 0 or
                      rr.get("v2_vpc_links", 0) > 0
                  ):
                      summary["totals"]["regions_with_api_gateway_resources"] += 1

                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_review_flags"] += 1

                  summary["totals"]["regions_scanned"] += 1
                  summary["failures"].extend([dict(f, region=region) for f in rr.get("failures", [])])
                  summary["region_results"].append(rr)

              summary["ended_at"] = utc_now().isoformat()
              print(json.dumps(summary, indent=2, default=str))
              return json.loads(json.dumps(summary, default=str))

  ReviewScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for API Gateway Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: ApiGatewayCostReviewTarget
          Input: '{"source":"eventbridge-schedule"}'

  ReviewSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref ReviewFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt ReviewScheduleRule.Arn

Outputs:
  ReviewFunctionName:
    Description: Lambda function name.
    Value: !Ref ReviewFunction

  RemediatorFunctionName:
    Description: Lambda function name alias.
    Value: !Ref ReviewFunction

  ReviewFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt ReviewFunction.Arn

  ReviewOnly:
    Description: This module is intentionally review-only.
    Value: 'true'
