#!/usr/bin/env bash
#
# count-ci-assets.sh - Count Configuration Items (CIs) and Assets per AWS account, across
# regions, with no dependency on Omnitam. AWS Config is optional.
#
#   Every resource of a type Omnitam models is a Configuration Item -> that is the CI count.
#   Assets are the subset: every CI minus the "CI-only" resource types (networking, IAM,
#   launch templates, alarms, stacks, API definitions - the supporting configuration you do
#   not own as a billable asset).
#
#   SOURCES: the script uses AWS Config's counts (fast) in every account/region where a
#   Config recorder is recording, otherwise it lists resources through the AWS Cloud Control
#   API (slower: one paginated list-resources call per modeled type). Types Cloud Control
#   cannot list there (unsupported, parent-scoped, access denied, still throttled after
#   retries) are skipped with a warning and reported as "not counted". Global types (IAM,
#   CloudFront, Route 53, S3 buckets) are listed in us-east-1 only, so they count once. The
#   source used is reported per account/region on the console and in the HTML report.
#
# USAGE
#   count-ci-assets.sh [options] [ACCOUNT ...]
#
# POSITIONAL
#   ACCOUNT ...            AWS account ids to scan. Omit to scan the CURRENT account.
#                          With --no-assume only the current account is reachable.
#
# OPTIONS
#   -R, --regions "a,b"    Regions to scan (comma or space separated). us-east-1 is ALWAYS
#                          added. Default: every ENABLED region of the caller (plus us-east-1).
#   -A, --all-regions      Scan EVERY commercial region, including opt-in regions the account has
#                          NOT enabled (those log a warning and count 0). GovCloud/China are a
#                          separate partition and are not included.
#   -r, --role NAME        Role to assume in each target account
#                          (default: OrganizationAccountAccessRole; env ROLE).
#   -p, --profile NAME     Base AWS profile to use for the caller (env AWS_PROFILE).
#       --no-assume        Use the CURRENT credentials directly; do not assume any role.
#       --source SRC       auto (default): AWS Config where recording, else Cloud Control;
#                          config: AWS Config only (an unrecorded region counts 0, warned);
#                          cloudcontrol: Cloud Control API only (no AWS Config calls).
#   -o, --output FILE      HTML report path (default: ./ci-asset-report-<UTC>.html).
#   -l, --log FILE         Log file path (default: ./count-ci-assets-<UTC>.log).
#   -q, --quiet            Do not echo log lines to the console (still written to the log file).
#   -h, --help             Show this help and exit.
#
# ENVIRONMENT
#   ROLE, AWS_PROFILE, REGIONS, AWS_REGION are all honored. CC_RETRY_BASE_SECONDS (default 1)
#   is the first Cloud Control throttling backoff; it doubles per retry (5 attempts max).
#
# REQUIREMENTS
#   awscli v2 + jq and read-only AWS credentials. Permissions on the effective credentials:
#   ec2:DescribeRegions; organizations:DescribeAccount (on the caller) for account names;
#   sts:AssumeRole into the target role when not using --no-assume; plus, per source,
#     AWS Config path:    config:GetDiscoveredResourceCounts and
#                         config:DescribeConfigurationRecorderStatus;
#     Cloud Control path: cloudformation:ListResources plus each type's own read/list
#                         permissions (the AWS managed ReadOnlyAccess policy covers both).
#   AWS Config recording is optional.
#
# OUTPUT
#   A table on stdout (Account, Description, Assets, CIs, Source), the list of regions
#   scanned, the source used per account/region, and a self-contained HTML report whose
#   file:// link is printed at the end. Exit 0 on success, 1 on a preflight/credential
#   failure, 2 on a usage error.
#
set -uo pipefail

