AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Transit Gateway Cost Review Remediator.
  Review-only module that inventories Transit Gateways, TGW attachments,
  route tables, route counts, peering attachments, VPC attachments, VPN attachments,
  Direct Connect Gateway attachments, Connect attachments, and multicast domains
  across enabled Regions. This module intentionally does not delete or modify TGW resources.

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

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

  IncludeTags:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include tags in inventory where EC2 returns tags.

  IncludeRouteSamples:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Include sample TGW routes from route tables.

  MaxRouteSamplesPerRouteTable:
    Type: Number
    Default: 25
    MinValue: 0
    Description: Maximum route samples per TGW route table.

  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: TransitGatewayCostReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: TransitGatewayRead
                Effect: Allow
                Action:
                  - ec2:DescribeTransitGateways
                  - ec2:DescribeTransitGatewayAttachments
                  - ec2:DescribeTransitGatewayVpcAttachments
                  - ec2:DescribeTransitGatewayPeeringAttachments
                  - ec2:DescribeTransitGatewayConnects
                  - ec2:DescribeTransitGatewayConnectPeers
                  - ec2:DescribeTransitGatewayRouteTables
                  - ec2:DescribeTransitGatewayRouteTableAssociations
                  - ec2:DescribeTransitGatewayRouteTablePropagations
                  - ec2:DescribeTransitGatewayMulticastDomains
                  - ec2:SearchTransitGatewayRoutes
                  - ec2:DescribeVpnConnections
                  - ec2:DescribeDirectConnectGateways
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for Transit Gateway 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
          INCLUDE_ROUTE_SAMPLES: !Ref IncludeRouteSamples
          MAX_ROUTE_SAMPLES_PER_ROUTE_TABLE: !Ref MaxRouteSamplesPerRouteTable
          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 attachment_type(att):
              return att.get("ResourceType") or "unknown"

          def safe_tags(obj, include_tags):
              return tags_to_dict(obj.get("Tags")) if include_tags else None

          def tgw_item(tgw, include_tags):
              options = tgw.get("Options", {}) or {}
              item = {
                  "transit_gateway_id": tgw.get("TransitGatewayId"),
                  "transit_gateway_arn": tgw.get("TransitGatewayArn"),
                  "state": tgw.get("State"),
                  "owner_id": tgw.get("OwnerId"),
                  "creation_time": tgw.get("CreationTime"),
                  "description": tgw.get("Description"),
                  "amazon_side_asn": options.get("AmazonSideAsn"),
                  "auto_accept_shared_attachments": options.get("AutoAcceptSharedAttachments"),
                  "default_route_table_association": options.get("DefaultRouteTableAssociation"),
                  "default_route_table_propagation": options.get("DefaultRouteTablePropagation"),
                  "dns_support": options.get("DnsSupport"),
                  "vpn_ecmp_support": options.get("VpnEcmpSupport"),
                  "multicast_support": options.get("MulticastSupport"),
                  "association_default_route_table_id": options.get("AssociationDefaultRouteTableId"),
                  "propagation_default_route_table_id": options.get("PropagationDefaultRouteTableId")
              }
              if include_tags:
                  item["tags"] = tags_to_dict(tgw.get("Tags"))
              return item

          def attachment_item(att, include_tags):
              item = {
                  "transit_gateway_attachment_id": att.get("TransitGatewayAttachmentId"),
                  "transit_gateway_id": att.get("TransitGatewayId"),
                  "transit_gateway_owner_id": att.get("TransitGatewayOwnerId"),
                  "resource_owner_id": att.get("ResourceOwnerId"),
                  "resource_type": att.get("ResourceType"),
                  "resource_id": att.get("ResourceId"),
                  "state": att.get("State"),
                  "association_state": (att.get("Association") or {}).get("State"),
                  "association_route_table_id": (att.get("Association") or {}).get("TransitGatewayRouteTableId"),
                  "creation_time": att.get("CreationTime")
              }
              if include_tags:
                  item["tags"] = tags_to_dict(att.get("Tags"))
              return item

          def vpc_attachment_item(att, include_tags):
              opts = att.get("Options", {}) or {}
              item = {
                  "transit_gateway_attachment_id": att.get("TransitGatewayAttachmentId"),
                  "transit_gateway_id": att.get("TransitGatewayId"),
                  "vpc_id": att.get("VpcId"),
                  "vpc_owner_id": att.get("VpcOwnerId"),
                  "state": att.get("State"),
                  "subnet_count": len(att.get("SubnetIds", []) or []),
                  "subnet_ids": att.get("SubnetIds", []) or [],
                  "creation_time": att.get("CreationTime"),
                  "dns_support": opts.get("DnsSupport"),
                  "ipv6_support": opts.get("Ipv6Support"),
                  "appliance_mode_support": opts.get("ApplianceModeSupport")
              }
              if include_tags:
                  item["tags"] = tags_to_dict(att.get("Tags"))
              return item

          def route_table_item(rt, include_tags):
              item = {
                  "transit_gateway_route_table_id": rt.get("TransitGatewayRouteTableId"),
                  "transit_gateway_id": rt.get("TransitGatewayId"),
                  "state": rt.get("State"),
                  "default_association_route_table": rt.get("DefaultAssociationRouteTable"),
                  "default_propagation_route_table": rt.get("DefaultPropagationRouteTable"),
                  "creation_time": rt.get("CreationTime")
              }
              if include_tags:
                  item["tags"] = tags_to_dict(rt.get("Tags"))
              return item

          def route_sample(client, rr, route_table_id, max_samples):
              if max_samples <= 0:
                  return []
              routes = safe_call(
                  rr,
                  "EC2",
                  "SearchTransitGatewayRoutes",
                  lambda: client.search_transit_gateway_routes(
                      TransitGatewayRouteTableId=route_table_id,
                      Filters=[{"Name": "state", "Values": ["active", "blackhole"]}],
                      MaxResults=min(max_samples, 100)
                  ).get("Routes", []),
                  []
              )
              samples = []
              for route in routes[:max_samples]:
                  samples.append({
                      "destination_cidr_block": route.get("DestinationCidrBlock"),
                      "state": route.get("State"),
                      "type": route.get("Type"),
                      "attachment_count": len(route.get("TransitGatewayAttachments", []) or []),
                      "attachments": route.get("TransitGatewayAttachments", []) or []
                  })
              return samples

          def scan_region(region, settings):
              rr = {
                  "region": region,
                  "transit_gateways": 0,
                  "available_transit_gateways": 0,
                  "pending_transit_gateways": 0,
                  "deleted_transit_gateways": 0,
                  "transit_gateway_attachments": 0,
                  "available_attachments": 0,
                  "pending_attachments": 0,
                  "deleted_attachments": 0,
                  "deleting_attachments": 0,
                  "failed_attachments": 0,
                  "vpc_attachments": 0,
                  "vpn_attachments": 0,
                  "direct_connect_gateway_attachments": 0,
                  "peering_attachments": 0,
                  "connect_attachments": 0,
                  "tgw_peering_attachment_details": 0,
                  "tgw_vpc_attachment_details": 0,
                  "tgw_connect_details": 0,
                  "tgw_connect_peers": 0,
                  "route_tables": 0,
                  "route_table_associations": 0,
                  "route_table_propagations": 0,
                  "multicast_domains": 0,
                  "vpc_attachment_subnets": 0,
                  "cross_account_attachments": 0,
                  "review_flags": [],
                  "attachment_types": {},
                  "tgw_inventory": [],
                  "attachment_inventory": [],
                  "vpc_attachment_inventory": [],
                  "peering_attachment_inventory": [],
                  "connect_inventory": [],
                  "route_table_inventory": [],
                  "multicast_domain_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              ec2 = boto3.client("ec2", region_name=region)

              tgws = safe_call(
                  rr, "EC2", "DescribeTransitGateways",
                  lambda: paginate(ec2, "describe_transit_gateways", "TransitGateways"),
                  []
              )
              attachments = safe_call(
                  rr, "EC2", "DescribeTransitGatewayAttachments",
                  lambda: paginate(ec2, "describe_transit_gateway_attachments", "TransitGatewayAttachments"),
                  []
              )
              vpc_attachments = safe_call(
                  rr, "EC2", "DescribeTransitGatewayVpcAttachments",
                  lambda: paginate(ec2, "describe_transit_gateway_vpc_attachments", "TransitGatewayVpcAttachments"),
                  []
              )
              peering_attachments = safe_call(
                  rr, "EC2", "DescribeTransitGatewayPeeringAttachments",
                  lambda: paginate(ec2, "describe_transit_gateway_peering_attachments", "TransitGatewayPeeringAttachments"),
                  []
              )
              connects = safe_call(
                  rr, "EC2", "DescribeTransitGatewayConnects",
                  lambda: paginate(ec2, "describe_transit_gateway_connects", "TransitGatewayConnects"),
                  []
              )
              route_tables = safe_call(
                  rr, "EC2", "DescribeTransitGatewayRouteTables",
                  lambda: paginate(ec2, "describe_transit_gateway_route_tables", "TransitGatewayRouteTables"),
                  []
              )
              multicast_domains = safe_call(
                  rr, "EC2", "DescribeTransitGatewayMulticastDomains",
                  lambda: paginate(ec2, "describe_transit_gateway_multicast_domains", "TransitGatewayMulticastDomains"),
                  []
              )

              rr["transit_gateways"] = len(tgws)
              rr["transit_gateway_attachments"] = len(attachments)
              rr["tgw_vpc_attachment_details"] = len(vpc_attachments)
              rr["tgw_peering_attachment_details"] = len(peering_attachments)
              rr["tgw_connect_details"] = len(connects)
              rr["route_tables"] = len(route_tables)
              rr["multicast_domains"] = len(multicast_domains)

              for tgw in tgws:
                  state = tgw.get("State")
                  if state == "available":
                      rr["available_transit_gateways"] += 1
                  elif state == "pending":
                      rr["pending_transit_gateways"] += 1
                  elif state == "deleted":
                      rr["deleted_transit_gateways"] += 1
                  if settings["include_details"]:
                      rr["tgw_inventory"].append(tgw_item(tgw, settings["include_tags"]))

              for att in attachments:
                  atype = attachment_type(att)
                  rr["attachment_types"][atype] = rr["attachment_types"].get(atype, 0) + 1

                  if atype == "vpc":
                      rr["vpc_attachments"] += 1
                  elif atype == "vpn":
                      rr["vpn_attachments"] += 1
                  elif atype == "direct-connect-gateway":
                      rr["direct_connect_gateway_attachments"] += 1
                  elif atype == "peering":
                      rr["peering_attachments"] += 1
                  elif atype == "connect":
                      rr["connect_attachments"] += 1

                  state = att.get("State")
                  if state == "available":
                      rr["available_attachments"] += 1
                  elif state == "pending":
                      rr["pending_attachments"] += 1
                  elif state == "deleted":
                      rr["deleted_attachments"] += 1
                  elif state == "deleting":
                      rr["deleting_attachments"] += 1
                  elif state == "failed":
                      rr["failed_attachments"] += 1

                  if att.get("TransitGatewayOwnerId") and att.get("ResourceOwnerId"):
                      if att.get("TransitGatewayOwnerId") != att.get("ResourceOwnerId"):
                          rr["cross_account_attachments"] += 1

                  if settings["include_details"]:
                      rr["attachment_inventory"].append(attachment_item(att, settings["include_tags"]))

              for va in vpc_attachments:
                  rr["vpc_attachment_subnets"] += len(va.get("SubnetIds", []) or [])
                  if settings["include_details"]:
                      rr["vpc_attachment_inventory"].append(vpc_attachment_item(va, settings["include_tags"]))

              for pa in peering_attachments:
                  if settings["include_details"]:
                      item = {
                          "transit_gateway_attachment_id": pa.get("TransitGatewayAttachmentId"),
                          "requester_transit_gateway_info": pa.get("RequesterTgwInfo"),
                          "accepter_transit_gateway_info": pa.get("AccepterTgwInfo"),
                          "state": pa.get("State"),
                          "creation_time": pa.get("CreationTime")
                      }
                      if settings["include_tags"]:
                          item["tags"] = tags_to_dict(pa.get("Tags"))
                      rr["peering_attachment_inventory"].append(item)

              for conn in connects:
                  if settings["include_details"]:
                      item = {
                          "transit_gateway_attachment_id": conn.get("TransitGatewayAttachmentId"),
                          "transport_transit_gateway_attachment_id": conn.get("TransportTransitGatewayAttachmentId"),
                          "transit_gateway_id": conn.get("TransitGatewayId"),
                          "state": conn.get("State"),
                          "creation_time": conn.get("CreationTime"),
                          "options": conn.get("Options")
                      }
                      if settings["include_tags"]:
                          item["tags"] = tags_to_dict(conn.get("Tags"))
                      rr["connect_inventory"].append(item)

              # Connect peers require a specific Connect attachment ID, so query them per connect attachment.
              for conn in connects:
                  connect_attachment_id = conn.get("TransitGatewayAttachmentId")
                  if not connect_attachment_id:
                      continue
                  peers = safe_call(
                      rr,
                      "EC2",
                      "DescribeTransitGatewayConnectPeers",
                      lambda cid=connect_attachment_id: paginate(
                          ec2,
                          "describe_transit_gateway_connect_peers",
                          "TransitGatewayConnectPeers",
                          {"Filters": [{"Name": "transit-gateway-attachment-id", "Values": [cid]}]}
                      ),
                      []
                  )
                  rr["tgw_connect_peers"] += len(peers)

              for rt in route_tables:
                  rt_id = rt.get("TransitGatewayRouteTableId")

                  associations = []
                  propagations = []
                  if rt_id:
                      associations = safe_call(
                          rr,
                          "EC2",
                          "DescribeTransitGatewayRouteTableAssociations",
                          lambda rid=rt_id: paginate(
                              ec2,
                              "describe_transit_gateway_route_table_associations",
                              "Associations",
                              {"TransitGatewayRouteTableId": rid}
                          ),
                          []
                      )
                      propagations = safe_call(
                          rr,
                          "EC2",
                          "DescribeTransitGatewayRouteTablePropagations",
                          lambda rid=rt_id: paginate(
                              ec2,
                              "describe_transit_gateway_route_table_propagations",
                              "TransitGatewayRouteTablePropagations",
                              {"TransitGatewayRouteTableId": rid}
                          ),
                          []
                      )

                  rr["route_table_associations"] += len(associations)
                  rr["route_table_propagations"] += len(propagations)

                  if settings["include_details"]:
                      item = route_table_item(rt, settings["include_tags"])
                      item["association_count"] = len(associations)
                      item["propagation_count"] = len(propagations)
                      if settings["include_route_samples"] and rt_id:
                          item["route_samples"] = route_sample(
                              ec2,
                              rr,
                              rt_id,
                              settings["max_route_samples_per_route_table"]
                          )
                      rr["route_table_inventory"].append(item)

              if settings["include_details"]:
                  for md in multicast_domains:
                      item = {
                          "transit_gateway_multicast_domain_id": md.get("TransitGatewayMulticastDomainId"),
                          "transit_gateway_id": md.get("TransitGatewayId"),
                          "state": md.get("State"),
                          "creation_time": md.get("CreationTime"),
                          "owner_id": md.get("OwnerId")
                      }
                      if settings["include_tags"]:
                          item["tags"] = tags_to_dict(md.get("Tags"))
                      rr["multicast_domain_inventory"].append(item)

              if rr["transit_gateways"] > 0:
                  rr["review_flags"].append("transit_gateways_present")
              if rr["transit_gateway_attachments"] > 0:
                  rr["review_flags"].append("transit_gateway_attachments_present")
              if rr["cross_account_attachments"] > 0:
                  rr["review_flags"].append("cross_account_attachments_present")
              if rr["peering_attachments"] > 0:
                  rr["review_flags"].append("peering_attachments_present")
              if rr["connect_attachments"] > 0:
                  rr["review_flags"].append("connect_attachments_present")
              if rr["multicast_domains"] > 0:
                  rr["review_flags"].append("multicast_domains_present")
              if rr["pending_attachments"] or rr["failed_attachments"] or rr["deleting_attachments"]:
                  rr["review_flags"].append("non_available_attachments_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)),
                  "include_route_samples": parse_bool(event.get("include_route_samples"), env_bool("INCLUDE_ROUTE_SAMPLES", True)),
                  "max_route_samples_per_route_table": int(event.get("max_route_samples_per_route_table") or os.environ.get("MAX_ROUTE_SAMPLES_PER_ROUTE_TABLE", "25"))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "review_only": True,
                  "include_details": settings["include_details"],
                  "include_tags": settings["include_tags"],
                  "include_route_samples": settings["include_route_samples"],
                  "excluded_regions": sorted(list(excluded)),
                  "totals": {
                      "regions_scanned": 0,
                      "transit_gateways": 0,
                      "available_transit_gateways": 0,
                      "pending_transit_gateways": 0,
                      "deleted_transit_gateways": 0,
                      "transit_gateway_attachments": 0,
                      "available_attachments": 0,
                      "pending_attachments": 0,
                      "deleted_attachments": 0,
                      "deleting_attachments": 0,
                      "failed_attachments": 0,
                      "vpc_attachments": 0,
                      "vpn_attachments": 0,
                      "direct_connect_gateway_attachments": 0,
                      "peering_attachments": 0,
                      "connect_attachments": 0,
                      "tgw_peering_attachment_details": 0,
                      "tgw_vpc_attachment_details": 0,
                      "tgw_connect_details": 0,
                      "tgw_connect_peers": 0,
                      "route_tables": 0,
                      "route_table_associations": 0,
                      "route_table_propagations": 0,
                      "multicast_domains": 0,
                      "vpc_attachment_subnets": 0,
                      "cross_account_attachments": 0,
                      "regions_with_tgw": 0,
                      "regions_with_attachments": 0,
                      "regions_with_review_flags": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "attachment_types": {},
                  "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_tgw", "regions_with_attachments", "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("transit_gateways", 0) > 0:
                      summary["totals"]["regions_with_tgw"] += 1
                  if rr.get("transit_gateway_attachments", 0) > 0:
                      summary["totals"]["regions_with_attachments"] += 1
                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_review_flags"] += 1

                  for atype, count in rr.get("attachment_types", {}).items():
                      summary["attachment_types"][atype] = summary["attachment_types"].get(atype, 0) + count

                  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 Transit Gateway Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: TransitGatewayCostReviewTarget
          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'
