AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Lambda Function Runtime and Idle Review Remediator.
  Purpose-specific module for reviewing AWS Lambda functions, runtimes, package type,
  code size, last modified date, invocation activity, errors, event source mappings,
  and basic schedule references. Optional remediation can disable EventBridge rules
  that directly target explicitly approved idle Lambda functions. It does not delete
  Lambda functions or code.

Parameters:
  ProjectName:
    Type: String
    Default: lambda-function-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 approved schedule-disable actions.

  ActionMode:
    Type: String
    Default: review-only
    AllowedValues:
      - review-only
      - disable-approved-schedules
    Description: review-only never changes resources. disable-approved-schedules disables EventBridge rules that directly target eligible approved idle functions.

  LookbackDays:
    Type: Number
    Default: 30
    Description: CloudWatch metric lookback window for Invocations and Errors.

  IdleInvocationThreshold:
    Type: Number
    Default: 0
    Description: Functions with invocation count less than or equal to this value over LookbackDays are flagged as idle.

  MinimumFunctionAgeDays:
    Type: Number
    Default: 30
    Description: Function must be at least this many days old before idle/schedule remediation consideration.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Schedule disable requires ApprovalTagKey=ApprovalTagValue on the Lambda function when true.

  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,DoNotDisable,DoNotModify,ComplianceHold,LegalHold,Production,prod
    Description: Comma-separated tag keys that prevent optional schedule disable.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotDisable=true,DoNotModify=true,ComplianceHold=true,LegalHold=true,Environment=Production,Environment=Prod
    Description: Comma-separated key=value tag pairs that prevent optional schedule disable.

  IncludeFunctionNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated Lambda function name prefixes to include. Leave blank to include all.

  ExcludeFunctionNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated Lambda function name prefixes to exclude.

  MaxFunctionsPerRun:
    Type: Number
    Default: 100
    Description: Safety limit. 0 means no limit. Recommended actual schedule-disable 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 for this review tool.

  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: LambdaFunctionReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadLambda
                Effect: Allow
                Action:
                  - lambda:ListFunctions
                  - lambda:GetFunction
                  - lambda:ListTags
                  - lambda:ListEventSourceMappings
                  - lambda:ListVersionsByFunction
                  - lambda:ListAliases
                Resource: '*'
              - Sid: ReadCloudWatchMetrics
                Effect: Allow
                Action:
                  - cloudwatch:GetMetricStatistics
                Resource: '*'
              - Sid: ReadEventBridge
                Effect: Allow
                Action:
                  - events:ListRules
                  - events:ListTargetsByRule
                  - events:DescribeRule
                Resource: '*'
              - Sid: DisableApprovedEventBridgeRules
                Effect: Allow
                Action:
                  - events:DisableRule
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Lambda review tool with guarded optional EventBridge schedule disable for approved idle functions.
      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
          LOOKBACK_DAYS: !Ref LookbackDays
          IDLE_INVOCATION_THRESHOLD: !Ref IdleInvocationThreshold
          MINIMUM_FUNCTION_AGE_DAYS: !Ref MinimumFunctionAgeDays
          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_FUNCTION_NAME_PREFIXES_CSV: !Ref IncludeFunctionNamePrefixesCsv
          EXCLUDE_FUNCTION_NAME_PREFIXES_CSV: !Ref ExcludeFunctionNamePrefixesCsv
          MAX_FUNCTIONS_PER_RUN: !Ref MaxFunctionsPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timedelta, timezone

          import boto3
          from botocore.exceptions import ClientError

          DEPRECATED_RUNTIMES = set([
              'nodejs', 'nodejs4.3', 'nodejs6.10', 'nodejs8.10', 'nodejs10.x', 'nodejs12.x', 'nodejs14.x',
              'python2.7', 'python3.6', 'python3.7', 'python3.8',
              'ruby2.5', 'ruby2.7',
              'java8', 'go1.x',
              'dotnetcore1.0', 'dotnetcore2.0', 'dotnetcore2.1', 'dotnetcore3.1'
          ])

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

          def split_csv(value):
              return [x.strip() for x in str(value or '').split(',') if x.strip()]

          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 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 parse_lambda_time(value):
              if not value:
                  return None
              try:
                  # Lambda LastModified often looks like 2025-01-01T12:00:00.000+0000
                  if value.endswith('+0000'):
                      value = value[:-5] + '+00:00'
                  return datetime.fromisoformat(value)
              except Exception:
                  return None

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

          def enabled_regions(excluded):
              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]

          def paginate(client, operation_name, result_key, **kwargs):
              items = []
              try:
                  paginator = client.get_paginator(operation_name)
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception:
                  request = dict(kwargs)
                  while True:
                      response = getattr(client, operation_name)(**request)
                      items.extend(response.get(result_key, []) or [])
                      token = response.get('NextMarker') or response.get('NextToken')
                      if not token:
                          break
                      if 'Marker' in response or operation_name in ('list_functions',):
                          request['Marker'] = token
                      else:
                          request['NextToken'] = token
                  return items

          def safe_tags(client, arn):
              if not arn:
                  return {}, None
              try:
                  response = client.list_tags(Resource=arn)
                  return response.get('Tags', {}) or {}, None
              except ClientError as e:
                  return {}, e

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

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

          def metric_sum(cw, function_name, metric_name, lookback_days):
              end_time = now()
              start_time = end_time - timedelta(days=lookback_days)
              try:
                  response = cw.get_metric_statistics(
                      Namespace='AWS/Lambda',
                      MetricName=metric_name,
                      Dimensions=[{'Name': 'FunctionName', 'Value': function_name}],
                      StartTime=start_time,
                      EndTime=end_time,
                      Period=86400,
                      Statistics=['Sum']
                  )
                  vals = [float(p.get('Sum', 0.0)) for p in response.get('Datapoints', []) if p.get('Sum') is not None]
                  return int(sum(vals)), len(vals), None
              except ClientError as e:
                  return 0, 0, e

          def list_eventbridge_rules_targeting_lambda(events_client, function_arn):
              matches = []
              rules = paginate(events_client, 'list_rules', 'Rules')
              for rule in rules:
                  rule_name = rule.get('Name')
                  try:
                      targets = paginate(events_client, 'list_targets_by_rule', 'Targets', Rule=rule_name, EventBusName=rule.get('EventBusName', 'default') if rule.get('EventBusName') else 'default')
                  except ClientError:
                      try:
                          targets = paginate(events_client, 'list_targets_by_rule', 'Targets', Rule=rule_name)
                      except ClientError:
                          continue
                  for target in targets:
                      arn = target.get('Arn', '')
                      if arn == function_arn or arn.startswith(function_arn + ':'):
                          matches.append({
                              'rule_name': rule_name,
                              'rule_arn': rule.get('Arn'),
                              'state': rule.get('State'),
                              'schedule_expression': rule.get('ScheduleExpression'),
                              'event_pattern': rule.get('EventPattern'),
                              'event_bus_name': rule.get('EventBusName', 'default'),
                              'target_id': target.get('Id'),
                              'target_arn': arn
                          })
              return matches

          def safe_count_list(client, operation_name, result_key, **kwargs):
              try:
                  return len(paginate(client, operation_name, result_key, **kwargs)), None
              except ClientError as e:
                  return 0, e

          def classify_runtime(runtime, package_type):
              if package_type == 'Image':
                  return 'container_image'
              if runtime in DEPRECATED_RUNTIMES:
                  return 'deprecated_or_legacy_review'
              if not runtime:
                  return 'unknown_runtime'
              return 'standard_review'

          def scan_region(region, settings):
              lam = boto3.client('lambda', region_name=region)
              cw = boto3.client('cloudwatch', region_name=region)
              events = boto3.client('events', region_name=region)

              result = {
                  'region': region,
                  'functions': 0,
                  'package_zip_functions': 0,
                  'package_image_functions': 0,
                  'deprecated_runtime_review': 0,
                  'idle_functions': 0,
                  'functions_with_invocations': 0,
                  'event_source_mappings': 0,
                  'aliases': 0,
                  'versions': 0,
                  'eventbridge_rules_targeting_functions': 0,
                  'eligible_functions_for_schedule_disable': 0,
                  'would_disable_rules': 0,
                  'disabled_rules': 0,
                  'skipped_too_new': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_not_idle': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'tag_read_access_denied': 0,
                  'metric_access_denied': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'function_inventory': [],
                  'candidates': [],
                  'disabled_rule_names': []
              }

              try:
                  functions = paginate(lam, 'list_functions', 'Functions')
              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': 'ListFunctions', 'error_code': code, 'error_message': message})
                  if code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              result['functions'] = len(functions)

              for fn in functions:
                  if settings['max_functions_per_run'] and (result['would_disable_rules'] + result['disabled_rules']) >= settings['max_functions_per_run']:
                      result['stopped_due_to_max_functions_per_run'] = True
                      return result

                  name = fn.get('FunctionName')
                  arn = fn.get('FunctionArn')
                  runtime = fn.get('Runtime')
                  package_type = fn.get('PackageType', 'Zip')
                  last_modified = fn.get('LastModified')
                  fn_age = age_days_from_last_modified(last_modified)
                  runtime_classification = classify_runtime(runtime, package_type)

                  tags, tag_error = safe_tags(lam, arn)
                  if tag_error:
                      code = tag_error.response.get('Error', {}).get('Code', 'Unknown')
                      message = tag_error.response.get('Error', {}).get('Message', str(tag_error))
                      if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in message.lower():
                          result['tag_read_access_denied'] += 1
                      else:
                          result['failed'] += 1

                  invocations, inv_points, inv_err = metric_sum(cw, name, 'Invocations', settings['lookback_days'])
                  errors, err_points, err_err = metric_sum(cw, name, 'Errors', settings['lookback_days'])
                  for err in (inv_err, err_err):
                      if err:
                          code = err.response.get('Error', {}).get('Code', 'Unknown')
                          message = err.response.get('Error', {}).get('Message', str(err))
                          if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in message.lower():
                              result['metric_access_denied'] += 1
                          else:
                              result['failed'] += 1

                  esm_count, esm_err = safe_count_list(lam, 'list_event_source_mappings', 'EventSourceMappings', FunctionName=name)
                  alias_count, alias_err = safe_count_list(lam, 'list_aliases', 'Aliases', FunctionName=name)
                  version_count, version_err = safe_count_list(lam, 'list_versions_by_function', 'Versions', FunctionName=name)
                  for err in (esm_err, alias_err, version_err):
                      if err:
                          code = err.response.get('Error', {}).get('Code', 'Unknown')
                          message = err.response.get('Error', {}).get('Message', str(err))
                          if code in ('AccessDenied', 'AccessDeniedException') or 'explicit deny' in message.lower():
                              result['access_denied'] += 1
                          else:
                              result['failed'] += 1

                  try:
                      eb_rules = list_eventbridge_rules_targeting_lambda(events, arn)
                  except Exception as e:
                      eb_rules = []
                      result['failed'] += 1
                      result['failures'].append({'function_name': name, 'operation': 'ListEventBridgeRulesTargetingFunction', 'error_code': type(e).__name__, 'error_message': str(e)})

                  is_idle = invocations <= settings['idle_invocation_threshold']

                  if package_type == 'Image':
                      result['package_image_functions'] += 1
                  else:
                      result['package_zip_functions'] += 1

                  if runtime_classification == 'deprecated_or_legacy_review':
                      result['deprecated_runtime_review'] += 1

                  if is_idle:
                      result['idle_functions'] += 1
                  else:
                      result['functions_with_invocations'] += 1

                  result['event_source_mappings'] += esm_count
                  result['aliases'] += alias_count
                  result['versions'] += version_count
                  result['eventbridge_rules_targeting_functions'] += len(eb_rules)

                  item = {
                      'function_name': name,
                      'function_arn': arn,
                      'runtime': runtime,
                      'runtime_classification': runtime_classification,
                      'package_type': package_type,
                      'handler': fn.get('Handler'),
                      'code_size_bytes': fn.get('CodeSize'),
                      'memory_size_mb': fn.get('MemorySize'),
                      'timeout_seconds': fn.get('Timeout'),
                      'last_modified': last_modified,
                      'age_days_since_last_modified': fn_age,
                      'description': fn.get('Description'),
                      'architectures': fn.get('Architectures', []),
                      'ephemeral_storage_mb': (fn.get('EphemeralStorage') or {}).get('Size'),
                      'invocations_sum': invocations,
                      'errors_sum': errors,
                      'metric_datapoints': inv_points + err_points,
                      'event_source_mappings_count': esm_count,
                      'aliases_count': alias_count,
                      'versions_count': version_count,
                      'eventbridge_rules_targeting_function': eb_rules,
                      'tags': tags
                  }

                  result['function_inventory'].append(item)

                  if not name_included(name, settings['include_function_prefixes']):
                      result['skipped_include_prefix'] += 1
                      continue

                  if name_excluded(name, settings['exclude_function_prefixes']):
                      result['skipped_exclude_prefix'] += 1
                      item['action'] = 'skipped_exclude_prefix'
                      result['candidates'].append(item)
                      continue

                  if fn_age is None or fn_age < settings['minimum_function_age_days']:
                      result['skipped_too_new'] += 1
                      item['action'] = 'skipped_too_new'
                      result['candidates'].append(item)
                      continue

                  if not is_idle:
                      result['skipped_not_idle'] += 1
                      item['action'] = 'skipped_not_idle'
                      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

                  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

                  if not eb_rules:
                      item['action'] = 'eligible_idle_no_eventbridge_rules'
                      result['candidates'].append(item)
                      continue

                  result['eligible_functions_for_schedule_disable'] += 1

                  if settings['action_mode'] != 'disable-approved-schedules':
                      item['action'] = 'review_only_candidate'
                      result['candidates'].append(item)
                      continue

                  for rule in eb_rules:
                      if rule.get('schedule_expression') is None:
                          continue
                      if settings['dry_run']:
                          result['would_disable_rules'] += 1
                          result['candidates'].append({**item, 'action': 'would_disable_rule', 'rule_to_disable': rule})
                          continue
                      try:
                          kwargs = {'Name': rule['rule_name']}
                          if rule.get('event_bus_name') and rule.get('event_bus_name') != 'default':
                              kwargs['EventBusName'] = rule['event_bus_name']
                          events.disable_rule(**kwargs)
                          result['disabled_rules'] += 1
                          result['disabled_rule_names'].append(rule['rule_name'])
                          result['candidates'].append({**item, 'action': 'disabled_rule', 'rule_disabled': rule})
                      except ClientError as e:
                          code = e.response.get('Error', {}).get('Code', 'Unknown')
                          message = e.response.get('Error', {}).get('Message', str(e))
                          result['failures'].append({'function_name': name, 'rule_name': rule.get('rule_name'), 'operation': 'DisableRule', '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'),
                  'lookback_days': env_int('LOOKBACK_DAYS', 30),
                  'idle_invocation_threshold': env_int('IDLE_INVOCATION_THRESHOLD', 0),
                  'minimum_function_age_days': env_int('MINIMUM_FUNCTION_AGE_DAYS', 30),
                  '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_function_prefixes': split_csv(os.environ.get('INCLUDE_FUNCTION_NAME_PREFIXES_CSV', '')),
                  'exclude_function_prefixes': split_csv(os.environ.get('EXCLUDE_FUNCTION_NAME_PREFIXES_CSV', '')),
                  'max_functions_per_run': env_int('MAX_FUNCTIONS_PER_RUN', 100),
                  '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 'lookback_days' in event:
                      settings['lookback_days'] = int(event['lookback_days'])
                  if 'idle_invocation_threshold' in event:
                      settings['idle_invocation_threshold'] = int(event['idle_invocation_threshold'])
                  if 'minimum_function_age_days' in event:
                      settings['minimum_function_age_days'] = int(event['minimum_function_age_days'])
                  if 'max_functions_per_run' in event:
                      settings['max_functions_per_run'] = int(event['max_functions_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])

              if settings['action_mode'] not in ('review-only', 'disable-approved-schedules'):
                  settings['action_mode'] = 'review-only'

              summary = {
                  'started_at': now().isoformat(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'lookback_days': settings['lookback_days'],
                  'idle_invocation_threshold': settings['idle_invocation_threshold'],
                  'minimum_function_age_days': settings['minimum_function_age_days'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'max_functions_per_run': settings['max_functions_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'functions': 0,
                      'package_zip_functions': 0,
                      'package_image_functions': 0,
                      'deprecated_runtime_review': 0,
                      'idle_functions': 0,
                      'functions_with_invocations': 0,
                      'event_source_mappings': 0,
                      'aliases': 0,
                      'versions': 0,
                      'eventbridge_rules_targeting_functions': 0,
                      'eligible_functions_for_schedule_disable': 0,
                      'would_disable_rules': 0,
                      'disabled_rules': 0,
                      'skipped_too_new': 0,
                      'skipped_not_idle': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'tag_read_access_denied': 0,
                      'metric_access_denied': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = 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,
                              'functions': 0,
                              'package_zip_functions': 0,
                              'package_image_functions': 0,
                              'deprecated_runtime_review': 0,
                              'idle_functions': 0,
                              'functions_with_invocations': 0,
                              'event_source_mappings': 0,
                              'aliases': 0,
                              'versions': 0,
                              'eventbridge_rules_targeting_functions': 0,
                              'eligible_functions_for_schedule_disable': 0,
                              'would_disable_rules': 0,
                              'disabled_rules': 0,
                              'skipped_too_new': 0,
                              'skipped_not_idle': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'tag_read_access_denied': 0,
                              'metric_access_denied': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'function_inventory': [],
                              'candidates': []
                          }
                      summary['region_results'].append(rr)
                      for key in summary['totals']:
                          summary['totals'][key] += int(rr.get(key, 0) or 0)
              finally:
                  summary['ended_at'] = now().isoformat()
                  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 Lambda Function Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: LambdaFunctionReviewTarget
          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