# ---- defaults / arg parsing -------------------------------------------------
TS="$(date -u +%Y%m%dT%H%M%SZ)"
ROLE="${ROLE:-OrganizationAccountAccessRole}"
REGIONS="${REGIONS:-${AWS_REGION:-}}"      # empty => discover enabled regions
NO_ASSUME=0; QUIET=0; ALL_REGIONS=0; SOURCE=auto
OUT="./ci-asset-report-${TS}.html"
LOG="./count-ci-assets-${TS}.log"
ACCTS=()
while [ $# -gt 0 ]; do case "$1" in
  -R|--regions) REGIONS="$2"; shift 2;;
  -r|--role)    ROLE="$2";    shift 2;;
  -p|--profile) export AWS_PROFILE="$2"; shift 2;;
  --no-assume)  NO_ASSUME=1;  shift;;
  --source)     SOURCE="$(printf '%s' "${2:-}" | tr '[:upper:]' '[:lower:]')"; shift 2 2>/dev/null || shift;;
  -A|--all-regions) ALL_REGIONS=1; shift;;
  -o|--output)  OUT="$2";     shift 2;;
  -l|--log)     LOG="$2";     shift 2;;
  -q|--quiet)   QUIET=1;      shift;;
  -h|--help)    sed -n '2,/^set /{/^set /d;s/^#\{0,1\} \{0,1\}//p}' "$0"; exit 0;;
  --) shift; while [ $# -gt 0 ]; do ACCTS+=("$1"); shift; done;;
  -*) echo "unknown option: $1 (try --help)" >&2; exit 2;;
  *)  ACCTS+=("$1"); shift;;
esac; done
case "$SOURCE" in auto|config|cloudcontrol) : ;;
  *) echo "invalid --source value: '$SOURCE' (expected auto, config or cloudcontrol)" >&2; exit 2;; esac
CC_RETRY_BASE="${CC_RETRY_BASE_SECONDS:-1}"
case "$CC_RETRY_BASE" in ''|*[!0-9]*) CC_RETRY_BASE=1;; esac
CC_MAX_TRIES=5          # Cloud Control attempts per page before a type is skipped as throttled
CC_PAGE=500             # identifiers fetched per list-resources page

# ---- logging ----------------------------------------------------------------
: > "$LOG" || { echo "cannot write log file $LOG" >&2; exit 1; }
_log(){ local l="$(date -u +%Y-%m-%dT%H:%M:%SZ) [$1] ${*:2}"; printf '%s\n' "$l" >>"$LOG"; [ "$QUIET" = 1 ] || printf '%s\n' "$l" >&2; }
info(){ _log INFO "$@"; }
warn(){ _log WARN "$@"; WARNINGS+=("$*"); }
err(){  _log ERROR "$@"; }
oneline(){ printf '%s' "$*" | tr '\n' ' ' | cut -c1-200; }
esc(){ local s="$*"; s="${s//&/&amp;}"; s="${s//</&lt;}"; s="${s//>/&gt;}"; printf '%s' "$s"; }
WARNINGS=()

# ---- preflight: required tooling (report EVERY missing item, then exit) -----
_missing=0
command -v aws >/dev/null 2>&1 || { err "REQUIRED: the AWS CLI (aws) is not on PATH. Install AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"; _missing=1; }
command -v jq  >/dev/null 2>&1 || { err "REQUIRED: jq is not on PATH. Install jq: https://jqlang.github.io/jq/download/"; _missing=1; }
[ "$_missing" = 0 ] || { err "preflight failed: install the missing requirement(s) above, then re-run. (log: $LOG)"; exit 1; }
AWS_VERSION="$(aws --version 2>&1)"
case "$AWS_VERSION" in aws-cli/2*) : ;; *) warn "expected AWS CLI v2, found: $(oneline "$AWS_VERSION") - some commands may behave differently";; esac
info "start (log: $LOG)  role=$ROLE  no_assume=$NO_ASSUME  source=$SOURCE  $(oneline "$AWS_VERSION")"

# ---- caller identity --------------------------------------------------------
CURRENT_ACCT="$(aws sts get-caller-identity --query Account --output text 2>>"$LOG")" || {
  err "REQUIRED: no working AWS credentials. Run 'aws configure' (or SSO login), pass -p PROFILE, or export AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY. Underlying error is in the log: $LOG"; exit 1; }
CALLER_ARN="$(aws sts get-caller-identity --query Arn --output text 2>/dev/null)"
info "caller: account $CURRENT_ACCT  arn $CALLER_ARN"

