AWSTemplateFormatVersion: '2010-09-09'
Description: >
  DynamoDB Table / Backup Review Remediator.
  Purpose-specific, opt-in remediation module for reviewing DynamoDB tables,
  PITR, TTL, table size, billing mode, deletion protection, and on-demand backups.
  Optional actions are limited to enabling TTL when an attribute is supplied and
  deleting explicitly approved old on-demand backups.

Parameters:
  ProjectName:
    Type: String
    Default: dynamodb-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
      - enable-ttl
      - delete-approved-on-demand-backups
    Description: review-only never changes resources. enable-ttl requires TtlAttributeName. delete-approved-on-demand-backups deletes eligible approved backups.

  MinimumBackupAgeDays:
    Type: Number
    Default: 180
    Description: On-demand backups must be at least this many days old to be considered delete candidates.

  MinimumBackupSizeGiB:
    Type: Number
    Default: 0
    Description: Optional backup size filter. 0 means no minimum size filter.

  TtlAttributeName:
    Type: String
    Default: ''
    Description: TTL attribute name used only when ActionMode=enable-ttl. Leave blank for review-only.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Actions require 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
    Description: Comma-separated tag keys that prevent actions when present.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDelete=true,DoNotModify=true,ComplianceHold=true,LegalHold=true
    Description: Comma-separated key=value tag pairs that prevent actions.

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

  ExcludeTableNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated table name prefixes to exclude.

  SkipTablesWithDeletionProtection:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Skip TTL enablement for tables with deletion protection enabled when true.

  MaxTablesPerRun:
    Type: Number
    Default: 100
    Description: Safety limit for table actions. 0 means no limit.

  MaxBackupsPerRun:
    Type: Number
    Default: 25
    Description: Safety limit for backup deletion actions. 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/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: DynamoDbTableBackupReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadDynamoDb
                Effect: Allow
                Action:
                  - dynamodb:ListTables
                  - dynamodb:DescribeTable
                  - dynamodb:DescribeContinuousBackups
                  - dynamodb:DescribeTimeToLive
                  - dynamodb:ListBackups
                  - dynamodb:DescribeBackup
                  - dynamodb:ListTagsOfResource
                Resource: '*'
              - Sid: SafeDynamoDbActions
                Effect: Allow
                Action:
                  - dynamodb:UpdateTimeToLive
                  - dynamodb:DeleteBackup
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific Lambda for DynamoDB table and backup 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
          MINIMUM_BACKUP_AGE_DAYS: !Ref MinimumBackupAgeDays
          MINIMUM_BACKUP_SIZE_GIB: !Ref MinimumBackupSizeGiB
          TTL_ATTRIBUTE_NAME: !Ref TtlAttributeName
          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_TABLE_NAME_PREFIXES_CSV: !Ref IncludeTableNamePrefixesCsv
          EXCLUDE_TABLE_NAME_PREFIXES_CSV: !Ref ExcludeTableNamePrefixesCsv
          SKIP_TABLES_WITH_DELETION_PROTECTION: !Ref SkipTablesWithDeletionProtection
          MAX_TABLES_PER_RUN: !Ref MaxTablesPerRun
          MAX_BACKUPS_PER_RUN: !Ref MaxBackupsPerRun
          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, EndpointConnectionError, ConnectTimeoutError

          GIB = 1024 ** 3

          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 name_included(name, prefixes):
              if not prefixes:
                  return True
              return any((name or '').startswith(prefix) for prefix in prefixes)

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

          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 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 bytes_to_gib(value):
              try:
                  return round(float(value or 0) / GIB, 3)
              except Exception:
                  return 0.0

          def list_table_names(dynamodb):
              names = []
              paginator = dynamodb.get_paginator('list_tables')
              for page in paginator.paginate():
                  names.extend(page.get('TableNames', []))
              return names

          def list_backups(dynamodb):
              backups = []
              paginator = dynamodb.get_paginator('list_backups')
              for page in paginator.paginate(BackupType='USER'):
                  backups.extend(page.get('BackupSummaries', []))
              return backups

          def get_tags(dynamodb, arn):
              if not arn:
                  return {}
              try:
                  response = dynamodb.list_tags_of_resource(ResourceArn=arn)
                  return {t.get('Key'): t.get('Value', '') for t in response.get('Tags', []) if t.get('Key')}
              except ClientError:
                  return {}

          def describe_table_bundle(dynamodb, table_name):
              table = dynamodb.describe_table(TableName=table_name).get('Table', {})
              try:
                  pitr = dynamodb.describe_continuous_backups(TableName=table_name).get('ContinuousBackupsDescription', {})
              except ClientError:
                  pitr = {}
              try:
                  ttl = dynamodb.describe_time_to_live(TableName=table_name).get('TimeToLiveDescription', {})
              except ClientError:
                  ttl = {}
              return table, pitr, ttl

          def normalize_billing_mode(table):
              summary = table.get('BillingModeSummary') or {}
              mode = summary.get('BillingMode')
              if mode:
                  return mode
              return 'PROVISIONED'

          def table_class(table):
              summary = table.get('TableClassSummary') or {}
              return summary.get('TableClass', 'STANDARD')

          def scan_region(region, settings):
              dynamodb = boto3.client('dynamodb', region_name=region)
              result = {
                  'region': region,
                  'evaluated_tables': 0,
                  'tables': 0,
                  'tables_with_pitr': 0,
                  'tables_without_pitr': 0,
                  'tables_with_ttl': 0,
                  'tables_without_ttl': 0,
                  'tables_with_deletion_protection': 0,
                  'tables_standard_ia': 0,
                  'tables_pay_per_request': 0,
                  'tables_provisioned': 0,
                  'total_table_size_gib': 0.0,
                  'eligible_ttl_enable': 0,
                  'would_enable_ttl': 0,
                  'enabled_ttl': 0,
                  'on_demand_backups': 0,
                  'old_on_demand_backups': 0,
                  'eligible_backup_deletes': 0,
                  'would_delete_backups': 0,
                  'deleted_backups': 0,
                  'total_backup_size_gib': 0.0,
                  'old_backup_size_gib': 0.0,
                  'skipped_table_include_prefix': 0,
                  'skipped_table_exclude_prefix': 0,
                  'skipped_ttl_attribute_missing': 0,
                  'skipped_deletion_protection': 0,
                  'skipped_ttl_already_enabled': 0,
                  'skipped_backup_too_new': 0,
                  'skipped_backup_too_small': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'table_candidates': [],
                  'backup_candidates': [],
                  'enabled_ttl_tables': [],
                  'deleted_backup_arns': []
              }

              try:
                  table_names = list_table_names(dynamodb)
              except (EndpointConnectionError, ConnectTimeoutError) as e:
                  result['failed'] += 1
                  result['failures'].append({'operation': 'ListTables', 'error_code': type(e).__name__, 'error_message': str(e)})
                  return result
              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': 'ListTables', 'error_code': code, 'error_message': message})
                  if code in ('AccessDeniedException', 'AccessDenied') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              for table_name in table_names:
                  if settings['max_tables_per_run'] and (result['would_enable_ttl'] + result['enabled_ttl']) >= settings['max_tables_per_run']:
                      result['stopped_due_to_max_tables_per_run'] = True
                      break

                  if not name_included(table_name, settings['include_table_name_prefixes']):
                      result['skipped_table_include_prefix'] += 1
                      continue
                  if name_excluded(table_name, settings['exclude_table_name_prefixes']):
                      result['skipped_table_exclude_prefix'] += 1
                      result['table_candidates'].append({'table_name': table_name, 'region': region, 'action': 'skipped_table_exclude_prefix'})
                      continue

                  try:
                      table, pitr, ttl = describe_table_bundle(dynamodb, table_name)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({'table_name': table_name, 'operation': 'DescribeTableBundle', 'error_code': code, 'error_message': message})
                      if code in ('AccessDeniedException', 'AccessDenied') or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise
                      continue

                  result['evaluated_tables'] += 1
                  result['tables'] += 1
                  table_arn = table.get('TableArn')
                  tags = get_tags(dynamodb, table_arn)
                  size_gib = bytes_to_gib(table.get('TableSizeBytes'))
                  result['total_table_size_gib'] += size_gib

                  billing_mode = normalize_billing_mode(table)
                  if billing_mode == 'PAY_PER_REQUEST':
                      result['tables_pay_per_request'] += 1
                  else:
                      result['tables_provisioned'] += 1

                  if table_class(table) == 'STANDARD_INFREQUENT_ACCESS':
                      result['tables_standard_ia'] += 1

                  pitr_status = ((pitr.get('PointInTimeRecoveryDescription') or {}).get('PointInTimeRecoveryStatus'))
                  if pitr_status == 'ENABLED':
                      result['tables_with_pitr'] += 1
                  else:
                      result['tables_without_pitr'] += 1

                  ttl_status = ttl.get('TimeToLiveStatus')
                  if ttl_status == 'ENABLED':
                      result['tables_with_ttl'] += 1
                  else:
                      result['tables_without_ttl'] += 1

                  deletion_protection = bool(table.get('DeletionProtectionEnabled', False))
                  if deletion_protection:
                      result['tables_with_deletion_protection'] += 1

                  item = {
                      'table_name': table_name,
                      'table_arn': table_arn,
                      'region': region,
                      'table_status': table.get('TableStatus'),
                      'created_at': str(table.get('CreationDateTime')),
                      'billing_mode': billing_mode,
                      'read_capacity_units': (table.get('ProvisionedThroughput') or {}).get('ReadCapacityUnits'),
                      'write_capacity_units': (table.get('ProvisionedThroughput') or {}).get('WriteCapacityUnits'),
                      'table_class': table_class(table),
                      'item_count': table.get('ItemCount'),
                      'table_size_gib': size_gib,
                      'pitr_status': pitr_status,
                      'ttl_status': ttl_status,
                      'ttl_attribute_name': ttl.get('AttributeName'),
                      'deletion_protection_enabled': deletion_protection,
                      'tags': tags
                  }

                  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['table_candidates'].append(item)
                      continue

                  if settings['action_mode'] != 'enable-ttl':
                      item['action'] = 'review_only_table'
                      result['table_candidates'].append(item)
                      continue

                  if not settings['ttl_attribute_name']:
                      result['skipped_ttl_attribute_missing'] += 1
                      item['action'] = 'skipped_ttl_attribute_missing'
                      result['table_candidates'].append(item)
                      continue

                  if ttl_status == 'ENABLED':
                      result['skipped_ttl_already_enabled'] += 1
                      item['action'] = 'skipped_ttl_already_enabled'
                      result['table_candidates'].append(item)
                      continue

                  if deletion_protection and settings['skip_tables_with_deletion_protection']:
                      result['skipped_deletion_protection'] += 1
                      item['action'] = 'skipped_deletion_protection'
                      result['table_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['table_candidates'].append(item)
                      continue

                  result['eligible_ttl_enable'] += 1
                  item['new_ttl_attribute_name'] = settings['ttl_attribute_name']
                  item['action'] = 'would_enable_ttl' if settings['dry_run'] else 'enable_ttl'

                  if settings['dry_run']:
                      result['would_enable_ttl'] += 1
                      result['table_candidates'].append(item)
                  else:
                      try:
                          dynamodb.update_time_to_live(
                              TableName=table_name,
                              TimeToLiveSpecification={'Enabled': True, 'AttributeName': settings['ttl_attribute_name']}
                          )
                          result['enabled_ttl'] += 1
                          result['enabled_ttl_tables'].append(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({'table_name': table_name, 'operation': 'UpdateTimeToLive', 'error_code': code, 'error_message': message})
                          if code in ('AccessDeniedException', 'AccessDenied') or 'explicit deny' in message.lower():
                              result['access_denied'] += 1
                          else:
                              result['failed'] += 1
                          if not settings['continue_on_access_denied']:
                              raise

              try:
                  backups = list_backups(dynamodb)
              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': 'ListBackups', 'error_code': code, 'error_message': message})
                  if code in ('AccessDeniedException', 'AccessDenied') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  backups = []

              for backup in backups:
                  if settings['max_backups_per_run'] and (result['would_delete_backups'] + result['deleted_backups']) >= settings['max_backups_per_run']:
                      result['stopped_due_to_max_backups_per_run'] = True
                      break

                  backup_arn = backup.get('BackupArn')
                  table_name = backup.get('TableName')
                  if not name_included(table_name, settings['include_table_name_prefixes']):
                      continue
                  if name_excluded(table_name, settings['exclude_table_name_prefixes']):
                      continue

                  result['on_demand_backups'] += 1
                  creation = backup.get('BackupCreationDateTime')
                  backup_age = age_days(creation)
                  backup_size_gib = bytes_to_gib(backup.get('BackupSizeBytes'))
                  result['total_backup_size_gib'] += backup_size_gib

                  item = {
                      'backup_name': backup.get('BackupName'),
                      'backup_arn': backup_arn,
                      'table_name': table_name,
                      'region': region,
                      'backup_status': backup.get('BackupStatus'),
                      'backup_type': backup.get('BackupType'),
                      'created_at': str(creation),
                      'age_days': backup_age,
                      'backup_size_gib': backup_size_gib
                  }

                  if backup.get('BackupStatus') != 'AVAILABLE':
                      item['action'] = 'skipped_backup_not_available'
                      result['backup_candidates'].append(item)
                      continue

                  if backup_age is None or backup_age < settings['minimum_backup_age_days']:
                      result['skipped_backup_too_new'] += 1
                      item['action'] = 'skipped_backup_too_new'
                      result['backup_candidates'].append(item)
                      continue

                  result['old_on_demand_backups'] += 1
                  result['old_backup_size_gib'] += backup_size_gib

                  if settings['minimum_backup_size_gib'] and backup_size_gib < settings['minimum_backup_size_gib']:
                      result['skipped_backup_too_small'] += 1
                      item['action'] = 'skipped_backup_too_small'
                      result['backup_candidates'].append(item)
                      continue

                  tags = get_tags(dynamodb, backup_arn)
                  item['tags'] = tags
                  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['backup_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['backup_candidates'].append(item)
                      continue

                  result['eligible_backup_deletes'] += 1

                  if settings['action_mode'] != 'delete-approved-on-demand-backups':
                      item['action'] = 'review_only_backup'
                      result['backup_candidates'].append(item)
                      continue

                  item['action'] = 'would_delete_backup' if settings['dry_run'] else 'delete_backup'
                  if settings['dry_run']:
                      result['would_delete_backups'] += 1
                      result['backup_candidates'].append(item)
                  else:
                      try:
                          dynamodb.delete_backup(BackupArn=backup_arn)
                          result['deleted_backups'] += 1
                          result['deleted_backup_arns'].append(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({'backup_arn': backup_arn, 'operation': 'DeleteBackup', 'error_code': code, 'error_message': message})
                          if code in ('AccessDeniedException', 'AccessDenied') or 'explicit deny' in message.lower():
                              result['access_denied'] += 1
                          else:
                              result['failed'] += 1
                          if not settings['continue_on_access_denied']:
                              raise

              result['total_table_size_gib'] = round(result['total_table_size_gib'], 3)
              result['total_backup_size_gib'] = round(result['total_backup_size_gib'], 3)
              result['old_backup_size_gib'] = round(result['old_backup_size_gib'], 3)
              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'),
                  'minimum_backup_age_days': env_int('MINIMUM_BACKUP_AGE_DAYS', 180),
                  'minimum_backup_size_gib': env_int('MINIMUM_BACKUP_SIZE_GIB', 0),
                  'ttl_attribute_name': os.environ.get('TTL_ATTRIBUTE_NAME', '').strip(),
                  '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_table_name_prefixes': split_csv(os.environ.get('INCLUDE_TABLE_NAME_PREFIXES_CSV', '')),
                  'exclude_table_name_prefixes': split_csv(os.environ.get('EXCLUDE_TABLE_NAME_PREFIXES_CSV', '')),
                  'skip_tables_with_deletion_protection': env_bool('SKIP_TABLES_WITH_DELETION_PROTECTION', True),
                  'max_tables_per_run': env_int('MAX_TABLES_PER_RUN', 100),
                  'max_backups_per_run': env_int('MAX_BACKUPS_PER_RUN', 25),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  for key in ('dry_run', 'require_approval_tag', 'skip_tables_with_deletion_protection'):
                      if key in event:
                          settings[key] = bool(event[key])
                  if 'action_mode' in event:
                      settings['action_mode'] = str(event['action_mode'])
                  if 'ttl_attribute_name' in event:
                      settings['ttl_attribute_name'] = str(event['ttl_attribute_name']).strip()
                  if 'minimum_backup_age_days' in event:
                      settings['minimum_backup_age_days'] = int(event['minimum_backup_age_days'])
                  if 'minimum_backup_size_gib' in event:
                      settings['minimum_backup_size_gib'] = int(event['minimum_backup_size_gib'])
                  if 'max_tables_per_run' in event:
                      settings['max_tables_per_run'] = int(event['max_tables_per_run'])
                  if 'max_backups_per_run' in event:
                      settings['max_backups_per_run'] = int(event['max_backups_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', 'enable-ttl', 'delete-approved-on-demand-backups'):
                  settings['action_mode'] = 'review-only'

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'minimum_backup_age_days': settings['minimum_backup_age_days'],
                  'minimum_backup_size_gib': settings['minimum_backup_size_gib'],
                  'ttl_attribute_name': settings['ttl_attribute_name'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'skip_tables_with_deletion_protection': settings['skip_tables_with_deletion_protection'],
                  'max_tables_per_run': settings['max_tables_per_run'],
                  'max_backups_per_run': settings['max_backups_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated_tables': 0,
                      'tables': 0,
                      'tables_with_pitr': 0,
                      'tables_without_pitr': 0,
                      'tables_with_ttl': 0,
                      'tables_without_ttl': 0,
                      'tables_with_deletion_protection': 0,
                      'tables_standard_ia': 0,
                      'tables_pay_per_request': 0,
                      'tables_provisioned': 0,
                      'total_table_size_gib': 0.0,
                      'eligible_ttl_enable': 0,
                      'would_enable_ttl': 0,
                      'enabled_ttl': 0,
                      'on_demand_backups': 0,
                      'old_on_demand_backups': 0,
                      'eligible_backup_deletes': 0,
                      'would_delete_backups': 0,
                      'deleted_backups': 0,
                      'total_backup_size_gib': 0.0,
                      'old_backup_size_gib': 0.0,
                      'skipped_table_include_prefix': 0,
                      'skipped_table_exclude_prefix': 0,
                      'skipped_ttl_attribute_missing': 0,
                      'skipped_deletion_protection': 0,
                      'skipped_ttl_already_enabled': 0,
                      'skipped_backup_too_new': 0,
                      'skipped_backup_too_small': 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_tables': 0,
                              'tables': 0,
                              'tables_with_pitr': 0,
                              'tables_without_pitr': 0,
                              'tables_with_ttl': 0,
                              'tables_without_ttl': 0,
                              'tables_with_deletion_protection': 0,
                              'tables_standard_ia': 0,
                              'tables_pay_per_request': 0,
                              'tables_provisioned': 0,
                              'total_table_size_gib': 0.0,
                              'eligible_ttl_enable': 0,
                              'would_enable_ttl': 0,
                              'enabled_ttl': 0,
                              'on_demand_backups': 0,
                              'old_on_demand_backups': 0,
                              'eligible_backup_deletes': 0,
                              'would_delete_backups': 0,
                              'deleted_backups': 0,
                              'total_backup_size_gib': 0.0,
                              'old_backup_size_gib': 0.0,
                              'skipped_table_include_prefix': 0,
                              'skipped_table_exclude_prefix': 0,
                              'skipped_ttl_attribute_missing': 0,
                              'skipped_deletion_protection': 0,
                              'skipped_ttl_already_enabled': 0,
                              'skipped_backup_too_new': 0,
                              'skipped_backup_too_small': 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()}],
                              'table_candidates': [],
                              'backup_candidates': [],
                              'enabled_ttl_tables': [],
                              'deleted_backup_arns': []
                          }
                      summary['region_results'].append(rr)
                      for key in summary['totals']:
                          summary['totals'][key] += rr.get(key, 0) or 0
                  summary['totals']['total_table_size_gib'] = round(summary['totals']['total_table_size_gib'], 3)
                  summary['totals']['total_backup_size_gib'] = round(summary['totals']['total_backup_size_gib'], 3)
                  summary['totals']['old_backup_size_gib'] = round(summary['totals']['old_backup_size_gib'], 3)
              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 DynamoDB Table / Backup Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: DynamoDbReviewTarget
          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'
