AWSTemplateFormatVersion: '2010-09-09'
Description: >
  CloudFront Distribution Review Remediator.
  Review-only module for inventorying Amazon CloudFront distributions and identifying
  low-activity, disabled, or potentially costly distributions for owner validation.
  This module intentionally does not disable or delete distributions.

Parameters:
  ProjectName:
    Type: String
    Default: cloudfront-distribution-review
    Description: Short base name used for created resources.

  LookbackDays:
    Type: Number
    Default: 14
    Description: Number of days of CloudWatch metrics to review.

  LowActivityRequestsPerDay:
    Type: Number
    Default: 10
    Description: Distributions averaging fewer than this many requests per day are flagged as low activity.

  MinimumDistributionAgeDays:
    Type: Number
    Default: 30
    Description: Distribution must be at least this old before being flagged as low activity.

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Attempt to read distribution tags.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning when blocked by IAM/SCP.

  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: CloudFrontDistributionReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: ReadCloudFront
                Effect: Allow
                Action:
                  - cloudfront:ListDistributions
                  - cloudfront:GetDistribution
                  - cloudfront:GetDistributionConfig
                  - cloudfront:ListTagsForResource
                Resource: '*'
              - Sid: ReadMetrics
                Effect: Allow
                Action:
                  - cloudwatch:GetMetricStatistics
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for CloudFront distribution inventory and low-activity cost candidate detection.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          LOOKBACK_DAYS: !Ref LookbackDays
          LOW_ACTIVITY_REQUESTS_PER_DAY: !Ref LowActivityRequestsPerDay
          MINIMUM_DISTRIBUTION_AGE_DAYS: !Ref MinimumDistributionAgeDays
          INCLUDE_TAGS: !Ref IncludeTags
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timedelta, timezone

          import boto3
          from botocore.exceptions import ClientError

          def 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 env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def age_days(dt):
              if not dt:
                  return None
              if dt.tzinfo is None:
                  dt = dt.replace(tzinfo=timezone.utc)
              return (now() - dt).days

          def paginate_distributions(client):
              items = []
              marker = None
              while True:
                  args = {}
                  if marker:
                      args['Marker'] = marker
                  response = client.list_distributions(**args)
                  dist_list = response.get('DistributionList', {}) or {}
                  items.extend(dist_list.get('Items', []) or [])
                  if not dist_list.get('IsTruncated'):
                      break
                  marker = dist_list.get('NextMarker')
                  if not marker:
                      break
              return items

          def normalize_tags(tag_response):
              tags = {}
              items = tag_response.get('Tags', {}).get('Items', []) if tag_response else []
              for tag in items:
                  key = tag.get('Key')
                  if key:
                      tags[key] = tag.get('Value', '')
              return tags

          def safe_tags(client, arn):
              if not arn:
                  return {}, None
              try:
                  return normalize_tags(client.list_tags_for_resource(Resource=arn)), None
              except ClientError as e:
                  return {}, e

          def metric_sum(cw, metric_name, dist_id, start_time, end_time):
              try:
                  response = cw.get_metric_statistics(
                      Namespace='AWS/CloudFront',
                      MetricName=metric_name,
                      Dimensions=[
                          {'Name': 'DistributionId', 'Value': dist_id},
                          {'Name': 'Region', 'Value': 'Global'}
                      ],
                      StartTime=start_time,
                      EndTime=end_time,
                      Period=86400,
                      Statistics=['Sum']
                  )
                  vals = [float(p.get('Sum', 0.0)) for p in response.get('Datapoints', []) if p.get('Sum') is not None]
                  return sum(vals), len(vals), None
              except ClientError as e:
                  return 0.0, 0, e

          def metric_average(cw, metric_name, dist_id, start_time, end_time):
              try:
                  response = cw.get_metric_statistics(
                      Namespace='AWS/CloudFront',
                      MetricName=metric_name,
                      Dimensions=[
                          {'Name': 'DistributionId', 'Value': dist_id},
                          {'Name': 'Region', 'Value': 'Global'}
                      ],
                      StartTime=start_time,
                      EndTime=end_time,
                      Period=86400,
                      Statistics=['Average']
                  )
                  vals = [float(p.get('Average', 0.0)) for p in response.get('Datapoints', []) if p.get('Average') is not None]
                  if not vals:
                      return None, len(response.get('Datapoints', []) or []), None
                  return sum(vals) / len(vals), len(vals), None
              except ClientError as e:
                  return None, 0, e

          def get_metrics(dist_id, lookback_days):
              cw = boto3.client('cloudwatch', region_name='us-east-1')
              end_time = now()
              start_time = end_time - timedelta(days=lookback_days)

              requests, request_points, req_err = metric_sum(cw, 'Requests', dist_id, start_time, end_time)
              bytes_downloaded, bd_points, bd_err = metric_sum(cw, 'BytesDownloaded', dist_id, start_time, end_time)
              bytes_uploaded, bu_points, bu_err = metric_sum(cw, 'BytesUploaded', dist_id, start_time, end_time)
              total_error_rate, er_points, er_err = metric_average(cw, 'TotalErrorRate', dist_id, start_time, end_time)

              errors = []
              for e in (req_err, bd_err, bu_err, er_err):
                  if e:
                      errors.append({
                          'error_code': e.response.get('Error', {}).get('Code', 'Unknown'),
                          'error_message': e.response.get('Error', {}).get('Message', str(e))
                      })

              datapoints = request_points + bd_points + bu_points + er_points

              return {
                  'lookback_days': lookback_days,
                  'requests_sum': int(requests),
                  'requests_per_day_estimate': round(float(requests) / max(lookback_days, 1), 4),
                  'bytes_downloaded_sum': int(bytes_downloaded),
                  'bytes_uploaded_sum': int(bytes_uploaded),
                  'total_error_rate_average': round(total_error_rate, 4) if total_error_rate is not None else None,
                  'metric_datapoints': datapoints,
                  'metric_errors': errors
              }

          def safe_count(container):
              if not container:
                  return 0
              return int(container.get('Quantity', 0) or 0)

          def items(container):
              if not container:
                  return []
              return container.get('Items', []) or []

          def summarize_distribution(dist, tags, metrics, settings):
              dist_id = dist.get('Id')
              status = dist.get('Status')
              enabled = dist.get('Enabled')
              domain_name = dist.get('DomainName')
              arn = dist.get('ARN')
              last_modified = dist.get('LastModifiedTime')
              d_age = age_days(last_modified)

              origins = []
              for origin in items(dist.get('Origins')):
                  origins.append({
                      'id': origin.get('Id'),
                      'domain_name': origin.get('DomainName'),
                      'origin_path': origin.get('OriginPath'),
                      'custom_origin': bool(origin.get('CustomOriginConfig')),
                      's3_origin': bool(origin.get('S3OriginConfig')),
                      'origin_access_control_id': origin.get('OriginAccessControlId')
                  })

              aliases = items(dist.get('Aliases'))
              cache_behaviors = dist.get('CacheBehaviors', {}) or {}
              default_cache_behavior = dist.get('DefaultCacheBehavior', {}) or {}
              viewer_cert = dist.get('ViewerCertificate', {}) or {}
              logging_cfg = dist.get('Logging', {}) or {}
              restrictions = dist.get('Restrictions', {}) or {}
              geo = restrictions.get('GeoRestriction', {}) or {}

              flags = []
              if enabled is False:
                  flags.append('disabled_distribution')
              if d_age is not None and d_age >= settings['minimum_distribution_age_days']:
                  if metrics.get('metric_datapoints', 0) > 0 and metrics.get('requests_per_day_estimate', 0) < settings['low_activity_requests_per_day']:
                      flags.append('low_activity')
              if metrics.get('metric_datapoints', 0) == 0:
                  flags.append('no_metric_datapoints')
              if dist.get('PriceClass') == 'PriceClass_All':
                  flags.append('price_class_all')
              if not aliases:
                  flags.append('no_aliases')
              if not logging_cfg.get('Enabled'):
                  flags.append('logging_disabled')
              if dist.get('WebACLId'):
                  flags.append('waf_associated')

              return {
                  'distribution_id': dist_id,
                  'arn': arn,
                  'domain_name': domain_name,
                  'status': status,
                  'enabled': enabled,
                  'last_modified_time': str(last_modified) if last_modified else None,
                  'age_days_since_last_modified': d_age,
                  'price_class': dist.get('PriceClass'),
                  'http_version': dist.get('HttpVersion'),
                  'ipv6_enabled': dist.get('IsIPV6Enabled'),
                  'comment': dist.get('Comment'),
                  'aliases_count': len(aliases),
                  'aliases': aliases,
                  'origins_count': len(origins),
                  'origins': origins,
                  'default_cache_behavior_target_origin_id': default_cache_behavior.get('TargetOriginId'),
                  'cache_behaviors_count': safe_count(cache_behaviors),
                  'viewer_certificate_acm_arn': viewer_cert.get('ACMCertificateArn'),
                  'viewer_certificate_cloudfront_default': viewer_cert.get('CloudFrontDefaultCertificate'),
                  'minimum_protocol_version': viewer_cert.get('MinimumProtocolVersion'),
                  'logging_enabled': logging_cfg.get('Enabled'),
                  'logging_bucket': logging_cfg.get('Bucket'),
                  'logging_prefix': logging_cfg.get('Prefix'),
                  'web_acl_id': dist.get('WebACLId'),
                  'geo_restriction_type': geo.get('RestrictionType'),
                  'geo_restriction_quantity': geo.get('Quantity'),
                  'metrics': metrics,
                  'cost_review_flags': flags,
                  'tags': tags
              }

          def handler(event, context):
              settings = {
                  'lookback_days': env_int('LOOKBACK_DAYS', 14),
                  'low_activity_requests_per_day': env_int('LOW_ACTIVITY_REQUESTS_PER_DAY', 10),
                  'minimum_distribution_age_days': env_int('MINIMUM_DISTRIBUTION_AGE_DAYS', 30),
                  'include_tags': env_bool('INCLUDE_TAGS', True),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True)
              }

              if isinstance(event, dict):
                  if 'lookback_days' in event:
                      settings['lookback_days'] = int(event['lookback_days'])
                  if 'low_activity_requests_per_day' in event:
                      settings['low_activity_requests_per_day'] = int(event['low_activity_requests_per_day'])
                  if 'minimum_distribution_age_days' in event:
                      settings['minimum_distribution_age_days'] = int(event['minimum_distribution_age_days'])

              client = boto3.client('cloudfront')
              summary = {
                  'started_at': now().isoformat(),
                  'review_only': True,
                  'scope': 'global',
                  'lookback_days': settings['lookback_days'],
                  'low_activity_requests_per_day': settings['low_activity_requests_per_day'],
                  'minimum_distribution_age_days': settings['minimum_distribution_age_days'],
                  'totals': {
                      'distributions': 0,
                      'enabled_distributions': 0,
                      'disabled_distributions': 0,
                      'distributions_with_metric_data': 0,
                      'low_activity_candidates': 0,
                      'no_metric_datapoints': 0,
                      'price_class_all': 0,
                      'logging_disabled': 0,
                      'waf_associated': 0,
                      'cost_review_candidates': 0,
                      'tag_read_access_denied': 0,
                      'metric_access_denied': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'distribution_inventory': [],
                  'failures': []
              }

              try:
                  distributions = paginate_distributions(client)
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  msg = e.response.get('Error', {}).get('Message', str(e))
                  summary['failures'].append({'operation': 'ListDistributions', 'error_code': code, 'error_message': msg})
                  if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in msg.lower():
                      summary['totals']['access_denied'] += 1
                  else:
                      summary['totals']['failed'] += 1
                  summary['ended_at'] = now().isoformat()
                  print(json.dumps(summary, indent=2, default=str))
                  return summary

              summary['totals']['distributions'] = len(distributions)

              for dist in distributions:
                  try:
                      tags = {}
                      if settings['include_tags']:
                          tags, tag_err = safe_tags(client, dist.get('ARN'))
                          if tag_err:
                              code = tag_err.response.get('Error', {}).get('Code', 'Unknown')
                              msg = tag_err.response.get('Error', {}).get('Message', str(tag_err))
                              if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in msg.lower():
                                  summary['totals']['tag_read_access_denied'] += 1
                              else:
                                  summary['totals']['failed'] += 1

                      metrics = get_metrics(dist.get('Id'), settings['lookback_days'])
                      for metric_error in metrics.get('metric_errors', []):
                          if metric_error.get('error_code') in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in metric_error.get('error_message', '').lower():
                              summary['totals']['metric_access_denied'] += 1

                      item = summarize_distribution(dist, tags, metrics, settings)
                      summary['distribution_inventory'].append(item)

                      if item.get('enabled'):
                          summary['totals']['enabled_distributions'] += 1
                      else:
                          summary['totals']['disabled_distributions'] += 1

                      if metrics.get('metric_datapoints', 0) > 0:
                          summary['totals']['distributions_with_metric_data'] += 1

                      flags = item.get('cost_review_flags', [])
                      if 'low_activity' in flags:
                          summary['totals']['low_activity_candidates'] += 1
                      if 'no_metric_datapoints' in flags:
                          summary['totals']['no_metric_datapoints'] += 1
                      if 'price_class_all' in flags:
                          summary['totals']['price_class_all'] += 1
                      if 'logging_disabled' in flags:
                          summary['totals']['logging_disabled'] += 1
                      if 'waf_associated' in flags:
                          summary['totals']['waf_associated'] += 1
                      if flags:
                          summary['totals']['cost_review_candidates'] += 1

                  except Exception as e:
                      summary['totals']['failed'] += 1
                      summary['failures'].append({
                          'distribution_id': dist.get('Id'),
                          'error_code': type(e).__name__,
                          'error_message': str(e),
                          'trace': traceback.format_exc()
                      })
                      if not settings['continue_on_access_denied']:
                          raise

              summary['ended_at'] = now().isoformat()
              print(json.dumps(summary, indent=2, default=str))
              return summary

  ReviewScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for CloudFront Distribution Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: CloudFrontDistributionReviewTarget
          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

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

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