# ---- credentials for an account: "" = current creds, else temp assume-role env
creds_for(){  # creds_for <account>
  local acct="$1" out
  if [ "$NO_ASSUME" = 1 ] || [ "$acct" = "$CURRENT_ACCT" ]; then printf ''; return 0; fi
  out="$(aws sts assume-role --role-arn "arn:aws:iam::${acct}:role/${ROLE}" \
          --role-session-name ci-asset-count --duration-seconds 900 \
          --query Credentials --output json 2>&1)" || { err "assume-role ${ROLE} in ${acct} failed: $(oneline "$out")"; return 1; }
  printf 'AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s' \
    "$(jq -r .AccessKeyId <<<"$out")" "$(jq -r .SecretAccessKey <<<"$out")" "$(jq -r .SessionToken <<<"$out")"
}

# Built-in fallback: every commercial (aws partition) region, used only if describe-regions
# is unavailable. describe-regions is authoritative and picks up new regions automatically.
ALL_REGIONS_FALLBACK="us-east-1 us-east-2 us-west-1 us-west-2 af-south-1 ap-east-1 ap-east-2 ap-south-1 ap-south-2 ap-northeast-1 ap-northeast-2 ap-northeast-3 ap-southeast-1 ap-southeast-2 ap-southeast-3 ap-southeast-4 ap-southeast-5 ap-southeast-7 ca-central-1 ca-west-1 eu-central-1 eu-central-2 eu-west-1 eu-west-2 eu-west-3 eu-north-1 eu-south-1 eu-south-2 il-central-1 me-central-1 me-south-1 mx-central-1 sa-east-1"

# ---- regions: honor -R / --all-regions, ALWAYS include us-east-1, dedupe (us-east-1 first)
resolve_regions(){  # resolve_regions "<creds env>"
  local list
  if [ -n "$REGIONS" ]; then
    list="${REGIONS//,/ }"
  elif [ "$ALL_REGIONS" = 1 ]; then
    # every region AWS knows for this partition (no opt-in filter)
    # shellcheck disable=SC2086  # $1 = zero or more VAR=VAL credential words for env(1)
    list="$(env $1 aws ec2 describe-regions --all-regions --query 'Regions[].RegionName' --output text 2>>"$LOG")"
    [ -z "$list" ] && { warn "describe-regions failed; using built-in region list ($(wc -w <<<"$ALL_REGIONS_FALLBACK") regions)"; list="$ALL_REGIONS_FALLBACK"; }
  else
    # only the caller's ENABLED regions (default-enabled + opted-in)
    # shellcheck disable=SC2086  # $1 = zero or more VAR=VAL credential words for env(1)
    list="$(env $1 aws ec2 describe-regions --all-regions \
              --query 'Regions[?OptInStatus!=`not-opted-in`].RegionName' --output text 2>>"$LOG")"
    [ -z "$list" ] && { warn "describe-regions returned nothing; using us-east-1 only"; list="us-east-1"; }
  fi
  # shellcheck disable=SC2086  # $list is a space-separated region list; one word per line is the point
  printf '%s\n' us-east-1 $list | awk 'NF && !seen[$0]++' | paste -sd' ' -
}
REGION_LIST="$(resolve_regions "")"
# Split the region list ONCE into an array (read -a word-splits on the list's spaces): the
# per-region loops iterate "${REGION_ARR[@]}", so there is no unquoted-expansion site left.
read -r -a REGION_ARR <<<"$REGION_LIST"
info "regions to scan: $REGION_LIST"

