AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Load Balancer / Target Group Cleanup Review Remediator.
  Purpose-specific, opt-in remediation module for identifying ELBv2 Application/Network
  Load Balancers and target groups that appear unused. Supports conservative deletion
  only when resources are explicitly approved and pass dependency checks.

Parameters:
  ProjectName:
    Type: String
    Default: lb-tg-cleanup-review
    Description: Short base name used for created resources.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would happen. When false, perform eligible actions.

  ActionMode:
    Type: String
    Default: review-only
    AllowedValues:
      - review-only
      - delete-empty-target-groups
      - delete-unused-load-balancers
    Description: >
      review-only never changes resources. delete-empty-target-groups deletes eligible target groups.
      delete-unused-load-balancers deletes eligible load balancers.

  TargetResourceTypes:
    Type: String
    Default: load-balancers,target-groups
    Description: Comma-separated resource types to evaluate. Valid values are load-balancers and target-groups.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Deletion requires ApprovalTagKey=ApprovalTagValue when true. Strongly recommended.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotDelete,DoNotModify
    Description: Comma-separated tag keys that prevent deletion when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDelete=true,DoNotModify=true
    Description: Comma-separated key=value tag pairs that prevent deletion when present.

  IncludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated resource name prefixes to include. Leave blank to include all.

  ExcludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated resource name prefixes to exclude.

  RequireNoListenersForLoadBalancerDelete:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, skip load balancer deletion if any listener exists.

  RequireNoRegisteredTargetsForTargetGroupDelete:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, skip target group deletion if any target is registered.

  RequireTargetGroupNotReferencedByRules:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, skip target group deletion if it is referenced by any listener rule.

  MaxResourcesPerRun:
    Type: Number
    Default: 25
    Description: Safety limit. 0 means no limit. Recommended actual delete batch is 1 to 5.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/resources 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:
  RemediatorRole:
    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: LoadBalancerTargetGroupCleanupPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadElbv2
                Effect: Allow
                Action:
                  - elasticloadbalancing:DescribeLoadBalancers
                  - elasticloadbalancing:DescribeTargetGroups
                  - elasticloadbalancing:DescribeTargetHealth
                  - elasticloadbalancing:DescribeListeners
                  - elasticloadbalancing:DescribeRules
                  - elasticloadbalancing:DescribeTags
                Resource: '*'
              - Sid: DeleteElbv2
                Effect: Allow
                Action:
                  - elasticloadbalancing:DeleteLoadBalancer
                  - elasticloadbalancing:DeleteTargetGroup
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific Lambda for ELBv2 load balancer and target group cleanup review.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          DRY_RUN: !Ref DryRun
          ACTION_MODE: !Ref ActionMode
          TARGET_RESOURCE_TYPES: !Ref TargetResourceTypes
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          INCLUDE_NAME_PREFIXES_CSV: !Ref IncludeNamePrefixesCsv
          EXCLUDE_NAME_PREFIXES_CSV: !Ref ExcludeNamePrefixesCsv
          REQUIRE_NO_LISTENERS_FOR_LOAD_BALANCER_DELETE: !Ref RequireNoListenersForLoadBalancerDelete
          REQUIRE_NO_REGISTERED_TARGETS_FOR_TARGET_GROUP_DELETE: !Ref RequireNoRegisteredTargetsForTargetGroupDelete
          REQUIRE_TARGET_GROUP_NOT_REFERENCED_BY_RULES: !Ref RequireTargetGroupNotReferencedByRules
          MAX_RESOURCES_PER_RUN: !Ref MaxResourcesPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now_iso():
              return datetime.now(timezone.utc).isoformat()

          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 split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]

          def name_included(name, include_prefixes):
              if not include_prefixes:
                  return True
              return any((name or '').startswith(prefix) for prefix in include_prefixes)

          def name_excluded(name, exclude_prefixes):
              if not exclude_prefixes:
                  return False
              return any((name or '').startswith(prefix) for prefix in exclude_prefixes)

          def tags_for_arns(elbv2, arns):
              if not arns:
                  return {}
              result = {}
              for i in range(0, len(arns), 20):
                  batch = arns[i:i+20]
                  response = elbv2.describe_tags(ResourceArns=batch)
                  for desc in response.get('TagDescriptions', []):
                      result[desc.get('ResourceArn')] = {t.get('Key'): t.get('Value', '') for t in desc.get('Tags', []) if t.get('Key')}
              return result

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def describe_all_load_balancers(elbv2):
              items = []
              paginator = elbv2.get_paginator('describe_load_balancers')
              for page in paginator.paginate():
                  items.extend(page.get('LoadBalancers', []))
              return items

          def describe_all_target_groups(elbv2):
              items = []
              paginator = elbv2.get_paginator('describe_target_groups')
              for page in paginator.paginate():
                  items.extend(page.get('TargetGroups', []))
              return items

          def listeners_for_load_balancer(elbv2, lb_arn):
              listeners = []
              paginator = elbv2.get_paginator('describe_listeners')
              try:
                  for page in paginator.paginate(LoadBalancerArn=lb_arn):
                      listeners.extend(page.get('Listeners', []))
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', '')
                  if code in ('LoadBalancerNotFound', 'ListenerNotFound'):
                      return []
                  raise
              return listeners

          def rules_for_listener(elbv2, listener_arn):
              rules = []
              paginator = elbv2.get_paginator('describe_rules')
              try:
                  for page in paginator.paginate(ListenerArn=listener_arn):
                      rules.extend(page.get('Rules', []))
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', '')
                  if code in ('ListenerNotFound', 'RuleNotFound'):
                      return []
                  raise
              return rules

          def target_health(elbv2, tg_arn):
              try:
                  response = elbv2.describe_target_health(TargetGroupArn=tg_arn)
                  return response.get('TargetHealthDescriptions', [])
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', '')
                  if code == 'TargetGroupNotFound':
                      return []
                  raise

          def build_target_group_rule_reference_map(elbv2, load_balancers):
              refs = {}
              for lb in load_balancers:
                  lb_arn = lb.get('LoadBalancerArn')
                  lb_name = lb.get('LoadBalancerName')
                  for listener in listeners_for_load_balancer(elbv2, lb_arn):
                      listener_arn = listener.get('ListenerArn')
                      for rule in rules_for_listener(elbv2, listener_arn):
                          for action in rule.get('Actions', []):
                              tg_arn = action.get('TargetGroupArn')
                              if tg_arn:
                                  refs.setdefault(tg_arn, []).append({
                                      'load_balancer_name': lb_name,
                                      'load_balancer_arn': lb_arn,
                                      'listener_arn': listener_arn,
                                      'rule_arn': rule.get('RuleArn'),
                                      'priority': rule.get('Priority')
                                  })
                              for tg in (action.get('ForwardConfig') or {}).get('TargetGroups', []):
                                  tg_arn2 = tg.get('TargetGroupArn')
                                  if tg_arn2:
                                      refs.setdefault(tg_arn2, []).append({
                                          'load_balancer_name': lb_name,
                                          'load_balancer_arn': lb_arn,
                                          'listener_arn': listener_arn,
                                          'rule_arn': rule.get('RuleArn'),
                                          'priority': rule.get('Priority')
                                      })
              return refs

          def summarize_targets(targets):
              summary = {'registered': len(targets), 'healthy': 0, 'unhealthy': 0, 'unused': 0, 'other': 0}
              for item in targets:
                  state = ((item.get('TargetHealth') or {}).get('State') or '').lower()
                  if state == 'healthy':
                      summary['healthy'] += 1
                  elif state == 'unhealthy':
                      summary['unhealthy'] += 1
                  elif state == 'unused':
                      summary['unused'] += 1
                  else:
                      summary['other'] += 1
              return summary

          def scan_region(region, settings):
              elbv2 = boto3.client('elbv2', region_name=region)
              result = {
                  'region': region,
                  'evaluated_load_balancers': 0,
                  'evaluated_target_groups': 0,
                  'unused_load_balancers': 0,
                  'empty_target_groups': 0,
                  'eligible': 0,
                  'would_delete': 0,
                  'deleted': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'skipped_has_listeners': 0,
                  'skipped_has_registered_targets': 0,
                  'skipped_target_group_referenced_by_rules': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'deleted_resources': []
              }

              try:
                  lbs = describe_all_load_balancers(elbv2)
                  tgs = describe_all_target_groups(elbv2)
                  tag_map = tags_for_arns(elbv2, [x.get('LoadBalancerArn') for x in lbs] + [x.get('TargetGroupArn') for x in tgs])
                  tg_rule_refs = build_target_group_rule_reference_map(elbv2, lbs)
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'InitialElbv2Discovery', 'error_code': code, 'error_message': message})
                  if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              if 'load-balancers' in settings['target_resource_types']:
                  for lb in lbs:
                      if settings['max_resources_per_run'] and (result['would_delete'] + result['deleted']) >= settings['max_resources_per_run']:
                          result['stopped_due_to_max_resources_per_run'] = True
                          return result

                      lb_arn = lb.get('LoadBalancerArn')
                      name = lb.get('LoadBalancerName')
                      tags = tag_map.get(lb_arn, {})
                      result['evaluated_load_balancers'] += 1

                      item = {
                          'resource_type': 'load-balancer',
                          'name': name,
                          'arn': lb_arn,
                          'region': region,
                          'type': lb.get('Type'),
                          'scheme': lb.get('Scheme'),
                          'state': (lb.get('State') or {}).get('Code'),
                          'vpc_id': lb.get('VpcId'),
                          'dns_name': lb.get('DNSName'),
                          'tags': tags
                      }

                      if not name_included(name, settings['include_name_prefixes']):
                          result['skipped_include_prefix'] += 1
                          continue
                      if name_excluded(name, settings['exclude_name_prefixes']):
                          result['skipped_exclude_prefix'] += 1
                          item['action'] = 'skipped_exclude_prefix'
                          result['candidates'].append(item)
                          continue
                      ignore, reason = has_ignore_tag(tags, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                      if ignore:
                          result['skipped_ignore_tag'] += 1
                          item['action'] = 'skipped_ignore_tag'
                          item['reason'] = reason
                          result['candidates'].append(item)
                          continue

                      try:
                          listeners = listeners_for_load_balancer(elbv2, lb_arn)
                      except ClientError as e:
                          result['failed'] += 1
                          result['failures'].append({'resource': name, 'operation': 'DescribeListeners', 'error_message': str(e)})
                          continue

                      item['listener_count'] = len(listeners)
                      if len(listeners) == 0:
                          result['unused_load_balancers'] += 1
                      else:
                          item['action'] = 'skipped_has_listeners'
                          result['skipped_has_listeners'] += 1
                          result['candidates'].append(item)
                          continue

                      if not has_approval_tag(tags, settings):
                          result['skipped_missing_approval_tag'] += 1
                          item['action'] = 'skipped_missing_approval_tag'
                          item['required_tag'] = settings['approval_tag_key'] + '=' + settings['approval_tag_value']
                          result['candidates'].append(item)
                          continue

                      result['eligible'] += 1
                      if settings['action_mode'] != 'delete-unused-load-balancers':
                          item['action'] = 'review_only_candidate'
                          result['candidates'].append(item)
                          continue
                      item['action'] = 'would_delete' if settings['dry_run'] else 'delete'
                      if settings['dry_run']:
                          result['would_delete'] += 1
                          result['candidates'].append(item)
                      else:
                          try:
                              elbv2.delete_load_balancer(LoadBalancerArn=lb_arn)
                              result['deleted'] += 1
                              result['deleted_resources'].append(item)
                          except ClientError as e:
                              result['failed'] += 1
                              result['failures'].append({'resource': name, 'operation': 'DeleteLoadBalancer', 'error_message': str(e)})

              if 'target-groups' in settings['target_resource_types']:
                  for tg in tgs:
                      if settings['max_resources_per_run'] and (result['would_delete'] + result['deleted']) >= settings['max_resources_per_run']:
                          result['stopped_due_to_max_resources_per_run'] = True
                          return result

                      tg_arn = tg.get('TargetGroupArn')
                      name = tg.get('TargetGroupName')
                      tags = tag_map.get(tg_arn, {})
                      result['evaluated_target_groups'] += 1

                      item = {
                          'resource_type': 'target-group',
                          'name': name,
                          'arn': tg_arn,
                          'region': region,
                          'protocol': tg.get('Protocol'),
                          'port': tg.get('Port'),
                          'target_type': tg.get('TargetType'),
                          'vpc_id': tg.get('VpcId'),
                          'load_balancer_arns': tg.get('LoadBalancerArns', []),
                          'tags': tags
                      }

                      if not name_included(name, settings['include_name_prefixes']):
                          result['skipped_include_prefix'] += 1
                          continue
                      if name_excluded(name, settings['exclude_name_prefixes']):
                          result['skipped_exclude_prefix'] += 1
                          item['action'] = 'skipped_exclude_prefix'
                          result['candidates'].append(item)
                          continue
                      ignore, reason = has_ignore_tag(tags, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                      if ignore:
                          result['skipped_ignore_tag'] += 1
                          item['action'] = 'skipped_ignore_tag'
                          item['reason'] = reason
                          result['candidates'].append(item)
                          continue

                      try:
                          targets = target_health(elbv2, tg_arn)
                      except ClientError as e:
                          result['failed'] += 1
                          result['failures'].append({'resource': name, 'operation': 'DescribeTargetHealth', 'error_message': str(e)})
                          continue

                      target_summary = summarize_targets(targets)
                      refs = tg_rule_refs.get(tg_arn, [])
                      item['target_summary'] = target_summary
                      item['rule_references'] = refs

                      if target_summary['registered'] == 0:
                          result['empty_target_groups'] += 1
                      elif settings['require_no_registered_targets_for_target_group_delete']:
                          result['skipped_has_registered_targets'] += 1
                          item['action'] = 'skipped_has_registered_targets'
                          result['candidates'].append(item)
                          continue

                      if refs and settings['require_target_group_not_referenced_by_rules']:
                          result['skipped_target_group_referenced_by_rules'] += 1
                          item['action'] = 'skipped_target_group_referenced_by_rules'
                          result['candidates'].append(item)
                          continue

                      if not has_approval_tag(tags, settings):
                          result['skipped_missing_approval_tag'] += 1
                          item['action'] = 'skipped_missing_approval_tag'
                          item['required_tag'] = settings['approval_tag_key'] + '=' + settings['approval_tag_value']
                          result['candidates'].append(item)
                          continue

                      result['eligible'] += 1
                      if settings['action_mode'] != 'delete-empty-target-groups':
                          item['action'] = 'review_only_candidate'
                          result['candidates'].append(item)
                          continue
                      item['action'] = 'would_delete' if settings['dry_run'] else 'delete'
                      if settings['dry_run']:
                          result['would_delete'] += 1
                          result['candidates'].append(item)
                      else:
                          try:
                              elbv2.delete_target_group(TargetGroupArn=tg_arn)
                              result['deleted'] += 1
                              result['deleted_resources'].append(item)
                          except ClientError as e:
                              result['failed'] += 1
                              result['failures'].append({'resource': name, 'operation': 'DeleteTargetGroup', 'error_message': str(e)})

              return result

          def handler(event, context):
              settings = {
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'action_mode': os.environ.get('ACTION_MODE', 'review-only'),
                  'target_resource_types': split_csv(os.environ.get('TARGET_RESOURCE_TYPES', 'load-balancers,target-groups')),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', True),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'include_name_prefixes': split_csv(os.environ.get('INCLUDE_NAME_PREFIXES_CSV', '')),
                  'exclude_name_prefixes': split_csv(os.environ.get('EXCLUDE_NAME_PREFIXES_CSV', '')),
                  'require_no_listeners_for_load_balancer_delete': env_bool('REQUIRE_NO_LISTENERS_FOR_LOAD_BALANCER_DELETE', True),
                  'require_no_registered_targets_for_target_group_delete': env_bool('REQUIRE_NO_REGISTERED_TARGETS_FOR_TARGET_GROUP_DELETE', True),
                  'require_target_group_not_referenced_by_rules': env_bool('REQUIRE_TARGET_GROUP_NOT_REFERENCED_BY_RULES', True),
                  'max_resources_per_run': env_int('MAX_RESOURCES_PER_RUN', 25),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'action_mode' in event:
                      settings['action_mode'] = str(event['action_mode'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'target_resource_types' in event:
                      settings['target_resource_types'] = list(event['target_resource_types'])
                  if 'include_name_prefixes' in event:
                      settings['include_name_prefixes'] = list(event['include_name_prefixes'])
                  if 'exclude_name_prefixes' in event:
                      settings['exclude_name_prefixes'] = list(event['exclude_name_prefixes'])
                  if 'max_resources_per_run' in event:
                      settings['max_resources_per_run'] = int(event['max_resources_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])

              if settings['action_mode'] not in ('review-only', 'delete-empty-target-groups', 'delete-unused-load-balancers'):
                  settings['action_mode'] = 'review-only'
              settings['target_resource_types'] = [x for x in settings['target_resource_types'] if x in ('load-balancers', 'target-groups')]
              if not settings['target_resource_types']:
                  settings['target_resource_types'] = ['load-balancers', 'target-groups']

              summary = {
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'include_name_prefixes': settings['include_name_prefixes'],
                  'exclude_name_prefixes': settings['exclude_name_prefixes'],
                  'max_resources_per_run': settings['max_resources_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated_load_balancers': 0,
                      'evaluated_target_groups': 0,
                      'unused_load_balancers': 0,
                      'empty_target_groups': 0,
                      'eligible': 0,
                      'would_delete': 0,
                      'deleted': 0,
                      'skipped_include_prefix': 0,
                      'skipped_exclude_prefix': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'skipped_has_listeners': 0,
                      'skipped_has_registered_targets': 0,
                      'skipped_target_group_referenced_by_rules': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions

                  for region in regions:
                      try:
                          rr = scan_region(region, settings)
                      except Exception as e:
                          rr = {
                              'region': region,
                              'evaluated_load_balancers': 0,
                              'evaluated_target_groups': 0,
                              'unused_load_balancers': 0,
                              'empty_target_groups': 0,
                              'eligible': 0,
                              'would_delete': 0,
                              'deleted': 0,
                              'skipped_include_prefix': 0,
                              'skipped_exclude_prefix': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'skipped_has_listeners': 0,
                              'skipped_has_registered_targets': 0,
                              'skipped_target_group_referenced_by_rules': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [
                                  {
                                      'error_code': type(e).__name__,
                                      'error_message': str(e),
                                      'trace': traceback.format_exc()
                                  }
                              ],
                              'candidates': [],
                              'deleted_resources': []
                          }

                      summary['region_results'].append(rr)

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

              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))

              return summary
