AWSTemplateFormatVersion: '2010-09-09'
Description: >
  Route 53 and Resolver Cost Review Remediator.
  Review-only module that inventories global Route 53 hosted zones, health checks,
  query logging configs, and regional Route 53 Resolver cost-related resources
  including Resolver endpoints, endpoint IP addresses, Resolver rules, DNS Firewall
  rule groups, DNS Firewall domain lists, and Resolver query log configs.
  This module intentionally does not delete or modify Route 53 or Resolver resources.

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

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

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

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

  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: Route53ResolverCostReviewPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: RegionAndVpcDiscovery
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                  - ec2:DescribeVpcs
                Resource: '*'
              - Sid: Route53GlobalRead
                Effect: Allow
                Action:
                  - route53:ListHostedZones
                  - route53:GetHostedZone
                  - route53:ListResourceRecordSets
                  - route53:ListHealthChecks
                  - route53:GetHealthCheck
                  - route53:ListQueryLoggingConfigs
                  - route53:ListTagsForResource
                  - route53:ListTagsForResources
                Resource: '*'
              - Sid: Route53ResolverRead
                Effect: Allow
                Action:
                  - route53resolver:ListResolverEndpoints
                  - route53resolver:GetResolverEndpoint
                  - route53resolver:ListResolverEndpointIpAddresses
                  - route53resolver:ListResolverRules
                  - route53resolver:GetResolverRule
                  - route53resolver:ListResolverRuleAssociations
                  - route53resolver:ListResolverQueryLogConfigs
                  - route53resolver:ListResolverQueryLogConfigAssociations
                  - route53resolver:ListFirewallRuleGroups
                  - route53resolver:ListFirewallRuleGroupAssociations
                  - route53resolver:ListFirewallDomainLists
                  - route53resolver:ListFirewallRules
                  - route53resolver:ListTagsForResource
                Resource: '*'

  ReviewFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Review-only Lambda for Route 53 and Resolver 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
          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(result, scope, service_name, operation_name, func, default):
              try:
                  return func()
              except EndpointConnectionError as e:
                  result["failed"] += 1
                  result["failures"].append(error_record(service_name, operation_name, e))
                  return default
              except ClientError as e:
                  if is_access_denied(e):
                      result["access_denied"] += 1
                  else:
                      result["failed"] += 1
                  rec = error_record(service_name, operation_name, e)
                  if scope:
                      rec["scope"] = scope
                  result["failures"].append(rec)
                  return default
              except Exception as e:
                  result["failed"] += 1
                  rec = error_record(service_name, operation_name, e)
                  if scope:
                      rec["scope"] = scope
                  result["failures"].append(rec)
                  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, token_param="NextToken", token_result="NextToken"):
              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[token_param] = token
                  response = method(**call_kwargs)
                  items.extend(response.get(result_key, []) or [])
                  token = response.get(token_result)
                  if not token:
                      break
              return items

          def r53_paginate_hosted_zones(client):
              zones = []
              marker = None
              while True:
                  kwargs = {}
                  if marker:
                      kwargs["Marker"] = marker
                  resp = client.list_hosted_zones(**kwargs)
                  zones.extend(resp.get("HostedZones", []) or [])
                  if not resp.get("IsTruncated"):
                      break
                  marker = resp.get("NextMarker")
              return zones

          def r53_paginate_health_checks(client):
              checks = []
              marker = None
              while True:
                  kwargs = {}
                  if marker:
                      kwargs["Marker"] = marker
                  resp = client.list_health_checks(**kwargs)
                  checks.extend(resp.get("HealthChecks", []) or [])
                  if not resp.get("IsTruncated"):
                      break
                  marker = resp.get("NextMarker")
              return checks

          def r53_paginate_record_sets(client, hosted_zone_id, max_items=10000):
              records = []
              start_name = None
              start_type = None
              start_identifier = None

              while True:
                  kwargs = {
                      "HostedZoneId": hosted_zone_id,
                      "MaxItems": "300"
                  }
                  if start_name:
                      kwargs["StartRecordName"] = start_name
                  if start_type:
                      kwargs["StartRecordType"] = start_type
                  if start_identifier:
                      kwargs["StartRecordIdentifier"] = start_identifier

                  resp = client.list_resource_record_sets(**kwargs)
                  records.extend(resp.get("ResourceRecordSets", []) or [])

                  if len(records) >= max_items:
                      break
                  if not resp.get("IsTruncated"):
                      break

                  start_name = resp.get("NextRecordName")
                  start_type = resp.get("NextRecordType")
                  start_identifier = resp.get("NextRecordIdentifier")

              return records

          def r53_paginate_query_logging_configs(client):
              configs = []
              token = None
              while True:
                  kwargs = {}
                  if token:
                      kwargs["NextToken"] = token
                  resp = client.list_query_logging_configs(**kwargs)
                  configs.extend(resp.get("QueryLoggingConfigs", []) or [])
                  token = resp.get("NextToken")
                  if not token:
                      break
              return configs

          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_r53_tags(client, result, resource_type, resource_id):
              try:
                  resp = client.list_tags_for_resource(ResourceType=resource_type, ResourceId=resource_id)
                  return tags_to_dict((resp.get("ResourceTagSet") or {}).get("Tags", []))
              except ClientError as e:
                  if is_access_denied(e):
                      result["tag_read_access_denied"] += 1
                  else:
                      result["failed"] += 1
                  result["failures"].append(error_record("Route53", "ListTagsForResource", e))
                  return {}

          def list_resolver_tags(client, rr, arn):
              if not arn:
                  return {}

              # Auto-defined Resolver rules and AWS-managed/shared Resolver resources
              # often cannot be tagged or read through ListTagsForResource from a
              # participant account. Treat those as visibility notes, not scan failures.
              if ":autodefined-rule/" in arn:
                  rr["tag_read_skipped_auto_defined_rule"] += 1
                  return {}

              try:
                  resp = client.list_tags_for_resource(ResourceArn=arn)
                  return tags_to_dict(resp.get("Tags", []))
              except ClientError as e:
                  code = e.response.get("Error", {}).get("Code", "")
                  msg = e.response.get("Error", {}).get("Message", "")

                  if "RSLVR-00903" in msg or "Cannot tag Auto Defined Rule" in msg:
                      rr["tag_read_skipped_auto_defined_rule"] += 1
                      return {}

                  if "RSLVR-00907" in msg or "shared resource" in msg or "AWSManaged" in arn:
                      rr["tag_read_skipped_shared_or_aws_managed"] += 1
                      return {}

                  if is_access_denied(e):
                      rr["tag_read_access_denied"] += 1
                  else:
                      rr["failed"] += 1
                  rr["failures"].append(error_record("Route53Resolver", "ListTagsForResource", e))
                  return {}

          def clean_zone_id(zone_id):
              return (zone_id or "").split("/")[-1]

          def scan_global_route53(include_details, include_tags):
              result = {
                  "scope": "global",
                  "hosted_zones": 0,
                  "public_hosted_zones": 0,
                  "private_hosted_zones": 0,
                  "hosted_zone_record_sets": 0,
                  "hosted_zones_with_query_logging": 0,
                  "health_checks": 0,
                  "query_logging_configs": 0,
                  "tag_read_access_denied": 0,
                  "review_flags": [],
                  "hosted_zone_inventory": [],
                  "health_check_inventory": [],
                  "query_logging_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

              client = boto3.client("route53")

              zones = safe_call(
                  result, "global", "Route53", "ListHostedZones",
                  lambda: r53_paginate_hosted_zones(client),
                  []
              )
              health_checks = safe_call(
                  result, "global", "Route53", "ListHealthChecks",
                  lambda: r53_paginate_health_checks(client),
                  []
              )
              query_logging_configs = safe_call(
                  result, "global", "Route53", "ListQueryLoggingConfigs",
                  lambda: r53_paginate_query_logging_configs(client),
                  []
              )

              result["hosted_zones"] = len(zones)
              result["health_checks"] = len(health_checks)
              result["query_logging_configs"] = len(query_logging_configs)

              logged_zone_ids = set()
              for cfg in query_logging_configs:
                  hzid = clean_zone_id(cfg.get("HostedZoneId"))
                  if hzid:
                      logged_zone_ids.add(hzid)

              result["hosted_zones_with_query_logging"] = len(logged_zone_ids)

              for zone in zones:
                  zone_id = clean_zone_id(zone.get("Id"))
                  config = zone.get("Config", {}) or {}
                  is_private = bool(config.get("PrivateZone"))

                  if is_private:
                      result["private_hosted_zones"] += 1
                  else:
                      result["public_hosted_zones"] += 1

                  record_count = 0
                  records_truncated = False

                  records = safe_call(
                      result,
                      "global",
                      "Route53",
                      "ListResourceRecordSets",
                      lambda zid=zone_id: r53_paginate_record_sets(client, zid, max_items=10000),
                      []
                  )
                  record_count = len(records)
                  result["hosted_zone_record_sets"] += record_count
                  if record_count >= 10000:
                      records_truncated = True

                  if include_details:
                      item = {
                          "hosted_zone_id": zone_id,
                          "name": zone.get("Name"),
                          "private_zone": is_private,
                          "record_set_count": record_count,
                          "record_sets_truncated_at_10000": records_truncated,
                          "linked_service": zone.get("LinkedService"),
                          "comment": config.get("Comment"),
                          "query_logging_enabled": zone_id in logged_zone_ids
                      }
                      if include_tags:
                          item["tags"] = list_r53_tags(client, result, "hostedzone", zone_id)
                      result["hosted_zone_inventory"].append(item)

              for hc in health_checks:
                  if include_details:
                      hc_id = hc.get("Id")
                      cfg = hc.get("HealthCheckConfig", {}) or {}
                      item = {
                          "health_check_id": hc_id,
                          "type": cfg.get("Type"),
                          "fully_qualified_domain_name": cfg.get("FullyQualifiedDomainName"),
                          "ip_address": cfg.get("IPAddress"),
                          "port": cfg.get("Port"),
                          "resource_path": cfg.get("ResourcePath"),
                          "measure_latency": cfg.get("MeasureLatency"),
                          "inverted": cfg.get("Inverted"),
                          "disabled": cfg.get("Disabled"),
                          "regions": cfg.get("Regions"),
                          "request_interval": cfg.get("RequestInterval"),
                          "failure_threshold": cfg.get("FailureThreshold")
                      }
                      if include_tags:
                          item["tags"] = list_r53_tags(client, result, "healthcheck", hc_id)
                      result["health_check_inventory"].append(item)

              if include_details:
                  for cfg in query_logging_configs:
                      result["query_logging_inventory"].append({
                          "id": cfg.get("Id"),
                          "hosted_zone_id": clean_zone_id(cfg.get("HostedZoneId")),
                          "cloudwatch_logs_log_group_arn": cfg.get("CloudWatchLogsLogGroupArn")
                      })

              if result["hosted_zones"] > 0:
                  result["review_flags"].append("hosted_zones_present")
              if result["health_checks"] > 0:
                  result["review_flags"].append("health_checks_present")
              if result["query_logging_configs"] > 0:
                  result["review_flags"].append("query_logging_configs_present")

              return result

          def resolver_endpoint_item(ep, include_tags, tags):
              return {
                  "id": ep.get("Id"),
                  "creator_request_id": ep.get("CreatorRequestId"),
                  "arn": ep.get("Arn"),
                  "name": ep.get("Name"),
                  "security_group_ids": ep.get("SecurityGroupIds"),
                  "direction": ep.get("Direction"),
                  "ip_address_count": ep.get("IpAddressCount"),
                  "host_vpc_id": ep.get("HostVPCId"),
                  "status": ep.get("Status"),
                  "status_message": ep.get("StatusMessage"),
                  "creation_time": ep.get("CreationTime"),
                  "modification_time": ep.get("ModificationTime"),
                  "resolver_endpoint_type": ep.get("ResolverEndpointType"),
                  "protocols": ep.get("Protocols"),
                  "tags": tags if include_tags else None
              }

          def scan_region_resolver(region, settings):
              rr = {
                  "region": region,
                  "resolver_endpoints": 0,
                  "inbound_resolver_endpoints": 0,
                  "outbound_resolver_endpoints": 0,
                  "dualstack_resolver_endpoints": 0,
                  "resolver_endpoint_ip_addresses": 0,
                  "resolver_rules": 0,
                  "forward_rules": 0,
                  "system_rules": 0,
                  "recursive_rules": 0,
                  "resolver_rule_associations": 0,
                  "resolver_query_log_configs": 0,
                  "resolver_query_log_config_associations": 0,
                  "firewall_rule_groups": 0,
                  "firewall_rule_group_associations": 0,
                  "firewall_domain_lists": 0,
                  "firewall_rules": 0,
                  "tag_read_access_denied": 0,
                  "tag_read_skipped_auto_defined_rule": 0,
                  "tag_read_skipped_shared_or_aws_managed": 0,
                  "review_flags": [],
                  "resolver_endpoint_inventory": [],
                  "resolver_rule_inventory": [],
                  "resolver_query_log_config_inventory": [],
                  "firewall_rule_group_inventory": [],
                  "firewall_domain_list_inventory": [],
                  "access_denied": 0,
                  "failed": 0,
                  "failures": []
              }

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

              endpoints = safe_call(
                  rr, region, "Route53Resolver", "ListResolverEndpoints",
                  lambda: paginate(client, "list_resolver_endpoints", "ResolverEndpoints"),
                  []
              )
              rules = safe_call(
                  rr, region, "Route53Resolver", "ListResolverRules",
                  lambda: paginate(client, "list_resolver_rules", "ResolverRules"),
                  []
              )
              rule_associations = safe_call(
                  rr, region, "Route53Resolver", "ListResolverRuleAssociations",
                  lambda: paginate(client, "list_resolver_rule_associations", "ResolverRuleAssociations"),
                  []
              )
              query_log_configs = safe_call(
                  rr, region, "Route53Resolver", "ListResolverQueryLogConfigs",
                  lambda: paginate(client, "list_resolver_query_log_configs", "ResolverQueryLogConfigs"),
                  []
              )
              query_log_associations = safe_call(
                  rr, region, "Route53Resolver", "ListResolverQueryLogConfigAssociations",
                  lambda: paginate(client, "list_resolver_query_log_config_associations", "ResolverQueryLogConfigAssociations"),
                  []
              )
              firewall_rule_groups = safe_call(
                  rr, region, "Route53Resolver", "ListFirewallRuleGroups",
                  lambda: paginate(client, "list_firewall_rule_groups", "FirewallRuleGroups"),
                  []
              )
              firewall_rule_group_associations = safe_call(
                  rr, region, "Route53Resolver", "ListFirewallRuleGroupAssociations",
                  lambda: paginate(client, "list_firewall_rule_group_associations", "FirewallRuleGroupAssociations"),
                  []
              )
              firewall_domain_lists = safe_call(
                  rr, region, "Route53Resolver", "ListFirewallDomainLists",
                  lambda: paginate(client, "list_firewall_domain_lists", "FirewallDomainLists"),
                  []
              )

              rr["resolver_endpoints"] = len(endpoints)
              rr["resolver_rules"] = len(rules)
              rr["resolver_rule_associations"] = len(rule_associations)
              rr["resolver_query_log_configs"] = len(query_log_configs)
              rr["resolver_query_log_config_associations"] = len(query_log_associations)
              rr["firewall_rule_groups"] = len(firewall_rule_groups)
              rr["firewall_rule_group_associations"] = len(firewall_rule_group_associations)
              rr["firewall_domain_lists"] = len(firewall_domain_lists)

              for ep in endpoints:
                  direction = ep.get("Direction")
                  if direction == "INBOUND":
                      rr["inbound_resolver_endpoints"] += 1
                  elif direction == "OUTBOUND":
                      rr["outbound_resolver_endpoints"] += 1

                  if ep.get("ResolverEndpointType") == "DUALSTACK":
                      rr["dualstack_resolver_endpoints"] += 1

                  ep_id = ep.get("Id")
                  ip_addrs = []
                  if ep_id:
                      ip_addrs = safe_call(
                          rr,
                          region,
                          "Route53Resolver",
                          "ListResolverEndpointIpAddresses",
                          lambda eid=ep_id: paginate(
                              client,
                              "list_resolver_endpoint_ip_addresses",
                              "IpAddresses",
                              {"ResolverEndpointId": eid}
                          ),
                          []
                      )
                  rr["resolver_endpoint_ip_addresses"] += len(ip_addrs)

                  if settings["include_details"]:
                      tags = list_resolver_tags(client, rr, ep.get("Arn")) if settings["include_tags"] else {}
                      item = resolver_endpoint_item(ep, settings["include_tags"], tags)
                      item["ip_addresses"] = ip_addrs
                      rr["resolver_endpoint_inventory"].append(item)

              for rule in rules:
                  rtype = rule.get("RuleType")
                  if rtype == "FORWARD":
                      rr["forward_rules"] += 1
                  elif rtype == "SYSTEM":
                      rr["system_rules"] += 1
                  elif rtype == "RECURSIVE":
                      rr["recursive_rules"] += 1

                  if settings["include_details"]:
                      item = {
                          "id": rule.get("Id"),
                          "arn": rule.get("Arn"),
                          "name": rule.get("Name"),
                          "domain_name": rule.get("DomainName"),
                          "rule_type": rule.get("RuleType"),
                          "status": rule.get("Status"),
                          "status_message": rule.get("StatusMessage"),
                          "owner_id": rule.get("OwnerId"),
                          "share_status": rule.get("ShareStatus"),
                          "resolver_endpoint_id": rule.get("ResolverEndpointId"),
                          "target_ips": rule.get("TargetIps"),
                          "creation_time": rule.get("CreationTime"),
                          "modification_time": rule.get("ModificationTime")
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_resolver_tags(client, rr, rule.get("Arn"))
                      rr["resolver_rule_inventory"].append(item)

              if settings["include_details"]:
                  for cfg in query_log_configs:
                      item = {
                          "id": cfg.get("Id"),
                          "arn": cfg.get("Arn"),
                          "name": cfg.get("Name"),
                          "destination_arn": cfg.get("DestinationArn"),
                          "owner_id": cfg.get("OwnerId"),
                          "share_status": cfg.get("ShareStatus"),
                          "association_count": cfg.get("AssociationCount"),
                          "creation_time": cfg.get("CreationTime"),
                          "creator_request_id": cfg.get("CreatorRequestId"),
                          "status": cfg.get("Status")
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_resolver_tags(client, rr, cfg.get("Arn"))
                      rr["resolver_query_log_config_inventory"].append(item)

                  for group in firewall_rule_groups:
                      arn = group.get("Arn")
                      group_id = group.get("Id")
                      rules_for_group = []
                      if group_id:
                          rules_for_group = safe_call(
                              rr,
                              region,
                              "Route53Resolver",
                              "ListFirewallRules",
                              lambda gid=group_id: paginate(
                                  client,
                                  "list_firewall_rules",
                                  "FirewallRules",
                                  {"FirewallRuleGroupId": gid}
                              ),
                              []
                          )
                      rr["firewall_rules"] += len(rules_for_group)
                      item = {
                          "id": group_id,
                          "arn": arn,
                          "name": group.get("Name"),
                          "owner_id": group.get("OwnerId"),
                          "share_status": group.get("ShareStatus"),
                          "creator_request_id": group.get("CreatorRequestId"),
                          "creation_time": group.get("CreationTime"),
                          "modification_time": group.get("ModificationTime"),
                          "status": group.get("Status"),
                          "rule_count": len(rules_for_group)
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_resolver_tags(client, rr, arn)
                      rr["firewall_rule_group_inventory"].append(item)

                  for dl in firewall_domain_lists:
                      arn = dl.get("Arn")
                      item = {
                          "id": dl.get("Id"),
                          "arn": arn,
                          "name": dl.get("Name"),
                          "owner_id": dl.get("OwnerId"),
                          "creator_request_id": dl.get("CreatorRequestId"),
                          "creation_time": dl.get("CreationTime"),
                          "modification_time": dl.get("ModificationTime"),
                          "status": dl.get("Status"),
                          "status_message": dl.get("StatusMessage"),
                          "domain_count": dl.get("DomainCount"),
                          "managed_owner_name": dl.get("ManagedOwnerName")
                      }
                      if settings["include_tags"]:
                          item["tags"] = list_resolver_tags(client, rr, arn)
                      rr["firewall_domain_list_inventory"].append(item)

              if rr["resolver_endpoints"] > 0:
                  rr["review_flags"].append("resolver_endpoints_present")
              if rr["resolver_query_log_configs"] > 0:
                  rr["review_flags"].append("resolver_query_log_configs_present")
              if rr["firewall_rule_groups"] > 0 or rr["firewall_domain_lists"] > 0:
                  rr["review_flags"].append("resolver_dns_firewall_present")
              if rr["forward_rules"] > 0:
                  rr["review_flags"].append("forward_resolver_rules_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))
              }

              summary = {
                  "started_at": utc_now().isoformat(),
                  "review_only": True,
                  "include_details": settings["include_details"],
                  "include_tags": settings["include_tags"],
                  "excluded_regions": sorted(list(excluded)),
                  "global_route53": {},
                  "totals": {
                      "regions_scanned": 0,
                      "hosted_zones": 0,
                      "public_hosted_zones": 0,
                      "private_hosted_zones": 0,
                      "hosted_zone_record_sets": 0,
                      "hosted_zones_with_query_logging": 0,
                      "health_checks": 0,
                      "route53_query_logging_configs": 0,
                      "resolver_endpoints": 0,
                      "inbound_resolver_endpoints": 0,
                      "outbound_resolver_endpoints": 0,
                      "dualstack_resolver_endpoints": 0,
                      "resolver_endpoint_ip_addresses": 0,
                      "resolver_rules": 0,
                      "forward_rules": 0,
                      "system_rules": 0,
                      "recursive_rules": 0,
                      "resolver_rule_associations": 0,
                      "resolver_query_log_configs": 0,
                      "resolver_query_log_config_associations": 0,
                      "firewall_rule_groups": 0,
                      "firewall_rule_group_associations": 0,
                      "firewall_domain_lists": 0,
                      "firewall_rules": 0,
                      "tag_read_skipped_auto_defined_rule": 0,
                      "tag_read_skipped_shared_or_aws_managed": 0,
                      "regions_with_resolver_endpoints": 0,
                      "regions_with_resolver_review_flags": 0,
                      "tag_read_access_denied": 0,
                      "access_denied": 0,
                      "failed": 0
                  },
                  "region_results": [],
                  "failures": []
              }

              global_result = scan_global_route53(settings["include_details"], settings["include_tags"])
              summary["global_route53"] = global_result

              global_mapping = {
                  "hosted_zones": "hosted_zones",
                  "public_hosted_zones": "public_hosted_zones",
                  "private_hosted_zones": "private_hosted_zones",
                  "hosted_zone_record_sets": "hosted_zone_record_sets",
                  "hosted_zones_with_query_logging": "hosted_zones_with_query_logging",
                  "health_checks": "health_checks",
                  "query_logging_configs": "route53_query_logging_configs",
                  "tag_read_access_denied": "tag_read_access_denied",
                  "access_denied": "access_denied",
                  "failed": "failed"
              }
              for src_key, dest_key in global_mapping.items():
                  summary["totals"][dest_key] += int(global_result.get(src_key, 0) or 0)

              summary["failures"].extend(global_result.get("failures", []) or [])

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

              regional_keys = [
                  "resolver_endpoints",
                  "inbound_resolver_endpoints",
                  "outbound_resolver_endpoints",
                  "dualstack_resolver_endpoints",
                  "resolver_endpoint_ip_addresses",
                  "resolver_rules",
                  "forward_rules",
                  "system_rules",
                  "recursive_rules",
                  "resolver_rule_associations",
                  "resolver_query_log_configs",
                  "resolver_query_log_config_associations",
                  "firewall_rule_groups",
                  "firewall_rule_group_associations",
                  "firewall_domain_lists",
                  "firewall_rules",
                  "tag_read_skipped_auto_defined_rule",
                  "tag_read_skipped_shared_or_aws_managed",
                  "tag_read_access_denied",
                  "access_denied",
                  "failed"
              ]

              for region in regions:
                  rr = scan_region_resolver(region, settings)

                  for key in regional_keys:
                      summary["totals"][key] += int(rr.get(key, 0) or 0)

                  if rr.get("resolver_endpoints", 0) > 0:
                      summary["totals"]["regions_with_resolver_endpoints"] += 1
                  if rr.get("review_flags"):
                      summary["totals"]["regions_with_resolver_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 Route 53 and Resolver Cost Review Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt ReviewFunction.Arn
          Id: Route53ResolverCostReviewTarget
          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'
