AWSTemplateFormatVersion: '2010-09-09'
Description: >
  RDS Manual Snapshot Review Remediator.
  Purpose-specific, opt-in remediation module for reviewing old RDS manual DB
  snapshots and DB cluster snapshots. Optional deletion is guarded by approval
  tags and default skip logic for AWS Backup / Clumio / third-party backup artifacts.

Parameters:
  ProjectName:
    Type: String
    Default: rds-snapshot-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-approved-snapshots
    Description: review-only never changes resources. delete-approved-snapshots deletes eligible approved manual snapshots.

  TargetSnapshotTypes:
    Type: String
    Default: db-snapshots,db-cluster-snapshots
    Description: Comma-separated snapshot categories to scan. Valid values are db-snapshots and db-cluster-snapshots.

  MinimumSnapshotAgeDays:
    Type: Number
    Default: 180
    Description: Manual snapshots must be at least this many days old to be considered delete candidates.

  MinimumAllocatedStorageGiB:
    Type: Number
    Default: 0
    Description: Optional allocated storage filter. 0 means no minimum size filter.

  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,ComplianceHold,LegalHold,BackupID,BackupAssetID,TaskID,OrgID,Vendor,OperationName,OperationType
    Description: Comma-separated tag keys that prevent deletion when present.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDelete=true,DoNotModify=true,ComplianceHold=true,LegalHold=true,Vendor=Clumio,OperationName=Clumio Backup Snapshot,OperationType=RDS Backup,OperationType=EC2 Backup
    Description: Comma-separated key=value tag pairs that prevent deletion.

  ExcludeSnapshotIdentifierPrefixesCsv:
    Type: String
    Default: clumio,clumio-airgap-encrypted,awsbackup,awsbackup-restore-test
    Description: Snapshot identifier prefixes to exclude from deletion candidacy.

  IncludeSnapshotIdentifierPrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated snapshot identifier prefixes to include. Leave blank to include all.

  SkipAwsBackupTaggedSnapshots:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip snapshots carrying AWS Backup style tags when true.

  SkipThirdPartyBackupSnapshots:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip snapshots that appear to be Clumio or third-party backup artifacts.

  MaxSnapshotsPerRun:
    Type: Number
    Default: 50
    Description: Safety limit. 0 means no limit. Recommended actual delete batch is 1 to 10.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/snapshots 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: RdsManualSnapshotReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadRdsSnapshots
                Effect: Allow
                Action:
                  - rds:DescribeDBSnapshots
                  - rds:DescribeDBClusterSnapshots
                  - rds:ListTagsForResource
                Resource: '*'
              - Sid: DeleteApprovedRdsSnapshots
                Effect: Allow
                Action:
                  - rds:DeleteDBSnapshot
                  - rds:DeleteDBClusterSnapshot
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific Lambda for RDS manual snapshot review and guarded deletion.
      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_SNAPSHOT_TYPES: !Ref TargetSnapshotTypes
          MINIMUM_SNAPSHOT_AGE_DAYS: !Ref MinimumSnapshotAgeDays
          MINIMUM_ALLOCATED_STORAGE_GIB: !Ref MinimumAllocatedStorageGiB
          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
          EXCLUDE_SNAPSHOT_IDENTIFIER_PREFIXES_CSV: !Ref ExcludeSnapshotIdentifierPrefixesCsv
          INCLUDE_SNAPSHOT_IDENTIFIER_PREFIXES_CSV: !Ref IncludeSnapshotIdentifierPrefixesCsv
          SKIP_AWS_BACKUP_TAGGED_SNAPSHOTS: !Ref SkipAwsBackupTaggedSnapshots
          SKIP_THIRD_PARTY_BACKUP_SNAPSHOTS: !Ref SkipThirdPartyBackupSnapshots
          MAX_SNAPSHOTS_PER_RUN: !Ref MaxSnapshotsPerRun
          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():
              return datetime.now(timezone.utc)

          def utc_now_iso():
              return utc_now().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 [x.strip() for x in str(value).split(',') if x.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  if key.strip():
                      pairs[key.strip()] = val.strip()
              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 age_days(dt):
              if not dt:
                  return None
              if dt.tzinfo is None:
                  dt = dt.replace(tzinfo=timezone.utc)
              return (utc_now() - dt).days

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

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

          def get_tags(rds, arn):
              if not arn:
                  return {}
              try:
                  response = rds.list_tags_for_resource(ResourceName=arn)
                  return {t.get('Key'): t.get('Value', '') for t in response.get('TagList', []) if t.get('Key')}
              except ClientError:
                  return {}

          def has_ignore_tag(tags, ignore_keys, ignore_pairs):
              for key in ignore_keys:
                  if key in tags:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_pairs.items():
                  if tags.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

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

          def appears_aws_backup_managed(tags, identifier, arn):
              keys = set(tags.keys())
              aws_backup_keys = {
                  'aws:backup:source-resource',
                  'aws:backup:backup-plan-id',
                  'aws:backup:backup-vault-name',
                  'aws:backup:recovery-point-arn'
              }
              if keys.intersection(aws_backup_keys):
                  return True
              ident = (identifier or '').lower()
              arn_lower = (arn or '').lower()
              return ident.startswith('awsbackup') or 'awsbackup' in arn_lower

          def appears_third_party_backup(tags, identifier):
              ident = (identifier or '').lower()
              if ident.startswith('clumio') or 'clumio' in ident:
                  return True
              if tags.get('Vendor', '').lower() == 'clumio':
                  return True
              third_party_keys = {'BackupID', 'BackupAssetID', 'TaskID', 'OrgID', 'OperationName', 'OperationType'}
              return bool(set(tags.keys()).intersection(third_party_keys))

          def allocated_storage(snapshot):
              value = snapshot.get('AllocatedStorage')
              if value is None:
                  return 0
              try:
                  return int(value)
              except Exception:
                  return 0

          def list_db_snapshots(rds):
              snapshots = []
              paginator = rds.get_paginator('describe_db_snapshots')
              for page in paginator.paginate(SnapshotType='manual'):
                  snapshots.extend(page.get('DBSnapshots', []))
              return snapshots

          def list_db_cluster_snapshots(rds):
              snapshots = []
              paginator = rds.get_paginator('describe_db_cluster_snapshots')
              for page in paginator.paginate(SnapshotType='manual'):
                  snapshots.extend(page.get('DBClusterSnapshots', []))
              return snapshots

          def build_db_snapshot_item(region, snap, rds, settings):
              arn = snap.get('DBSnapshotArn')
              identifier = snap.get('DBSnapshotIdentifier')
              db_identifier = snap.get('DBInstanceIdentifier')
              created = snap.get('SnapshotCreateTime')
              tags = get_tags(rds, arn)
              storage_gib = allocated_storage(snap)
              return {
                  'snapshot_category': 'db-snapshot',
                  'snapshot_identifier': identifier,
                  'snapshot_arn': arn,
                  'source_identifier': db_identifier,
                  'region': region,
                  'engine': snap.get('Engine'),
                  'status': snap.get('Status'),
                  'snapshot_type': snap.get('SnapshotType'),
                  'created_at': str(created),
                  'age_days': age_days(created),
                  'allocated_storage_gib': storage_gib,
                  'encrypted': snap.get('Encrypted'),
                  'storage_type': snap.get('StorageType'),
                  'tags': tags
              }

          def build_cluster_snapshot_item(region, snap, rds, settings):
              arn = snap.get('DBClusterSnapshotArn')
              identifier = snap.get('DBClusterSnapshotIdentifier')
              cluster_identifier = snap.get('DBClusterIdentifier')
              created = snap.get('SnapshotCreateTime')
              tags = get_tags(rds, arn)
              storage_gib = allocated_storage(snap)
              return {
                  'snapshot_category': 'db-cluster-snapshot',
                  'snapshot_identifier': identifier,
                  'snapshot_arn': arn,
                  'source_identifier': cluster_identifier,
                  'region': region,
                  'engine': snap.get('Engine'),
                  'status': snap.get('Status'),
                  'snapshot_type': snap.get('SnapshotType'),
                  'created_at': str(created),
                  'age_days': age_days(created),
                  'allocated_storage_gib': storage_gib,
                  'encrypted': snap.get('StorageEncrypted'),
                  'tags': tags
              }

          def evaluate_snapshot(item, result, settings):
              identifier = item['snapshot_identifier']
              tags = item.get('tags', {})
              age = item.get('age_days')
              storage_gib = item.get('allocated_storage_gib') or 0

              if not name_included(identifier, settings['include_snapshot_identifier_prefixes']):
                  result['skipped_include_prefix'] += 1
                  return None

              if name_excluded(identifier, settings['exclude_snapshot_identifier_prefixes']):
                  result['skipped_exclude_prefix'] += 1
                  item['action'] = 'skipped_exclude_prefix'
                  result['candidates'].append(item)
                  return None

              if item.get('status') != 'available':
                  result['skipped_not_available'] += 1
                  item['action'] = 'skipped_not_available'
                  result['candidates'].append(item)
                  return None

              if settings['skip_aws_backup_tagged_snapshots'] and appears_aws_backup_managed(tags, identifier, item.get('snapshot_arn')):
                  result['skipped_aws_backup_managed'] += 1
                  item['action'] = 'skipped_aws_backup_managed'
                  result['candidates'].append(item)
                  return None

              if settings['skip_third_party_backup_snapshots'] and appears_third_party_backup(tags, identifier):
                  result['skipped_third_party_backup'] += 1
                  item['action'] = 'skipped_third_party_backup'
                  result['candidates'].append(item)
                  return None

              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)
                  return None

              if age is None or age < settings['minimum_snapshot_age_days']:
                  result['skipped_too_new'] += 1
                  item['action'] = 'skipped_too_new'
                  result['candidates'].append(item)
                  return None

              result['old_snapshots'] += 1
              result['old_allocated_storage_gib'] += storage_gib

              if settings['minimum_allocated_storage_gib'] and storage_gib < settings['minimum_allocated_storage_gib']:
                  result['skipped_too_small'] += 1
                  item['action'] = 'skipped_too_small'
                  result['candidates'].append(item)
                  return None

              if settings['minimum_allocated_storage_gib'] and storage_gib >= settings['minimum_allocated_storage_gib']:
                  result['large_snapshots'] += 1

              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)
                  return None

              result['eligible'] += 1
              result['eligible_allocated_storage_gib'] += storage_gib
              return item

          def scan_region(region, settings):
              rds = boto3.client('rds', region_name=region)
              result = {
                  'region': region,
                  'evaluated_db_snapshots': 0,
                  'evaluated_db_cluster_snapshots': 0,
                  'manual_snapshots': 0,
                  'old_snapshots': 0,
                  'large_snapshots': 0,
                  'eligible': 0,
                  'would_delete': 0,
                  'deleted': 0,
                  'total_allocated_storage_gib': 0,
                  'old_allocated_storage_gib': 0,
                  'eligible_allocated_storage_gib': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_not_available': 0,
                  'skipped_too_new': 0,
                  'skipped_too_small': 0,
                  'skipped_aws_backup_managed': 0,
                  'skipped_third_party_backup': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'deleted_snapshots': []
              }

              def handle_snapshot(item):
                  if settings['max_snapshots_per_run'] and (result['would_delete'] + result['deleted']) >= settings['max_snapshots_per_run']:
                      result['stopped_due_to_max_snapshots_per_run'] = True
                      return

                  result['manual_snapshots'] += 1
                  result['total_allocated_storage_gib'] += item.get('allocated_storage_gib') or 0

                  eligible_item = evaluate_snapshot(item, result, settings)
                  if not eligible_item:
                      return

                  if settings['action_mode'] != 'delete-approved-snapshots':
                      eligible_item['action'] = 'review_only_candidate'
                      result['candidates'].append(eligible_item)
                      return

                  eligible_item['action'] = 'would_delete' if settings['dry_run'] else 'delete'

                  if settings['dry_run']:
                      result['would_delete'] += 1
                      result['candidates'].append(eligible_item)
                      return

                  try:
                      if eligible_item['snapshot_category'] == 'db-snapshot':
                          rds.delete_db_snapshot(DBSnapshotIdentifier=eligible_item['snapshot_identifier'])
                      else:
                          rds.delete_db_cluster_snapshot(DBClusterSnapshotIdentifier=eligible_item['snapshot_identifier'])
                      result['deleted'] += 1
                      result['deleted_snapshots'].append(eligible_item)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({
                          'snapshot_identifier': eligible_item['snapshot_identifier'],
                          'operation': 'DeleteSnapshot',
                          '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

              try:
                  if 'db-snapshots' in settings['target_snapshot_types']:
                      for snap in list_db_snapshots(rds):
                          result['evaluated_db_snapshots'] += 1
                          handle_snapshot(build_db_snapshot_item(region, snap, rds, settings))

                  if 'db-cluster-snapshots' in settings['target_snapshot_types']:
                      for snap in list_db_cluster_snapshots(rds):
                          result['evaluated_db_cluster_snapshots'] += 1
                          handle_snapshot(build_cluster_snapshot_item(region, snap, rds, settings))

              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': 'SnapshotDiscovery', '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

          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_snapshot_types': split_csv(os.environ.get('TARGET_SNAPSHOT_TYPES', 'db-snapshots,db-cluster-snapshots')),
                  'minimum_snapshot_age_days': env_int('MINIMUM_SNAPSHOT_AGE_DAYS', 180),
                  'minimum_allocated_storage_gib': env_int('MINIMUM_ALLOCATED_STORAGE_GIB', 0),
                  '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', '')),
                  'exclude_snapshot_identifier_prefixes': split_csv(os.environ.get('EXCLUDE_SNAPSHOT_IDENTIFIER_PREFIXES_CSV', '')),
                  'include_snapshot_identifier_prefixes': split_csv(os.environ.get('INCLUDE_SNAPSHOT_IDENTIFIER_PREFIXES_CSV', '')),
                  'skip_aws_backup_tagged_snapshots': env_bool('SKIP_AWS_BACKUP_TAGGED_SNAPSHOTS', True),
                  'skip_third_party_backup_snapshots': env_bool('SKIP_THIRD_PARTY_BACKUP_SNAPSHOTS', True),
                  'max_snapshots_per_run': env_int('MAX_SNAPSHOTS_PER_RUN', 50),
                  '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 'minimum_snapshot_age_days' in event:
                      settings['minimum_snapshot_age_days'] = int(event['minimum_snapshot_age_days'])
                  if 'minimum_allocated_storage_gib' in event:
                      settings['minimum_allocated_storage_gib'] = int(event['minimum_allocated_storage_gib'])
                  if 'target_snapshot_types' in event:
                      settings['target_snapshot_types'] = list(event['target_snapshot_types'])
                  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-approved-snapshots'):
                  settings['action_mode'] = 'review-only'
              settings['target_snapshot_types'] = [x for x in settings['target_snapshot_types'] if x in ('db-snapshots', 'db-cluster-snapshots')]
              if not settings['target_snapshot_types']:
                  settings['target_snapshot_types'] = ['db-snapshots', 'db-cluster-snapshots']

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'target_snapshot_types': settings['target_snapshot_types'],
                  'minimum_snapshot_age_days': settings['minimum_snapshot_age_days'],
                  'minimum_allocated_storage_gib': settings['minimum_allocated_storage_gib'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'skip_aws_backup_tagged_snapshots': settings['skip_aws_backup_tagged_snapshots'],
                  'skip_third_party_backup_snapshots': settings['skip_third_party_backup_snapshots'],
                  'max_snapshots_per_run': settings['max_snapshots_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated_db_snapshots': 0,
                      'evaluated_db_cluster_snapshots': 0,
                      'manual_snapshots': 0,
                      'old_snapshots': 0,
                      'large_snapshots': 0,
                      'eligible': 0,
                      'would_delete': 0,
                      'deleted': 0,
                      'total_allocated_storage_gib': 0,
                      'old_allocated_storage_gib': 0,
                      'eligible_allocated_storage_gib': 0,
                      'skipped_include_prefix': 0,
                      'skipped_exclude_prefix': 0,
                      'skipped_not_available': 0,
                      'skipped_too_new': 0,
                      'skipped_too_small': 0,
                      'skipped_aws_backup_managed': 0,
                      'skipped_third_party_backup': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 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_db_snapshots': 0,
                              'evaluated_db_cluster_snapshots': 0,
                              'manual_snapshots': 0,
                              'old_snapshots': 0,
                              'large_snapshots': 0,
                              'eligible': 0,
                              'would_delete': 0,
                              'deleted': 0,
                              'total_allocated_storage_gib': 0,
                              'old_allocated_storage_gib': 0,
                              'eligible_allocated_storage_gib': 0,
                              'skipped_include_prefix': 0,
                              'skipped_exclude_prefix': 0,
                              'skipped_not_available': 0,
                              'skipped_too_new': 0,
                              'skipped_too_small': 0,
                              'skipped_aws_backup_managed': 0,
                              'skipped_third_party_backup': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'candidates': [],
                              'deleted_snapshots': []
                          }
                      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

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for RDS Manual Snapshot Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: RdsManualSnapshotReviewTarget
          Input: '{"source":"eventbridge-schedule","dry_run":true,"action_mode":"review-only"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  ActionModeSetting:
    Description: Current ActionMode setting.
    Value: !Ref ActionMode

  ManualReviewExample:
    Description: Example manual review invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"review-only","require_approval_tag":false}'' response.json'