# CI-only AWS Config types (Asset=false): the catalog's "C" rows intersected with the
# types AWS Config actually records. Derived from itam-backend asset-ci-classification.ts
# (CI_ONLY_RESOURCE_TYPES) + itam-discovery config-resource-types.yaml. Every OTHER
# discovered type defaults to Both, so it counts as an Asset - including iam:user,
# ec2:vpc-endpoint, route53:hosted-zone and every rds:* type (the split is analysis, not
# billing: a resource is an Asset because you own and track it, not because it has a bill line).
CI_ONLY="AWS::ApiGateway::RestApi AWS::ApiGatewayV2::Api AWS::ApiGatewayV2::Integration AWS::AutoScaling::AutoScalingGroup AWS::CloudFormation::Stack AWS::CloudFormation::StackSet AWS::CloudWatch::Alarm AWS::EC2::VPC AWS::EC2::Subnet AWS::EC2::SecurityGroup AWS::EC2::RouteTable AWS::EC2::InternetGateway AWS::EC2::VPCPeeringConnection AWS::EC2::LaunchTemplate AWS::EC2::NetworkInterface AWS::ECS::Cluster AWS::ECS::Service AWS::ECS::TaskDefinition AWS::EKS::FargateProfile AWS::ElasticLoadBalancingV2::Listener AWS::GuardDuty::Detector AWS::IAM::Role AWS::IAM::Group AWS::IAM::Policy AWS::IAM::InstanceProfile AWS::IAM::SAMLProvider AWS::IAM::OIDCProvider AWS::Events::Rule AWS::Events::EventBus"

# Resource types Omnitam models (Cloud Control path): every non-null AWS Config type mapping
# in itam-discovery connector/aws/config-resource-types.yaml (57 types; a superset of
# CI_ONLY). Regenerate from an itam-discovery checkout with:
#   git show origin/main:connector/aws/config-resource-types.yaml | sed -n '/^types:/,$p' | sed 's/#.*//' | grep -oE 'AWS::[A-Za-z0-9]+::[A-Za-z0-9]+' | sort -u | tr '\n' ' '
CC_TYPES="AWS::ACM::Certificate AWS::ApiGateway::RestApi AWS::ApiGatewayV2::Api AWS::ApiGatewayV2::Integration AWS::AutoScaling::AutoScalingGroup AWS::CloudFormation::Stack AWS::CloudFormation::StackSet AWS::CloudFront::Distribution AWS::CloudWatch::Alarm AWS::DynamoDB::Table AWS::EC2::EIP AWS::EC2::Instance AWS::EC2::InternetGateway AWS::EC2::LaunchTemplate AWS::EC2::NatGateway AWS::EC2::NetworkInterface AWS::EC2::RouteTable AWS::EC2::SecurityGroup AWS::EC2::Subnet AWS::EC2::TransitGateway AWS::EC2::VPC AWS::EC2::VPCEndpoint AWS::EC2::VPCPeeringConnection AWS::EC2::Volume AWS::ECS::Cluster AWS::ECS::Service AWS::ECS::TaskDefinition AWS::EKS::Cluster AWS::EKS::FargateProfile AWS::EKS::Nodegroup AWS::ElasticLoadBalancing::LoadBalancer AWS::ElasticLoadBalancingV2::Listener AWS::ElasticLoadBalancingV2::LoadBalancer AWS::Events::EventBus AWS::Events::Rule AWS::GuardDuty::Detector AWS::IAM::Group AWS::IAM::InstanceProfile AWS::IAM::OIDCProvider AWS::IAM::Policy AWS::IAM::Role AWS::IAM::SAMLProvider AWS::IAM::ServerCertificate AWS::IAM::User AWS::KMS::Key AWS::Lambda::Function AWS::MSK::Cluster AWS::MSK::ServerlessCluster AWS::RDS::DBCluster AWS::RDS::DBInstance AWS::Route53::HealthCheck AWS::Route53::HostedZone AWS::S3::Bucket AWS::SNS::Topic AWS::SQS::Queue AWS::SecretsManager::Secret AWS::StepFunctions::StateMachine"
# Global types: Cloud Control returns the same account-wide set in every region, so they are
# listed in us-east-1 only (always scanned) and count once.
CC_GLOBAL="AWS::CloudFront::Distribution AWS::IAM::Group AWS::IAM::InstanceProfile AWS::IAM::OIDCProvider AWS::IAM::Policy AWS::IAM::Role AWS::IAM::SAMLProvider AWS::IAM::ServerCertificate AWS::IAM::User AWS::Route53::HealthCheck AWS::Route53::HostedZone AWS::S3::Bucket"
read -r -a CC_TYPE_ARR <<<"$CC_TYPES"

