AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Global Accelerator Review Remediator.
  Review-only module for inventorying AWS Global Accelerator accelerators,
  listeners, endpoint groups, and endpoints. Identifies disabled or low-activity
  accelerators for owner validation. This module intentionally does not disable
  or delete accelerators.

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

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

  LowActivityNewFlowsPerDay:
    Type: Number
    Default: 10
    Description: Accelerators averaging fewer than this many new flows per day are flagged as low activity.

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

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Attempt to read accelerator 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: GlobalAcceleratorReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: ReadGlobalAccelerator
                Effect: Allow
                Action:
                  - globalaccelerator:ListAccelerators
                  - globalaccelerator:DescribeAccelerator
                  - globalaccelerator:ListListeners
                  - globalaccelerator:DescribeListener
                  - globalaccelerator:ListEndpointGroups
                  - globalaccelerator:DescribeEndpointGroup
                  - globalaccelerator:ListTagsForResource
                Resource: '*'
              - Sid: ReadMetrics
                Effect: Allow
                Action:
                  - cloudwatch:GetMetricStatistics
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for Global Accelerator 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_NEW_FLOWS_PER_DAY: !Ref LowActivityNewFlowsPerDay
          MINIMUM_ACCELERATOR_AGE_DAYS: !Ref MinimumAcceleratorAgeDays
          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(client, operation_name, result_key, **kwargs):
              items = []
              request = dict(kwargs)
              while True:
                  response = getattr(client, operation_name)(**request)
                  items.extend(response.get(result_key, []) or [])
                  token = response.get('NextToken')
                  if not token:
                      break
                  request['NextToken'] = token
              return items

          def normalize_tags(tag_response):
              return {t.get('Key'): t.get('Value', '') for t in (tag_response.get('Tags', []) or []) if t.get('Key')}

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

          def metric_sum(cw, metric_name, accelerator, start_time, end_time):
              dimensions = [
                  {'Name': 'Accelerator', 'Value': accelerator}
              ]
              try:
                  response = cw.get_metric_statistics(
                      Namespace='AWS/GlobalAccelerator',
                      MetricName=metric_name,
                      Dimensions=dimensions,
                      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 get_metrics(accelerator_arn, lookback_days):
              cw = boto3.client('cloudwatch', region_name='us-west-2')
              end_time = now()
              start_time = end_time - timedelta(days=lookback_days)

              # Global Accelerator CloudWatch metrics are global metrics. The Accelerator
              # dimension is commonly the accelerator ARN.
              metrics_to_query = [
                  'NewFlowCount',
                  'ProcessedBytesIn',
                  'ProcessedBytesOut'
              ]

              result = {
                  'lookback_days': lookback_days,
                  'new_flow_count_sum': 0,
                  'new_flows_per_day_estimate': 0.0,
                  'processed_bytes_in_sum': 0,
                  'processed_bytes_out_sum': 0,
                  'metric_datapoints': 0,
                  'metric_errors': []
              }

              for name in metrics_to_query:
                  total, points, err = metric_sum(cw, name, accelerator_arn, start_time, end_time)
                  result['metric_datapoints'] += points
                  if name == 'NewFlowCount':
                      result['new_flow_count_sum'] = int(total)
                      result['new_flows_per_day_estimate'] = round(float(total) / max(lookback_days, 1), 4)
                  elif name == 'ProcessedBytesIn':
                      result['processed_bytes_in_sum'] = int(total)
                  elif name == 'ProcessedBytesOut':
                      result['processed_bytes_out_sum'] = int(total)

                  if err:
                      result['metric_errors'].append({
                          'metric_name': name,
                          'error_code': err.response.get('Error', {}).get('Code', 'Unknown'),
                          'error_message': err.response.get('Error', {}).get('Message', str(err))
                      })

              return result

          def endpoint_summary(ep):
              return {
                  'endpoint_id': ep.get('EndpointId'),
                  'weight': ep.get('Weight'),
                  'health_state': ep.get('HealthState'),
                  'client_ip_preservation_enabled': ep.get('ClientIPPreservationEnabled')
              }

          def scan_accelerator(client, acc, settings):
              arn = acc.get('AcceleratorArn')
              acc_name = acc.get('Name')
              acc_enabled = acc.get('Enabled')
              acc_status = acc.get('Status')
              created = acc.get('CreatedTime')
              modified = acc.get('LastModifiedTime')
              acc_age = age_days(created)

              listeners = []
              endpoint_groups = []
              total_endpoints = 0
              unhealthy_endpoints = 0

              listener_items = paginate(client, 'list_listeners', 'Listeners', AcceleratorArn=arn)
              for listener in listener_items:
                  listener_arn = listener.get('ListenerArn')
                  listener_record = {
                      'listener_arn': listener_arn,
                      'client_affinity': listener.get('ClientAffinity'),
                      'protocol': listener.get('Protocol'),
                      'port_ranges': listener.get('PortRanges', []),
                      'endpoint_groups_count': 0
                  }

                  groups = paginate(client, 'list_endpoint_groups', 'EndpointGroups', ListenerArn=listener_arn)
                  listener_record['endpoint_groups_count'] = len(groups)
                  listeners.append(listener_record)

                  for group in groups:
                      eps = [endpoint_summary(ep) for ep in (group.get('EndpointDescriptions', []) or [])]
                      total_endpoints += len(eps)
                      unhealthy_endpoints += len([ep for ep in eps if ep.get('health_state') and ep.get('health_state') != 'HEALTHY'])
                      endpoint_groups.append({
                          'endpoint_group_arn': group.get('EndpointGroupArn'),
                          'endpoint_group_region': group.get('EndpointGroupRegion'),
                          'traffic_dial_percentage': group.get('TrafficDialPercentage'),
                          'health_check_port': group.get('HealthCheckPort'),
                          'health_check_protocol': group.get('HealthCheckProtocol'),
                          'health_check_path': group.get('HealthCheckPath'),
                          'health_check_interval_seconds': group.get('HealthCheckIntervalSeconds'),
                          'threshold_count': group.get('ThresholdCount'),
                          'endpoints_count': len(eps),
                          'endpoints': eps
                      })

              tags = {}
              tag_error = None
              if settings['include_tags']:
                  tags, tag_error = safe_tags(client, arn)

              metrics = get_metrics(arn, settings['lookback_days'])

              flags = []
              if acc_enabled is False:
                  flags.append('disabled_accelerator')
              if acc_status and acc_status != 'DEPLOYED':
                  flags.append('non_deployed_status')
              if len(listener_items) == 0:
                  flags.append('no_listeners')
              if total_endpoints == 0:
                  flags.append('no_endpoints')
              if unhealthy_endpoints:
                  flags.append('unhealthy_endpoints')
              if acc_age is not None and acc_age >= settings['minimum_accelerator_age_days']:
                  if metrics.get('metric_datapoints', 0) > 0 and metrics.get('new_flows_per_day_estimate', 0) < settings['low_activity_new_flows_per_day']:
                      flags.append('low_activity')
              if metrics.get('metric_datapoints', 0) == 0:
                  flags.append('no_metric_datapoints')

              return {
                  'accelerator_arn': arn,
                  'name': acc_name,
                  'dns_name': acc.get('DnsName'),
                  'ip_address_type': acc.get('IpAddressType'),
                  'enabled': acc_enabled,
                  'status': acc_status,
                  'created_time': str(created) if created else None,
                  'last_modified_time': str(modified) if modified else None,
                  'age_days': acc_age,
                  'ip_sets': acc.get('IpSets', []),
                  'listeners_count': len(listeners),
                  'endpoint_groups_count': len(endpoint_groups),
                  'endpoints_count': total_endpoints,
                  'unhealthy_endpoints_count': unhealthy_endpoints,
                  'listeners': listeners,
                  'endpoint_groups': endpoint_groups,
                  'metrics': metrics,
                  'cost_review_flags': flags,
                  'tags': tags,
                  'tag_read_error': {
                      'error_code': tag_error.response.get('Error', {}).get('Code', 'Unknown'),
                      'error_message': tag_error.response.get('Error', {}).get('Message', str(tag_error))
                  } if tag_error else None
              }

          def handler(event, context):
              settings = {
                  'lookback_days': env_int('LOOKBACK_DAYS', 14),
                  'low_activity_new_flows_per_day': env_int('LOW_ACTIVITY_NEW_FLOWS_PER_DAY', 10),
                  'minimum_accelerator_age_days': env_int('MINIMUM_ACCELERATOR_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_new_flows_per_day' in event:
                      settings['low_activity_new_flows_per_day'] = int(event['low_activity_new_flows_per_day'])
                  if 'minimum_accelerator_age_days' in event:
                      settings['minimum_accelerator_age_days'] = int(event['minimum_accelerator_age_days'])

              client = boto3.client('globalaccelerator', region_name='us-west-2')
              summary = {
                  'started_at': now().isoformat(),
                  'review_only': True,
                  'scope': 'global',
                  'lookback_days': settings['lookback_days'],
                  'low_activity_new_flows_per_day': settings['low_activity_new_flows_per_day'],
                  'minimum_accelerator_age_days': settings['minimum_accelerator_age_days'],
                  'totals': {
                      'accelerators': 0,
                      'enabled_accelerators': 0,
                      'disabled_accelerators': 0,
                      'listeners': 0,
                      'endpoint_groups': 0,
                      'endpoints': 0,
                      'unhealthy_endpoints': 0,
                      'accelerators_with_metric_data': 0,
                      'low_activity_candidates': 0,
                      'no_metric_datapoints': 0,
                      'no_listener_candidates': 0,
                      'no_endpoint_candidates': 0,
                      'cost_review_candidates': 0,
                      'tag_read_access_denied': 0,
                      'metric_access_denied': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'accelerator_inventory': [],
                  'failures': []
              }

              try:
                  accelerators = paginate(client, 'list_accelerators', 'Accelerators')
              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': 'ListAccelerators', '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']['accelerators'] = len(accelerators)

              for acc in accelerators:
                  try:
                      item = scan_accelerator(client, acc, settings)
                      summary['accelerator_inventory'].append(item)

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

                      summary['totals']['listeners'] += int(item.get('listeners_count', 0) or 0)
                      summary['totals']['endpoint_groups'] += int(item.get('endpoint_groups_count', 0) or 0)
                      summary['totals']['endpoints'] += int(item.get('endpoints_count', 0) or 0)
                      summary['totals']['unhealthy_endpoints'] += int(item.get('unhealthy_endpoints_count', 0) or 0)

                      if item.get('tag_read_error'):
                          code = item['tag_read_error'].get('error_code')
                          msg = item['tag_read_error'].get('error_message', '')
                          if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in msg.lower():
                              summary['totals']['tag_read_access_denied'] += 1
                          else:
                              summary['totals']['failed'] += 1

                      metrics = item.get('metrics', {})
                      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

                      if metrics.get('metric_datapoints', 0) > 0:
                          summary['totals']['accelerators_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 'no_listeners' in flags:
                          summary['totals']['no_listener_candidates'] += 1
                      if 'no_endpoints' in flags:
                          summary['totals']['no_endpoint_candidates'] += 1
                      if flags:
                          summary['totals']['cost_review_candidates'] += 1

                  except Exception as e:
                      summary['totals']['failed'] += 1
                      summary['failures'].append({
                          'accelerator_arn': acc.get('AcceleratorArn'),
                          '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 Global Accelerator Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: GlobalAcceleratorReviewTarget
          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'
