AWSTemplateFormatVersion: '2010-09-09'
Description: >
  EventBridge and Scheduler Cost Review Remediator.
  Review-only module that inventories EventBridge rules, targets, event buses,
  archives, replays, API destinations, connections, and EventBridge Scheduler
  schedules/groups across enabled Regions. This module intentionally does not
  delete, disable, or modify EventBridge or Scheduler resources.

Parameters:
  ProjectName:
    Type: String
    Default: eventbridge-scheduler-cost-review
    Description: Short base name used for created resources.

  IncludeDetails:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include detailed EventBridge/Scheduler inventory in Lambda response.

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include tags where supported.

  MaxRulesPerRegion:
    Type: Number
    Default: 500
    MinValue: 1
    Description: Maximum EventBridge rules to inspect per Region.

  MaxSchedulesPerRegion:
    Type: Number
    Default: 1000
    MinValue: 1
    Description: Maximum EventBridge Scheduler schedules to inspect per Region.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated Regions to skip.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create an enabled EventBridge schedule.

  ScheduleExpression:
    Type: String
    Default: rate(30 days)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  ReviewRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: EventBridgeSchedulerCostReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: EventBridgeRead
                Effect: Allow
                Action:
                  - events:ListRules
                  - events:DescribeRule
                  - events:ListTargetsByRule
                  - events:ListEventBuses
                  - events:DescribeEventBus
                  - events:ListArchives
                  - events:DescribeArchive
                  - events:ListReplays
                  - events:DescribeReplay
                  - events:ListApiDestinations
                  - events:DescribeApiDestination
                  - events:ListConnections
                  - events:DescribeConnection
                  - events:ListTagsForResource
                Resource: '*'
              - Sid: SchedulerRead
                Effect: Allow
                Action:
                  - scheduler:ListSchedules
                  - scheduler:GetSchedule
                  - scheduler:ListScheduleGroups
                  - scheduler:GetScheduleGroup
                  - scheduler:ListTagsForResource
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for EventBridge and Scheduler cost visibility.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt ReviewRole.Arn
      Environment:
        Variables:
          INCLUDE_DETAILS: !Ref IncludeDetails
          INCLUDE_TAGS: !Ref IncludeTags
          MAX_RULES_PER_REGION: !Ref MaxRulesPerRegion
          MAX_SCHEDULES_PER_REGION: !Ref MaxSchedulesPerRegion
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
      Code:
        ZipFile: |
          import json
          import os
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError, EndpointConnectionError

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

          def env_bool(name, default=False):
              return os.environ.get(name, str(default)).strip().lower() in ("1", "true", "yes", "y")

          def parse_bool(value, default=False):
              if value is None:
                  return default
              return str(value).strip().lower() in ("1", "true", "yes", "y")

          def csv_list(value):
              raw = value or ""
              return [x.strip() for x in raw.split(",") if x.strip()]

          def error_record(service, operation, err):
              if isinstance(err, ClientError):
                  return {
                      "service": service,
                      "operation": operation,
                      "error_code": err.response.get("Error", {}).get("Code", "Unknown"),
                      "error_message": err.response.get("Error", {}).get("Message", str(err))
                  }
              return {
                  "service": service,
                  "operation": operation,
                  "error_code": type(err).__name__,
                  "error_message": str(err)
              }

          def is_access_denied(err):
              text = str(err).lower()
              if isinstance(err, ClientError):
                  code = err.response.get("Error", {}).get("Code", "").lower()
                  msg = err.response.get("Error", {}).get("Message", "").lower()
                  text = f"{code} {msg}"
              return "accessdenied" in text or "not authorized" in text or "explicit deny" in text or "service control policy" in text

          def safe_call(region_result, service_name, operation_name, func, default):
              try:
                  return func()
              except EndpointConnectionError as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except ClientError as e:
                  if is_access_denied(e):
                      region_result["access_denied"] += 1
                  else:
                      region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except Exception as e:
                  region_result["failed"] += 1
                  region_result["failures"].append(error_record(service_name, operation_name, e))
                  return default

          def get_enabled_regions(excluded):
              ec2 = boto3.client("ec2", region_name="us-east-1")
              response = ec2.describe_regions(AllRegions=True)
              regions = []
              for r in response.get("Regions", []):
                  status = r.get("OptInStatus")
                  name = r.get("RegionName")
                  if status in (None, "opt-in-not-required", "opted-in") and name not in excluded:
                      regions.append(name)
              return sorted(regions)

          def paginate(client, operation, result_key, kwargs=None):
              kwargs = dict(kwargs or {})
              try:
                  paginator = client.get_paginator(operation)
                  items = []
                  for page in paginator.paginate(**kwargs):
                      items.extend(page.get(result_key, []) or [])
                  return items
              except Exception as paginator_error:
                  if "Operation cannot be paginated" not in str(paginator_error):
                      raise

              method = getattr(client, operation)
              items = []
              token = None
              while True:
                  call_kwargs = dict(kwargs)
                  if token:
                      call_kwargs["NextToken"] = token
                  response = method(**call_kwargs)
                  items.extend(response.get(result_key, []) or [])
                  token = response.get("NextToken")
                  if not token:
                      break
              return items

          def tags_to_dict(tags):
              out = {}
              for tag in tags or []:
                  key = tag.get("Key")
                  if key:
                      out[key] = tag.get("Value")
              return out

          def list_eventbridge_tags(client, rr, arn):
              if not arn:
                  return {}
              try:
                  return tags_to_dict(client.list_tags_for_resource(ResourceARN=arn).get("Tags", []))
              except ClientError as e:
                  if is_access_denied(e):
                      rr["tag_read_access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(error_record("EventBridge", "ListTagsForResource", e))
                  return {}

          def list_scheduler_tags(client, rr, arn):
              if not arn:
                  return {}
              try:
                  return tags_to_dict(client.list_tags_for_resource(ResourceArn=arn).get("Tags", []))
              except ClientError as e:
                  if is_access_denied(e):
                      rr["tag_read_access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(error_record("Scheduler", "ListTagsForResource", e))
                  return {}

          def target_type_from_arn(arn):
              if not arn:
                  return "unknown"
              parts = arn.split(":")
              if len(parts) < 6:
                  return "unknown"
              service = parts[2]
              resource = parts[5]
              if service == "lambda":
                  return "lambda"
              if service == "sns":
                  return "sns"
              if service == "sqs":
                  return "sqs"
              if service == "states":
                  return "stepfunctions"
              if service == "ecs":
                  return "ecs"
              if service == "ssm":
                  return "ssm"
              if service == "events":
                  if resource.startswith("event-bus/"):
                      return "event_bus"
                  if resource.startswith("api-destination/"):
                      return "api_destination"
                  return "events"
              if service == "logs":
                  return "cloudwatch_logs"
              if service == "kinesis":
                  return "kinesis"
              if service == "firehose":
                  return "firehose"
              if service == "codebuild":
                  return "codebuild"
              if service == "batch":
                  return "batch"
              return service or "unknown"

          def rule_item(rule, targets, tags, include_tags):
              target_counts = {}
              for t in targets:
                  ttype = target_type_from_arn(t.get("Arn"))
                  target_counts[ttype] = target_counts.get(ttype, 0) + 1
              item = {
                  "name": rule.get("Name"),
                  "arn": rule.get("Arn"),
                  "event_bus_name": rule.get("EventBusName"),
                  "state": rule.get("State"),
                  "description": rule.get("Description"),
                  "schedule_expression": rule.get("ScheduleExpression"),
                  "event_pattern_present": bool(rule.get("EventPattern")),
                  "managed_by": rule.get("ManagedBy"),
                  "role_arn_present": bool(rule.get("RoleArn")),
                  "target_count": len(targets),
                  "target_type_counts": target_counts,
                  "targets": [
                      {
                          "id": t.get("Id"),
                          "arn": t.get("Arn"),
                          "target_type": target_type_from_arn(t.get("Arn")),
                          "role_arn_present": bool(t.get("RoleArn")),
                          "input_present": bool(t.get("Input") or t.get("InputPath") or t.get("InputTransformer")),
                          "dead_letter_config_present": bool(t.get("DeadLetterConfig")),
                          "retry_policy_present": bool(t.get("RetryPolicy"))
                      }
                      for t in targets
                  ]
              }
              if include_tags:
                  item["tags"] = tags or {}
              return item

          def schedule_item(schedule, detail, tags, include_tags):
              target = detail.get("Target", {}) or {}
              item = {
                  "name": schedule.get("Name") or detail.get("Name"),
                  "group_name": schedule.get("GroupName") or detail.get("GroupName"),
                  "arn": schedule.get("Arn") or detail.get("Arn"),
                  "state": schedule.get("State") or detail.get("State"),
                  "schedule_expression": detail.get("ScheduleExpression"),
                  "schedule_expression_timezone": detail.get("ScheduleExpressionTimezone"),
                  "flexible_time_window": detail.get("FlexibleTimeWindow"),
                  "target_arn": target.get("Arn"),
                  "target_type": target_type_from_arn(target.get("Arn")),
                  "role_arn_present": bool(target.get("RoleArn")),
                  "start_date": detail.get("StartDate"),
                  "end_date": detail.get("EndDate"),
                  "kms_key_arn_present": bool(detail.get("KmsKeyArn")),
                  "action_after_completion": detail.get("ActionAfterCompletion")
              }
              if include_tags:
                  item["tags"] = tags or {}
              return item

          def scan_region(region, settings):
              rr = {
                  "region": region,
                  "event_buses": 0,
                  "custom_event_buses": 0,
                  "default_event_bus_present": 0,
                  "rules": 0,
                  "enabled_rules": 0,
                  "disabled_rules": 0,
                  "scheduled_rules": 0,
                  "event_pattern_rules": 0,
                  "managed_rules": 0,
                  "rules_with_targets": 0,
                  "targets": 0,
                  "rules_targeting_lambda": 0,
                  "rules_targeting_sns": 0,
                  "rules_targeting_sqs": 0,
                  "rules_targeting_stepfunctions": 0,
                  "rules_targeting_ecs": 0,
                  "rules_targeting_ssm": 0,
                  "rules_targeting_api_destination": 0,
                  "rules_targeting_event_bus": 0,
                  "rules_targeting_other": 0,
                  "archives": 0,
                  "replays": 0,
                  "api_destinations": 0,
                  "connections": 0,
                  "scheduler_groups": 0,
                  "schedules": 0,
                  "enabled_schedules": 0,
                  "disabled_schedules": 0,
                  "one_time_schedules": 0,
                  "rate_schedules": 0,
                  "cron_schedules": 0,
                  "schedules_with_flexible_window": 0,
                  "schedules_targeting_lambda": 0,
                  "schedules_targeting_sns": 0,
                  "schedules_targeting_sqs": 0,
                  "schedules_targeting_stepfunctions": 0,
                  "schedules_targeting_ecs": 0,
                  "schedules_targeting_ssm": 0,
                  "schedules_targeting_other": 0,
                  "tag_read_access_denied": 0,
                  "review_flags": [],
                  "event_bus_inventory": [],
                  "rule_inventory": [],
                  "archive_inventory": [],
                  "replay_inventory": [],
                  "api_destination_inventory": [],
                  "connection_inventory": [],
                  "scheduler_group_inventory": [],
                  "schedule_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              events = boto3.client("events", region_name=region)
              scheduler = boto3.client("scheduler", region_name=region)

              buses = safe_call(
                  rr,
                  "EventBridge",
                  "ListEventBuses",
                  lambda: paginate(events, "list_event_buses", "EventBuses"),
                  []
              )
              rr["event_buses"] = len(buses)
              for bus in buses:
                  name = bus.get("Name")
                  if name == "default":
                      rr["default_event_bus_present"] = 1
                  else:
                      rr["custom_event_buses"] += 1
                  if settings["include_details"]:
                      item = {
                          "name": name,
                          "arn": bus.get("Arn"),
                          "policy_present": bool(bus.get("Policy")),
                          "creation_time": bus.get("CreationTime"),
                          "last_modified_time": bus.get("LastModifiedTime")
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_eventbridge_tags(events, rr, bus.get("Arn"))
                      rr["event_bus_inventory"].append(item)

              rules = safe_call(
                  rr,
                  "EventBridge",
                  "ListRules",
                  lambda: paginate(events, "list_rules", "Rules"),
                  []
              )[:settings["max_rules_per_region"]]

              rr["rules"] = len(rules)

              for rule in rules:
                  name = rule.get("Name")
                  bus_name = rule.get("EventBusName") or "default"
                  state = rule.get("State")
                  if state == "ENABLED":
                      rr["enabled_rules"] += 1
                  elif state == "DISABLED":
                      rr["disabled_rules"] += 1

                  if rule.get("ScheduleExpression"):
                      rr["scheduled_rules"] += 1
                  if rule.get("EventPattern"):
                      rr["event_pattern_rules"] += 1
                  if rule.get("ManagedBy"):
                      rr["managed_rules"] += 1

                  targets = []
                  if name:
                      targets = safe_call(
                          rr,
                          "EventBridge",
                          "ListTargetsByRule",
                          lambda n=name, b=bus_name: paginate(events, "list_targets_by_rule", "Targets", {"Rule": n, "EventBusName": b}),
                          []
                      )
                  rr["targets"] += len(targets)
                  if targets:
                      rr["rules_with_targets"] += 1

                  seen_target_types = set()
                  for target in targets:
                      seen_target_types.add(target_type_from_arn(target.get("Arn")))

                  if "lambda" in seen_target_types:
                      rr["rules_targeting_lambda"] += 1
                  if "sns" in seen_target_types:
                      rr["rules_targeting_sns"] += 1
                  if "sqs" in seen_target_types:
                      rr["rules_targeting_sqs"] += 1
                  if "stepfunctions" in seen_target_types:
                      rr["rules_targeting_stepfunctions"] += 1
                  if "ecs" in seen_target_types:
                      rr["rules_targeting_ecs"] += 1
                  if "ssm" in seen_target_types:
                      rr["rules_targeting_ssm"] += 1
                  if "api_destination" in seen_target_types:
                      rr["rules_targeting_api_destination"] += 1
                  if "event_bus" in seen_target_types:
                      rr["rules_targeting_event_bus"] += 1
                  other_types = seen_target_types - set(["lambda","sns","sqs","stepfunctions","ecs","ssm","api_destination","event_bus"])
                  if other_types:
                      rr["rules_targeting_other"] += 1

                  if settings["include_details"]:
                      tags = list_eventbridge_tags(events, rr, rule.get("Arn")) if settings["include_tags"] else {}
                      rr["rule_inventory"].append(rule_item(rule, targets, tags, settings["include_tags"]))

              archives = safe_call(
                  rr,
                  "EventBridge",
                  "ListArchives",
                  lambda: paginate(events, "list_archives", "Archives"),
                  []
              )
              rr["archives"] = len(archives)
              if settings["include_details"]:
                  for a in archives:
                      item = {
                          "archive_name": a.get("ArchiveName"),
                          "event_source_arn": a.get("EventSourceArn"),
                          "state": a.get("State"),
                          "state_reason": a.get("StateReason"),
                          "retention_days": a.get("RetentionDays"),
                          "size_bytes": a.get("SizeBytes"),
                          "event_count": a.get("EventCount"),
                          "creation_time": a.get("CreationTime")
                      }
                      rr["archive_inventory"].append(item)

              replays = safe_call(
                  rr,
                  "EventBridge",
                  "ListReplays",
                  lambda: paginate(events, "list_replays", "Replays"),
                  []
              )
              rr["replays"] = len(replays)
              if settings["include_details"]:
                  for replay in replays:
                      rr["replay_inventory"].append({
                          "replay_name": replay.get("ReplayName"),
                          "state": replay.get("State"),
                          "state_reason": replay.get("StateReason"),
                          "event_start_time": replay.get("EventStartTime"),
                          "event_end_time": replay.get("EventEndTime"),
                          "event_last_replayed_time": replay.get("EventLastReplayedTime")
                      })

              api_destinations = safe_call(
                  rr,
                  "EventBridge",
                  "ListApiDestinations",
                  lambda: paginate(events, "list_api_destinations", "ApiDestinations"),
                  []
              )
              rr["api_destinations"] = len(api_destinations)
              if settings["include_details"]:
                  for d in api_destinations:
                      rr["api_destination_inventory"].append({
                          "name": d.get("Name"),
                          "arn": d.get("ApiDestinationArn"),
                          "connection_arn": d.get("ConnectionArn"),
                          "invocation_endpoint": d.get("InvocationEndpoint"),
                          "http_method": d.get("HttpMethod"),
                          "invocation_rate_limit_per_second": d.get("InvocationRateLimitPerSecond"),
                          "state": d.get("ApiDestinationState"),
                          "creation_time": d.get("CreationTime"),
                          "last_modified_time": d.get("LastModifiedTime")
                      })

              connections = safe_call(
                  rr,
                  "EventBridge",
                  "ListConnections",
                  lambda: paginate(events, "list_connections", "Connections"),
                  []
              )
              rr["connections"] = len(connections)
              if settings["include_details"]:
                  for c in connections:
                      rr["connection_inventory"].append({
                          "name": c.get("Name"),
                          "arn": c.get("ConnectionArn"),
                          "state": c.get("ConnectionState"),
                          "state_reason": c.get("StateReason"),
                          "authorization_type": c.get("AuthorizationType"),
                          "creation_time": c.get("CreationTime"),
                          "last_modified_time": c.get("LastModifiedTime")
                      })

              schedule_groups = safe_call(
                  rr,
                  "Scheduler",
                  "ListScheduleGroups",
                  lambda: paginate(scheduler, "list_schedule_groups", "ScheduleGroups"),
                  []
              )
              rr["scheduler_groups"] = len(schedule_groups)
              if settings["include_details"]:
                  for group in schedule_groups:
                      item = {
                          "name": group.get("Name"),
                          "arn": group.get("Arn"),
                          "state": group.get("State"),
                          "creation_date": group.get("CreationDate"),
                          "last_modification_date": group.get("LastModificationDate")
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_scheduler_tags(scheduler, rr, group.get("Arn"))
                      rr["scheduler_group_inventory"].append(item)

              schedules = safe_call(
                  rr,
                  "Scheduler",
                  "ListSchedules",
                  lambda: paginate(scheduler, "list_schedules", "Schedules"),
                  []
              )[:settings["max_schedules_per_region"]]

              rr["schedules"] = len(schedules)

              for sched in schedules:
                  name = sched.get("Name")
                  group = sched.get("GroupName") or "default"
                  state = sched.get("State")
                  expression = sched.get("ScheduleExpression") or ""

                  if state == "ENABLED":
                      rr["enabled_schedules"] += 1
                  elif state == "DISABLED":
                      rr["disabled_schedules"] += 1

                  if expression.startswith("at("):
                      rr["one_time_schedules"] += 1
                  elif expression.startswith("rate("):
                      rr["rate_schedules"] += 1
                  elif expression.startswith("cron("):
                      rr["cron_schedules"] += 1

                  detail = {}
                  if name:
                      detail = safe_call(
                          rr,
                          "Scheduler",
                          "GetSchedule",
                          lambda n=name, g=group: scheduler.get_schedule(Name=n, GroupName=g),
                          {}
                      )

                  fw = detail.get("FlexibleTimeWindow") or sched.get("FlexibleTimeWindow") or {}
                  if fw.get("Mode") == "FLEXIBLE":
                      rr["schedules_with_flexible_window"] += 1

                  target = detail.get("Target", {}) or {}
                  ttype = target_type_from_arn(target.get("Arn"))
                  if ttype == "lambda":
                      rr["schedules_targeting_lambda"] += 1
                  elif ttype == "sns":
                      rr["schedules_targeting_sns"] += 1
                  elif ttype == "sqs":
                      rr["schedules_targeting_sqs"] += 1
                  elif ttype == "stepfunctions":
                      rr["schedules_targeting_stepfunctions"] += 1
                  elif ttype == "ecs":
                      rr["schedules_targeting_ecs"] += 1
                  elif ttype == "ssm":
                      rr["schedules_targeting_ssm"] += 1
                  else:
                      rr["schedules_targeting_other"] += 1

                  if settings["include_details"]:
                      tags = list_scheduler_tags(scheduler, rr, detail.get("Arn") or sched.get("Arn")) if settings["include_tags"] else {}
                      rr["schedule_inventory"].append(schedule_item(sched, detail, tags, settings["include_tags"]))

              if rr["rules"] > 0:
                  rr["review_flags"].append("eventbridge_rules_present")
              if rr["scheduled_rules"] > 0:
                  rr["review_flags"].append("scheduled_eventbridge_rules_present")
              if rr["schedules"] > 0:
                  rr["review_flags"].append("eventbridge_scheduler_schedules_present")
              if rr["custom_event_buses"] > 0:
                  rr["review_flags"].append("custom_event_buses_present")
              if rr["archives"] > 0:
                  rr["review_flags"].append("eventbridge_archives_present")
              if rr["api_destinations"] > 0 or rr["connections"] > 0:
                  rr["review_flags"].append("eventbridge_api_destinations_or_connections_present")
              if rr["enabled_rules"] == 0 and rr["enabled_schedules"] == 0 and (rr["disabled_rules"] > 0 or rr["disabled_schedules"] > 0):
                  rr["review_flags"].append("only_disabled_automation_present")

              return rr

          def handler(event, context):
              event = event or {}
              excluded = set(csv_list(event.get("excluded_regions_csv") or os.environ.get("EXCLUDED_REGIONS_CSV", "ap-northeast-3")))
              settings = {
                  "include_details": parse_bool(event.get("include_details"), env_bool("INCLUDE_DETAILS", True)),
                  "include_tags": parse_bool(event.get("include_tags"), env_bool("INCLUDE_TAGS", True)),
                  "max_rules_per_region": int(event.get("max_rules_per_region") or os.environ.get("MAX_RULES_PER_REGION", "500")),
                  "max_schedules_per_region": int(event.get("max_schedules_per_region") or os.environ.get("MAX_SCHEDULES_PER_REGION", "1000"))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "review_only": True,
                  "include_details": settings["include_details"],
                  "include_tags": settings["include_tags"],
                  "excluded_regions": sorted(list(excluded)),
                  "totals": {
                      "regions_scanned": 0,
                      "event_buses": 0,
                      "custom_event_buses": 0,
                      "default_event_bus_present": 0,
                      "rules": 0,
                      "enabled_rules": 0,
                      "disabled_rules": 0,
                      "scheduled_rules": 0,
                      "event_pattern_rules": 0,
                      "managed_rules": 0,
                      "rules_with_targets": 0,
                      "targets": 0,
                      "rules_targeting_lambda": 0,
                      "rules_targeting_sns": 0,
                      "rules_targeting_sqs": 0,
                      "rules_targeting_stepfunctions": 0,
                      "rules_targeting_ecs": 0,
                      "rules_targeting_ssm": 0,
                      "rules_targeting_api_destination": 0,
                      "rules_targeting_event_bus": 0,
                      "rules_targeting_other": 0,
                      "archives": 0,
                      "replays": 0,
                      "api_destinations": 0,
                      "connections": 0,
                      "scheduler_groups": 0,
                      "schedules": 0,
                      "enabled_schedules": 0,
                      "disabled_schedules": 0,
                      "one_time_schedules": 0,
                      "rate_schedules": 0,
                      "cron_schedules": 0,
                      "schedules_with_flexible_window": 0,
                      "schedules_targeting_lambda": 0,
                      "schedules_targeting_sns": 0,
                      "schedules_targeting_sqs": 0,
                      "schedules_targeting_stepfunctions": 0,
                      "schedules_targeting_ecs": 0,
                      "schedules_targeting_ssm": 0,
                      "schedules_targeting_other": 0,
                      "regions_with_rules": 0,
                      "regions_with_schedules": 0,
                      "regions_with_review_flags": 0,
                      "tag_read_access_denied": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "region_results": [],
                  "failures": []
              }

              try:
                  regions = get_enabled_regions(excluded)
              except Exception as e:
                  summary["totals"]["failed"] += 1
                  summary["failures"].append(error_record("EC2", "DescribeRegions", e))
                  summary["ended_at"] = utc_now().isoformat()
                  print(json.dumps(summary, indent=2, default=str))
                  return json.loads(json.dumps(summary, default=str))

              total_keys = [
                  k for k in summary["totals"].keys()
                  if k not in ("regions_scanned", "regions_with_rules", "regions_with_schedules", "regions_with_review_flags")
              ]

              for region in regions:
                  rr = scan_region(region, settings)
                  for key in total_keys:
                      summary["totals"][key] += int(rr.get(key, 0) or 0)

                  if rr.get("rules", 0) > 0:
                      summary["totals"]["regions_with_rules"] += 1
                  if rr.get("schedules", 0) > 0:
                      summary["totals"]["regions_with_schedules"] += 1
                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_review_flags"] += 1

                  summary["totals"]["regions_scanned"] += 1
                  summary["failures"].extend([dict(f, region=region) for f in rr.get("failures", [])])
                  summary["region_results"].append(rr)

              summary["ended_at"] = utc_now().isoformat()
              print(json.dumps(summary, indent=2, default=str))
              return json.loads(json.dumps(summary, default=str))

  ReviewScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for EventBridge/Scheduler Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: EventBridgeSchedulerCostReviewTarget
          Input: '{"source":"eventbridge-schedule"}'

  ReviewSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref ReviewFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt ReviewScheduleRule.Arn

Outputs:
  ReviewFunctionName:
    Description: Lambda function name.
    Value: !Ref ReviewFunction

  RemediatorFunctionName:
    Description: Lambda function name alias.
    Value: !Ref ReviewFunction

  ReviewFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt ReviewFunction.Arn

  ReviewOnly:
    Description: This module is intentionally review-only.
    Value: 'true'