# ---- describe an account (name) ---------------------------------------------
describe_account(){  # describe_account <account> "<creds>"
  local acct="$1" cenv="$2" name
  name="$(aws organizations describe-account --account-id "$acct" --query 'Account.Name' --output text 2>/dev/null)"
  [ -n "$name" ] && [ "$name" != None ] && { printf '%s' "$name"; return; }
  # shellcheck disable=SC2086  # $cenv = zero or more VAR=VAL credential words for env(1)
  name="$(env $cenv aws iam list-account-aliases --query 'AccountAliases[0]' --output text 2>/dev/null)"
  [ -n "$name" ] && [ "$name" != None ] && { printf '%s' "$name"; return; }
  printf '%s' '-'
}

# ---- Cloud Control: classify a failed list-resources call -----------------------
cc_reason(){  # cc_reason "<aws error text>"
  case "$1" in
    *UnsupportedActionException*) echo Unsupported;;
    *TypeNotFoundException*)      echo TypeNotFound;;
    *AccessDenied*|*UnauthorizedOperation*|*"not authorized"*) echo AccessDenied;;
    *"Required property"*|*"ResourceModel"*) echo ParentRequired;;
    *) echo Error;;
  esac
}

# ---- Cloud Control: count one type in one region; prints N or SKIP:<reason> ------
# Paginates with --max-items/--starting-token; throttling is retried with exponential
# backoff (CC_RETRY_BASE seconds, doubling) up to CC_MAX_TRIES attempts per page.
cc_count_type(){  # cc_count_type "<creds>" <region> <type>
  local cenv="$1" region="$2" type="$3" token="" total=0 out page n tries delay
  local -a args
  while :; do
    tries=0; delay="$CC_RETRY_BASE"
    while :; do
      args=(cloudcontrol list-resources --type-name "$type" --region "$region" --max-items "$CC_PAGE" --output json)
      [ -n "$token" ] && args+=(--starting-token "$token")
      # shellcheck disable=SC2086  # $cenv = zero or more VAR=VAL credential words for env(1)
      out="$(env $cenv aws "${args[@]}" 2>&1)" && break
      case "$out" in
        *Throttl*|*TooManyRequests*|*RequestLimitExceeded*|*"Rate exceeded"*)
          tries=$((tries + 1))
          if [ "$tries" -ge "$CC_MAX_TRIES" ]; then
            _log WARN "${region}: ${type}: still throttled after ${tries} attempts"; echo "SKIP:Throttled"; return; fi
          _log INFO "${region}: ${type}: throttled, retry ${tries} in ${delay}s"
          sleep "$delay"; delay=$((delay * 2)); continue;;
      esac
      _log WARN "${region}: ${type}: cloudcontrol list-resources failed: $(oneline "$out")"
      echo "SKIP:$(cc_reason "$out")"; return
    done
    page="$(jq -r '"\((.ResourceDescriptions // []) | length) \(.NextToken // "")"' <<<"$out" 2>/dev/null)" || {
      _log WARN "${region}: ${type}: unreadable list-resources output: $(oneline "$out")"; echo "SKIP:Error"; return; }
    read -r n token <<<"$page"
    total=$((total + ${n:-0}))
    [ -n "$token" ] || break
  done
  echo "$total"
}

# ---- Cloud Control: count one region; prints "assets cis status cloudcontrol skipped" ---
# status ok | partial (some types skipped; skipped = comma-joined Type=Reason, else "-").
count_region_cc(){  # count_region_cc "<creds>" <region>
  local cenv="$1" region="$2" t n a=0 c=0 skipped=""
  for t in "${CC_TYPE_ARR[@]}"; do
    case " $CC_GLOBAL " in *" $t "*) [ "$region" = us-east-1 ] || continue;; esac
    n="$(cc_count_type "$cenv" "$region" "$t")"
    case "$n" in
      SKIP:*) skipped="${skipped:+${skipped},}${t}=${n#SKIP:}"; continue;;
      ''|*[!0-9]*) skipped="${skipped:+${skipped},}${t}=Error"; continue;;
    esac
    c=$((c + n))
    case " $CI_ONLY " in *" $t "*) : ;; *) a=$((a + n));; esac
  done
  if [ -n "$skipped" ]; then echo "$a $c partial cloudcontrol $skipped"; else echo "$a $c ok cloudcontrol -"; fi
}

# ---- count one region: prints "assets cis status source skipped" -------------
# source config: status ok | err (API call failed; detail in the log) | norec (the call
# SUCCEEDED but the region has no AWS Config recorder recording, so the 0/0 is
# "unrecorded", not "empty"). err/norec only stand with --source config; in auto mode they
# fall back to Cloud Control. Runs in a $(...) subshell, so the caller turns statuses into
# report warnings (W6).
count_region(){  # count_region "<creds>" <region>
  local cenv="$1" region="$2" j a c n
  if [ "$SOURCE" = cloudcontrol ]; then count_region_cc "$cenv" "$region"; return; fi
  # shellcheck disable=SC2086  # $cenv = zero or more VAR=VAL credential words for env(1)
  j="$(env $cenv aws configservice get-discovered-resource-counts --region "$region" --output json 2>&1)" || {
    _log WARN "${region}: get-discovered-resource-counts failed: $(oneline "$j")"
    if [ "$SOURCE" = config ]; then echo "0 0 err config -"; return; fi
    _log INFO "${region}: AWS Config unavailable - counting through the Cloud Control API"
    count_region_cc "$cenv" "$region"; return; }
  read -r a c <<<"$(jq -r --arg deny "$CI_ONLY" '
      ($deny|split(" ")) as $d
      | ([.resourceCounts[].count]|add // 0) as $ci
      | ([.resourceCounts[]|select(.resourceType as $t|($d|index($t))==null)|.count]|add // 0) as $as
      | "\($as) \($ci)"' <<<"$j" 2>/dev/null)"
  a="${a:-0}"; c="${c:-0}"
  if [ "$a" = 0 ] && [ "$c" = 0 ]; then
    # shellcheck disable=SC2086  # $cenv = zero or more VAR=VAL credential words for env(1)
    n="$(env $cenv aws configservice describe-configuration-recorder-status --region "$region" \
          --query 'length(ConfigurationRecordersStatus[?recording==`true`])' --output text 2>/dev/null)"
    case "$n" in ''|*[!0-9]*|0)
      if [ "$SOURCE" = config ]; then echo "0 0 norec config -"; return; fi
      _log INFO "${region}: no AWS Config recorder recording - counting through the Cloud Control API"
      count_region_cc "$cenv" "$region"; return;;
    esac
  fi
  echo "$a $c ok config -"
}

# ---- main loop --------------------------------------------------------------
if [ "$NO_ASSUME" = 1 ] || [ ${#ACCTS[@]} -eq 0 ]; then
  if [ ${#ACCTS[@]} -gt 0 ]; then case " ${ACCTS[*]} " in *" $CURRENT_ACCT "*) : ;;
    *) warn "--no-assume can only reach the current account; counting $CURRENT_ACCT, not ${ACCTS[*]}";; esac; fi
  TARGETS=("$CURRENT_ACCT")
else TARGETS=("${ACCTS[@]}"); fi

declare -a R_ACC R_DESC R_AS R_CI R_SRC R_SRCMAP   # per-account rows
DETAIL=""                                   # "acct\tdesc\tregion\tassets\tcis\tsource\tskipped" lines
TOTAL_AS=0; TOTAL_CI=0; UNCOUNTED=0      # UNCOUNTED = account/region scopes not counted (W6)
NOT_COUNTED=0; PARTIAL=0                 # Cloud Control type listings skipped / scopes affected
for acct in "${TARGETS[@]}"; do
  cenv="$(creds_for "$acct")" || { R_ACC+=("$acct"); R_DESC+=("(assume failed)"); R_AS+=("-"); R_CI+=("-"); R_SRC+=("-"); R_SRCMAP+=("-"); continue; }
  desc="$(describe_account "$acct" "$cenv")"
  info "account $acct ($desc): scanning ${REGION_LIST}"
  aas=0; aci=0; cfg_regs=""; cc_regs=""
  for region in "${REGION_ARR[@]}"; do
    read -r ra rc rs src skp <<<"$(count_region "$cenv" "$region")"
    src="${src:-config}"; skp="${skp:--}"
    case "$rs" in
      err)   warn "${acct}/${region}: get-discovered-resource-counts failed - counted 0 (see $LOG)"; UNCOUNTED=$((UNCOUNTED + 1));;
      norec) warn "${acct}/${region}: AWS Config recorder is not recording here - counted 0, resources in this region are NOT in the totals"; UNCOUNTED=$((UNCOUNTED + 1));;
      partial)
        nskip="$(tr ',' '\n' <<<"$skp" | grep -c .)"
        warn "${acct}/${region}: Cloud Control could not list ${nskip} resource type(s) - not counted: $(sed 's/=\([A-Za-z]*\)/ (\1)/g; s/,/, /g' <<<"$skp")"
        NOT_COUNTED=$((NOT_COUNTED + nskip)); PARTIAL=$((PARTIAL + 1));;
    esac
    if [ "$src" = cloudcontrol ]; then cc_regs+="${cc_regs:+ }${region}"; else cfg_regs+="${cfg_regs:+ }${region}"; fi
    info "  $acct/$region: assets=$ra cis=$rc source=$src"
    aas=$((aas + ra)); aci=$((aci + rc))
    DETAIL+="${acct}"$'\t'"${desc}"$'\t'"${region}"$'\t'"${ra}"$'\t'"${rc}"$'\t'"${src}"$'\t'"${skp}"$'\n'
  done
  if [ -z "$cc_regs" ]; then asrc=config; elif [ -z "$cfg_regs" ]; then asrc=cloudcontrol; else asrc=mixed; fi
  R_ACC+=("$acct"); R_DESC+=("$desc"); R_AS+=("$aas"); R_CI+=("$aci"); R_SRC+=("$asrc")
  R_SRCMAP+=("config: ${cfg_regs:--} | cloudcontrol: ${cc_regs:--}")
  TOTAL_AS=$((TOTAL_AS + aas)); TOTAL_CI=$((TOTAL_CI + aci))
done

# ---- console table ----------------------------------------------------------
echo
echo "Regions scanned: $REGION_LIST"
printf '%-14s  %-26s  %10s  %10s  %s\n' ACCOUNT DESCRIPTION ASSETS CIs SOURCE
printf '%-14s  %-26s  %10s  %10s  %s\n' "-------------" "--------------------------" "----------" "----------" "------------"
for i in "${!R_ACC[@]}"; do
  printf '%-14s  %-26.26s  %10s  %10s  %s\n' "${R_ACC[$i]}" "${R_DESC[$i]}" "${R_AS[$i]}" "${R_CI[$i]}" "${R_SRC[$i]}"
done
printf '%-14s  %-26s  %10s  %10s\n' TOTAL "${#TARGETS[@]} account(s)" "$TOTAL_AS" "$TOTAL_CI"
echo
echo "Count source per account/region:"
for i in "${!R_ACC[@]}"; do printf '  %s: %s\n' "${R_ACC[$i]}" "${R_SRCMAP[$i]}"; done
[ "$UNCOUNTED" -gt 0 ] && echo "NOTE: $UNCOUNTED account/region scope(s) were not counted (Config recorder off or API error) - totals are a lower bound."
[ "$NOT_COUNTED" -gt 0 ] && echo "NOTE: $NOT_COUNTED resource type listing(s) in $PARTIAL account/region scope(s) were not counted through Cloud Control (unsupported, parent-scoped, access denied or throttled) - totals are a lower bound."

# ---- HTML report ------------------------------------------------------------
{
  echo "<!doctype html><html lang=en><head><meta charset=utf-8><title>CI / Asset count</title>"
  echo "<style>:root{color-scheme:dark}body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#050b17;color:#e9f1fd;margin:0;padding:32px 28px;line-height:1.5}"
  echo "h1{font-size:20px;margin:0 0 4px}h2{font-size:14px;color:#8fd0ff;margin:30px 0 10px;text-transform:uppercase;letter-spacing:.06em}.sub{color:#7d90ad;font-size:12.5px;font-family:ui-monospace,monospace}"
  echo ".tiles{display:flex;flex-wrap:wrap;gap:14px;margin:22px 0}.tile{background:#0c1a31;border:1px solid #22406e;border-radius:12px;padding:16px 20px;min-width:120px}.tile .n{font-size:26px;font-weight:600}.tile .l{color:#7d90ad;font-size:11.5px;text-transform:uppercase;letter-spacing:.05em}"
  echo "table{border-collapse:collapse;width:100%;margin:6px 0 8px;font-size:13px}th,td{border:1px solid #152847;padding:8px 12px;text-align:left}th{background:#0c1a31;color:#8fd0ff;font-weight:600}td.n{text-align:right;font-variant-numeric:tabular-nums}tr:nth-child(even) td{background:#0a1424}tfoot td{font-weight:600;background:#0c1a31}"
  echo ".chip{display:inline-block;background:#0c1a31;border:1px solid #22406e;border-radius:6px;padding:3px 9px;margin:3px 4px 3px 0;font-size:12px;font-family:ui-monospace,monospace}.warn{color:#ffb454}code{font-family:ui-monospace,monospace;color:#8fd0ff}</style></head><body>"
  echo "<h1>CI / Asset count</h1>"
  echo "<div class=sub>Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) &middot; caller <code>$(esc "$CALLER_ARN")</code> &middot; role <code>$(esc "$ROLE")</code> &middot; source <code>$(esc "$SOURCE")</code></div>"
  echo "<div class=tiles><div class=tile><div class=n>${#TARGETS[@]}</div><div class=l>Accounts</div></div>"
  echo "<div class=tile><div class=n>$(wc -w <<<"$REGION_LIST")</div><div class=l>Regions</div></div>"
  echo "<div class=tile><div class=n>${TOTAL_AS}</div><div class=l>Assets</div></div>"
  echo "<div class=tile><div class=n>${TOTAL_CI}</div><div class=l>Config Items</div></div></div>"
  echo "<h2>Regions scanned</h2><div>"; for r in "${REGION_ARR[@]}"; do echo "<span class=chip>$(esc "$r")</span>"; done; echo "</div>"
  echo "<h2>Per account</h2><table><thead><tr><th>Account</th><th>Description</th><th>Assets</th><th>CIs</th><th>Source</th></tr></thead><tbody>"
  for i in "${!R_ACC[@]}"; do echo "<tr><td><code>$(esc "${R_ACC[$i]}")</code></td><td>$(esc "${R_DESC[$i]}")</td><td class=n>${R_AS[$i]}</td><td class=n>${R_CI[$i]}</td><td>$(esc "${R_SRC[$i]}")</td></tr>"; done
  echo "</tbody><tfoot><tr><td>TOTAL</td><td>${#TARGETS[@]} account(s)</td><td class=n>${TOTAL_AS}</td><td class=n>${TOTAL_CI}</td><td></td></tr></tfoot></table>"
  echo "<h2>Per account and region</h2><table><thead><tr><th>Account</th><th>Description</th><th>Region</th><th>Assets</th><th>CIs</th><th>Source</th><th>Not counted</th></tr></thead><tbody>"
  printf '%s' "$DETAIL" | while IFS=$'\t' read -r acc dsc reg ra rc src skp; do [ -n "$acc" ] && echo "<tr><td><code>$(esc "$acc")</code></td><td>$(esc "$dsc")</td><td><code>$(esc "$reg")</code></td><td class=n>${ra}</td><td class=n>${rc}</td><td>$(esc "$src")</td><td>$(esc "${skp//,/, }")</td></tr>"; done
  echo "</tbody></table>"
  if [ ${#WARNINGS[@]} -gt 0 ]; then echo "<h2>Warnings (${#WARNINGS[@]})</h2><ul>"; for w in "${WARNINGS[@]}"; do echo "<li class=warn>$(esc "$w")</li>"; done; echo "</ul>"; fi
  echo "<div class=sub style=margin-top:26px>Full run log: <code>$(esc "$LOG")</code></div></body></html>"
} > "$OUT"

ABS="$(cd "$(dirname "$OUT")" && pwd)/$(basename "$OUT")"
echo
echo "HTML report: file://${ABS}"
[ ${#WARNINGS[@]} -gt 0 ] && echo "(${#WARNINGS[@]} warning(s) - see the report or ${LOG})"
info "done: ${#TARGETS[@]} account(s), assets=$TOTAL_AS cis=$TOTAL_CI, report $ABS"
