import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Dict, List, Optional

import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger(__name__)


def get_session_from_role(
    role_arn: str,
    session_name: str = "stacksage-session",
    external_id: str = None,
    duration_seconds: int = 3600,
):
    """
    Assume role (if role_arn provided) using STS. Supports ExternalId and session duration.
    If role_arn is empty, returns default boto3.Session() with a sane default region.
    """
    # Provide a default region to avoid NoRegionError when user has no AWS config.
    # This is also used for STS calls when assuming a role.
    base_session = boto3.Session()
    default_region = (
        getattr(base_session, "region_name", None)
        or os.environ.get("STACKSAGE_DEFAULT_REGION")
        or os.environ.get("AWS_DEFAULT_REGION")
        or "us-east-1"
    )

    if not role_arn:
        return boto3.Session(region_name=default_region)

    # Use a session-bound STS client so endpoint resolution doesn't depend on env.
    sts = boto3.Session(region_name=default_region).client("sts")
    # Pre-flight: verify source (auditor) credentials are valid
    try:
        _ = sts.get_caller_identity()
    except Exception as e:
        # Common: botocore.exceptions.ClientError: ExpiredToken
        raise RuntimeError(f"Source credentials invalid before AssumeRole: {e}")
    # Allow overriding session duration via env (bounded by role's MaxSessionDuration)
    try:
        env_dur = int(
            os.environ.get("STACKSAGE_ROLE_SESSION_DURATION", duration_seconds)
        )
        # STS allows 900..43200, but role's MaxSessionDuration may be lower; we pass requested value and let AWS enforce.
        requested_duration = max(900, min(env_dur, 43200))
    except Exception:
        requested_duration = duration_seconds
    params = {
        "RoleArn": role_arn,
        "RoleSessionName": session_name,
        "DurationSeconds": requested_duration,
    }
    if external_id:
        params["ExternalId"] = external_id
    try:
        resp = sts.assume_role(**params)
    except Exception as e:
        # Bubble with clearer context (distinguish customer role vs source creds already validated)
        raise RuntimeError(f"AssumeRole failed for {role_arn}: {e}")
    creds = resp["Credentials"]
    return boto3.Session(
        aws_access_key_id=creds["AccessKeyId"],
        aws_secret_access_key=creds["SecretAccessKey"],
        aws_session_token=creds["SessionToken"],
        region_name=default_region,
    )


def list_ec2(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out = []
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate():
        for r in page.get("Reservations", []):
            for i in r.get("Instances", []):
                rec = {
                    "InstanceId": i.get("InstanceId"),
                    "InstanceType": i.get("InstanceType"),
                    "State": i.get("State", {}).get("Name"),
                    "StateTransitionReason": i.get("StateTransitionReason"),
                    "LaunchTime": str(i.get("LaunchTime")),
                    "Tags": i.get("Tags", []),
                    "VpcId": i.get("VpcId"),
                    "Placement": i.get("Placement"),
                    "Region": ec2.meta.region_name,
                }
                # Envelope fields per schema
                rec.update(
                    {
                        "id": rec["InstanceId"],
                        "resource_type": "ec2",
                        "region": rec["Region"],
                        "availability_zone": (rec.get("Placement") or {}).get(
                            "AvailabilityZone"
                        ),
                        "vpc_id": rec.get("VpcId"),
                        "tags": rec.get("Tags", []),
                    }
                )
                out.append(rec)
    return out


def list_ebs(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out = []
    paginator = ec2.get_paginator("describe_volumes")
    for page in paginator.paginate():
        for v in page.get("Volumes", []):
            rec = {
                "VolumeId": v.get("VolumeId"),
                "Size": v.get("Size"),
                "VolumeType": v.get("VolumeType"),
                "Iops": v.get("Iops"),
                "Throughput": v.get("Throughput"),
                "State": v.get("State"),
                "Attachments": v.get("Attachments"),
                "CreateTime": str(v.get("CreateTime")),
                "AvailabilityZone": v.get("AvailabilityZone"),
                "Tags": v.get("Tags", []),
                "Region": ec2.meta.region_name,
            }
            rec.update(
                {
                    "id": rec["VolumeId"],
                    "resource_type": "ebs",
                    "region": rec["Region"],
                    "availability_zone": rec.get("AvailabilityZone"),
                    "vpc_id": None,
                    "tags": rec.get("Tags", []),
                }
            )
            out.append(rec)
    return out


def list_s3(session: boto3.Session) -> List[Dict[str, Any]]:
    s3 = session.client("s3")
    out = []
    resp = s3.list_buckets()
    for b in resp.get("Buckets", []):
        name = b.get("Name")
        region = None
        try:
            loc = s3.get_bucket_location(Bucket=name).get("LocationConstraint")
            region = loc if loc else "us-east-1"
        except Exception:
            region = None

        # --- Access logging ---
        logging_enabled = False
        logging_target_bucket: Optional[str] = None
        try:
            log_cfg = s3.get_bucket_logging(Bucket=name)
            log_status = log_cfg.get("LoggingEnabled")
            if log_status:
                logging_enabled = True
                logging_target_bucket = log_status.get("TargetBucket")
        except Exception:
            # AccessDenied, NoSuchBucket, etc. — treat as unknown (None signals this)
            logging_enabled = None  # type: ignore[assignment]
            logging_target_bucket = None

        # --- Intelligent-Tiering configuration ---
        intelligent_tiering_configured = False
        try:
            it_resp = s3.list_bucket_intelligent_tiering_configurations(Bucket=name)
            configs = it_resp.get("IntelligentTieringConfigurationList") or []
            intelligent_tiering_configured = len(configs) > 0
        except Exception:
            intelligent_tiering_configured = None  # type: ignore[assignment]

        rec = {
            "Name": name,
            "CreationDate": str(b.get("CreationDate")),
            "Region": region,
            "logging_enabled": logging_enabled,
            "logging_target_bucket": logging_target_bucket,
            "intelligent_tiering_configured": intelligent_tiering_configured,
        }
        rec.update(
            {
                "id": rec["Name"],
                "resource_type": "s3",
                "region": rec.get("Region"),
                "availability_zone": None,
                "vpc_id": None,
                "tags": [],
            }
        )
        out.append(rec)
    return out


def list_rds(
    session: boto3.Session,
    region_name: Optional[str] = None,
    include_tags: bool = False,
) -> List[Dict[str, Any]]:
    rds = (
        session.client("rds", region_name=region_name)
        if region_name
        else session.client("rds")
    )
    out = []
    paginator = rds.get_paginator("describe_db_instances")
    for page in paginator.paginate():
        for db in page.get("DBInstances", []):
            # derive VpcId via DBSubnetGroup if present
            vpc_id = None
            try:
                subnet_group = db.get("DBSubnetGroup", {})
                vpc_id = subnet_group.get("VpcId")
            except Exception:
                vpc_id = None
            rec = {
                "DBInstanceIdentifier": db.get("DBInstanceIdentifier"),
                "DBInstanceClass": db.get("DBInstanceClass"),
                "Engine": db.get("Engine"),
                "DBInstanceStatus": db.get("DBInstanceStatus"),
                "PubliclyAccessible": db.get("PubliclyAccessible"),
                "StorageEncrypted": db.get("StorageEncrypted"),
                "BackupRetentionPeriod": db.get("BackupRetentionPeriod"),
                "AvailabilityZone": db.get("AvailabilityZone"),
                "VpcId": vpc_id,
                "DBInstanceArn": db.get("DBInstanceArn"),
                "Region": rds.meta.region_name,
            }

            # Optional: tags are not returned by describe_db_instances; fetch only when needed.
            tag_list: List[Dict[str, Any]] = []
            if include_tags and rec.get("DBInstanceArn"):
                try:
                    tag_list = rds.list_tags_for_resource(
                        ResourceName=rec["DBInstanceArn"]
                    ).get("TagList", [])
                except Exception:
                    tag_list = []
            rec["TagList"] = tag_list

            rec.update(
                {
                    "id": rec["DBInstanceIdentifier"],
                    "resource_type": "rds",
                    "region": rec["Region"],
                    "availability_zone": rec.get("AvailabilityZone"),
                    "vpc_id": rec.get("VpcId"),
                    "tags": tag_list,
                }
            )
            out.append(rec)
    return out


# New resources
def list_snapshots(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out: List[Dict[str, Any]] = []
    paginator = ec2.get_paginator("describe_snapshots")
    for page in paginator.paginate(OwnerIds=["self"]):
        for s in page.get("Snapshots", []):
            rec = {
                "SnapshotId": s.get("SnapshotId"),
                "VolumeId": s.get("VolumeId"),
                "StartTime": str(s.get("StartTime")),
                "VolumeSize": s.get("VolumeSize"),
                "Description": s.get("Description"),
                "Tags": s.get("Tags", []),
                "Region": ec2.meta.region_name,
            }
            rec.update(
                {
                    "id": rec["SnapshotId"],
                    "resource_type": "snapshot",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": None,
                    "tags": rec.get("Tags", []),
                }
            )
            out.append(rec)
    return out


def list_nat_gateways(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out: List[Dict[str, Any]] = []
    paginator = ec2.get_paginator("describe_nat_gateways")
    for page in paginator.paginate():
        for ng in page.get("NatGateways", []):
            rec = {
                "NatGatewayId": ng.get("NatGatewayId"),
                "State": ng.get("State"),
                "VpcId": ng.get("VpcId"),
                "SubnetId": ng.get("SubnetId"),
                "CreateTime": str(ng.get("CreateTime")),
                "Region": ec2.meta.region_name,
            }
            rec.update(
                {
                    "id": rec["NatGatewayId"],
                    "resource_type": "nat",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": rec.get("VpcId"),
                    "tags": [],
                }
            )
            out.append(rec)
    return out


def list_eips(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out: List[Dict[str, Any]] = []
    resp = ec2.describe_addresses()
    for a in resp.get("Addresses", []):
        rec = {
            "PublicIp": a.get("PublicIp"),
            "AllocationId": a.get("AllocationId"),
            "AssociationId": a.get("AssociationId"),
            "InstanceId": a.get("InstanceId"),
            "NetworkInterfaceId": a.get("NetworkInterfaceId"),
            "Region": ec2.meta.region_name,
        }
        rec.update(
            {
                "id": rec["PublicIp"],
                "resource_type": "eip",
                "region": rec["Region"],
                "availability_zone": None,
                "vpc_id": None,
                "tags": [],
            }
        )
        out.append(rec)
    return out


def list_load_balancers(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    # ALB/NLB via elbv2, Classic ELB via elb
    out: List[Dict[str, Any]] = []
    elbv2 = (
        session.client("elbv2", region_name=region_name)
        if region_name
        else session.client("elbv2")
    )
    paginator_v2 = elbv2.get_paginator("describe_load_balancers")
    for page in paginator_v2.paginate():
        for lb in page.get("LoadBalancers", []):
            arn = lb.get("LoadBalancerArn")
            # Extract Application/NLB resource id expected by CloudWatch dimension ("app/<name>/<id>" or "net/<name>/<id>")
            resource_id = None
            try:
                if arn and "/" in arn:
                    resource_id = arn.split(":loadbalancer/")[-1]
            except Exception:
                resource_id = None
            # Get target groups for this LB (ALB/NLB only)
            target_groups = []
            try:
                tg_response = elbv2.describe_target_groups(LoadBalancerArn=arn)
                for tg in tg_response.get("TargetGroups", []):
                    tg_arn = tg.get("TargetGroupArn")
                    # Get health of targets in this group
                    healthy_count = 0
                    unhealthy_count = 0
                    total_count = 0
                    try:
                        health_response = elbv2.describe_target_health(
                            TargetGroupArn=tg_arn
                        )
                        for target_health in health_response.get(
                            "TargetHealthDescriptions", []
                        ):
                            total_count += 1
                            state = target_health.get("TargetHealth", {}).get("State")
                            if state == "healthy":
                                healthy_count += 1
                            elif state in ("unhealthy", "draining"):
                                unhealthy_count += 1
                    except Exception:
                        pass

                    target_groups.append(
                        {
                            "TargetGroupArn": tg_arn,
                            "TargetGroupName": tg.get("TargetGroupName"),
                            "HealthyTargetCount": healthy_count,
                            "UnhealthyTargetCount": unhealthy_count,
                            "TotalTargetCount": total_count,
                        }
                    )
            except Exception:
                pass

            rec = {
                "Name": lb.get("LoadBalancerName"),
                "Type": lb.get("Type"),
                "Scheme": lb.get("Scheme"),
                "VpcId": lb.get("VpcId"),
                "State": lb.get("State", {}).get("Code"),
                "CreatedTime": str(lb.get("CreatedTime")),
                "Region": elbv2.meta.region_name,
                "LoadBalancerArn": arn,
                "LoadBalancerResourceId": resource_id,
                "TargetGroups": target_groups,
            }
            rec.update(
                {
                    "id": rec["Name"],
                    "resource_type": "elb",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": rec.get("VpcId"),
                    "tags": [],
                }
            )
            out.append(rec)
    # Classic ELB
    elb = (
        session.client("elb", region_name=region_name)
        if region_name
        else session.client("elb")
    )
    paginator_elb = elb.get_paginator("describe_load_balancers")
    for page in paginator_elb.paginate():
        for lb in page.get("LoadBalancerDescriptions", []):
            rec = {
                "Name": lb.get("LoadBalancerName"),
                "Type": "classic",
                "Scheme": lb.get("Scheme"),
                "VpcId": lb.get("VPCId"),
                "State": "active",  # classic elb has no explicit state in same format
                "Region": elb.meta.region_name,
            }
            rec.update(
                {
                    "id": rec["Name"],
                    "resource_type": "elb",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": rec.get("VpcId"),
                    "tags": [],
                }
            )
            out.append(rec)
    return out


def list_vpc_endpoints(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    ec2 = (
        session.client("ec2", region_name=region_name)
        if region_name
        else session.client("ec2")
    )
    out: List[Dict[str, Any]] = []
    paginator = ec2.get_paginator("describe_vpc_endpoints")
    for page in paginator.paginate():
        for ep in page.get("VpcEndpoints", []):
            rec = {
                "VpcEndpointId": ep.get("VpcEndpointId"),
                "ServiceName": ep.get("ServiceName"),
                "VpcEndpointType": ep.get("VpcEndpointType"),
                "VpcId": ep.get("VpcId"),
                "Region": ec2.meta.region_name,
            }
            rec.update(
                {
                    "id": rec["VpcEndpointId"],
                    "resource_type": "vpc-endpoint",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": rec.get("VpcId"),
                    "tags": [],
                }
            )
            out.append(rec)
    return out


def _ecs_tags_to_aws(tags: Optional[List[Dict[str, Any]]]) -> List[Dict[str, str]]:
    """Convert ECS tag shape ({'key','value'}) into AWS tag shape ({'Key','Value'})."""
    out: List[Dict[str, str]] = []
    for t in tags or []:
        if not isinstance(t, dict):
            continue
        k = t.get("key")
        v = t.get("value")
        if k is None:
            continue
        out.append({"Key": str(k), "Value": "" if v is None else str(v)})
    return out


def _extract_taskdef_resources(task_def: Dict[str, Any]) -> Dict[str, Optional[int]]:
    """Extract task-level CPU units and memory MiB from an ECS task definition.

    Prefer task-level fields (TaskDefinition.cpu/memory). If absent, fall back to summing
    container-level values where possible.
    """
    cpu_units: Optional[int] = None
    memory_mib: Optional[int] = None

    try:
        cpu_raw = task_def.get("cpu")
        if cpu_raw is not None and str(cpu_raw).strip() != "":
            cpu_units = int(str(cpu_raw))
    except Exception:
        cpu_units = None

    try:
        mem_raw = task_def.get("memory")
        if mem_raw is not None and str(mem_raw).strip() != "":
            memory_mib = int(str(mem_raw))
    except Exception:
        memory_mib = None

    if cpu_units is None or memory_mib is None:
        # Fallback: sum container-level hints
        c_cpu = 0
        c_mem = 0
        saw_any = False
        for c in task_def.get("containerDefinitions", []) or []:
            if not isinstance(c, dict):
                continue
            saw_any = True
            try:
                if cpu_units is None:
                    c_cpu += int(c.get("cpu") or 0)
            except Exception:
                pass
            # Prefer memoryReservation if present, else memory
            try:
                if memory_mib is None:
                    c_mem += int(c.get("memoryReservation") or c.get("memory") or 0)
            except Exception:
                pass
        if saw_any:
            if cpu_units is None and c_cpu > 0:
                cpu_units = c_cpu
            if memory_mib is None and c_mem > 0:
                memory_mib = c_mem

    return {"TaskCpuUnits": cpu_units, "TaskMemoryMiB": memory_mib}


def list_ecs_clusters(
    session: boto3.Session,
    region_name: Optional[str] = None,
    include_tags: bool = False,
) -> List[Dict[str, Any]]:
    """List ECS clusters in a region."""
    ecs = (
        session.client("ecs", region_name=region_name)
        if region_name
        else session.client("ecs")
    )
    out: List[Dict[str, Any]] = []

    cluster_arns: List[str] = []
    paginator = ecs.get_paginator("list_clusters")
    for page in paginator.paginate():
        cluster_arns.extend(page.get("clusterArns", []) or [])

    # describe_clusters max 100
    for i in range(0, len(cluster_arns), 100):
        batch = cluster_arns[i : i + 100]
        if not batch:
            continue
        params: Dict[str, Any] = {"clusters": batch}
        if include_tags:
            params["include"] = ["TAGS", "SETTINGS", "STATISTICS"]
        resp = ecs.describe_clusters(**params)
        for c in resp.get("clusters", []) or []:
            if not isinstance(c, dict):
                continue
            tags = _ecs_tags_to_aws(c.get("tags")) if include_tags else []
            rec = {
                "ClusterArn": c.get("clusterArn"),
                "ClusterName": c.get("clusterName"),
                "Status": c.get("status"),
                "ActiveServicesCount": c.get("activeServicesCount"),
                "RunningTasksCount": c.get("runningTasksCount"),
                "RegisteredContainerInstancesCount": c.get(
                    "registeredContainerInstancesCount"
                ),
                "CapacityProviders": c.get("capacityProviders", []),
                "Tags": tags,
                "Region": ecs.meta.region_name,
            }
            rec.update(
                {
                    "id": rec.get("ClusterArn") or rec.get("ClusterName"),
                    "resource_type": "ecs_cluster",
                    "region": rec["Region"],
                    "availability_zone": None,
                    "vpc_id": None,
                    "tags": tags,
                }
            )
            out.append(rec)

    return out


def list_ecs_services(
    session: boto3.Session,
    region_name: Optional[str] = None,
    include_tags: bool = False,
) -> List[Dict[str, Any]]:
    """List ECS services in a region (across all clusters) and enrich with task CPU/memory."""
    ecs = (
        session.client("ecs", region_name=region_name)
        if region_name
        else session.client("ecs")
    )
    out: List[Dict[str, Any]] = []

    clusters = list_ecs_clusters(
        session, region_name=ecs.meta.region_name, include_tags=False
    )
    cluster_arns = [c.get("ClusterArn") for c in clusters if c.get("ClusterArn")]
    cluster_name_by_arn = {
        c.get("ClusterArn"): c.get("ClusterName")
        for c in clusters
        if c.get("ClusterArn")
    }

    for cluster_arn in cluster_arns:
        service_arns: List[str] = []
        paginator = ecs.get_paginator("list_services")
        for page in paginator.paginate(cluster=cluster_arn):
            service_arns.extend(page.get("serviceArns", []) or [])

        # describe_services max 10
        for i in range(0, len(service_arns), 10):
            batch = service_arns[i : i + 10]
            if not batch:
                continue
            params: Dict[str, Any] = {"cluster": cluster_arn, "services": batch}
            if include_tags:
                params["include"] = ["TAGS"]
            resp = ecs.describe_services(**params)
            for svc in resp.get("services", []) or []:
                if not isinstance(svc, dict):
                    continue

                task_cpu_units: Optional[int] = None
                task_memory_mib: Optional[int] = None
                task_def_arn = svc.get("taskDefinition")
                if task_def_arn:
                    try:
                        td = ecs.describe_task_definition(
                            taskDefinition=task_def_arn
                        ).get("taskDefinition", {})
                        res = _extract_taskdef_resources(td or {})
                        task_cpu_units = res.get("TaskCpuUnits")
                        task_memory_mib = res.get("TaskMemoryMiB")
                    except Exception:
                        task_cpu_units = None
                        task_memory_mib = None

                tags = _ecs_tags_to_aws(svc.get("tags")) if include_tags else []
                rec = {
                    "ServiceArn": svc.get("serviceArn"),
                    "ServiceName": svc.get("serviceName"),
                    "ClusterArn": cluster_arn,
                    "ClusterName": cluster_name_by_arn.get(cluster_arn),
                    "Status": svc.get("status"),
                    "LaunchType": svc.get("launchType"),
                    "CapacityProviderStrategy": svc.get("capacityProviderStrategy", []),
                    "DesiredCount": svc.get("desiredCount"),
                    "RunningCount": svc.get("runningCount"),
                    "PendingCount": svc.get("pendingCount"),
                    "TaskDefinition": task_def_arn,
                    "TaskCpuUnits": task_cpu_units,
                    "TaskMemoryMiB": task_memory_mib,
                    "Tags": tags,
                    "Region": ecs.meta.region_name,
                }
                rec.update(
                    {
                        "id": rec.get("ServiceArn") or rec.get("ServiceName"),
                        "resource_type": "ecs_service",
                        "region": rec["Region"],
                        "availability_zone": None,
                        "vpc_id": None,
                        "tags": tags,
                    }
                )
                out.append(rec)

    return out


# Simple retry/backoff wrapper
def with_retry(fn: Callable[[], Any], retries: int = 3, base_delay: float = 0.5):
    for attempt in range(retries):
        try:
            return fn()
        except Exception:
            if attempt == retries - 1:
                raise
            time.sleep(base_delay * (2**attempt))


# Error codes that mean "you don't have permission" — not worth retrying
_ACCESS_DENIED_CODES = frozenset(
    {
        "AccessDenied",
        "AccessDeniedException",
        "UnauthorizedOperation",
        "AuthorizationError",
        "AuthFailure",
        "ExpiredTokenException",
        "InvalidClientTokenId",
        "OptInRequired",  # service not enabled in this region / account
    }
)

# AWS error codes that indicate a transient/unrecoverable service issue;
# not worth crashing the whole scan over — skip and warn.
_SKIP_CODES = _ACCESS_DENIED_CODES | frozenset(
    {
        "InternalError",
        "InternalFailure",
        "ServiceUnavailable",
        "ServiceUnavailableException",
        "RequestExpired",
        "RequestTimeout",
        "Unavailable",
        # Bare HTTP status codes — botocore sometimes uses these as the Code
        "500",
        "502",
        "503",
        "504",
    }
)


def _should_skip(exc: ClientError) -> bool:
    """Return True if this ClientError should be skipped (warn + return [])."""
    code = exc.response.get("Error", {}).get("Code", "")
    if code in _SKIP_CODES:
        return True
    # Also skip on any 5xx HTTP status — covers cases where botocore surfaces
    # the raw HTTP status code rather than a named error code.
    http_status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode", 0)
    if isinstance(http_status, int) and http_status >= 500:
        return True
    return False


def safe_list(
    fn: Callable[[], Any],
    *,
    resource: str = "resource",
    region: str = "",
) -> list:
    """Run fn() with retry; return [] instead of crashing on access/service errors.

    SCP denials, IAM permission gaps, opt-in region restrictions, and transient
    AWS-side failures all produce a ClientError with a known code.  We log a
    warning and skip that service/region combination rather than aborting the scan.
    """
    try:
        return with_retry(fn)
    except ClientError as exc:
        if _should_skip(exc):
            code = exc.response.get("Error", {}).get("Code", "")
            loc = f" in {region}" if region else ""
            if code in _ACCESS_DENIED_CODES:
                reason = f"insufficient permissions ({code})"
            else:
                reason = f"AWS service error ({code})"
            logger.warning("Skipping %s%s — %s", resource, loc, reason)
            return []
        raise


def list_dynamodb_tables(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    """List DynamoDB tables in a region."""
    dynamodb = (
        session.client("dynamodb", region_name=region_name)
        if region_name
        else session.client("dynamodb")
    )
    out = []
    try:
        paginator = dynamodb.get_paginator("list_tables")
        for page in paginator.paginate():
            for table_name in page.get("TableNames", []):
                # Get table details
                try:
                    table_desc = dynamodb.describe_table(TableName=table_name)
                    table = table_desc.get("Table", {})
                    out.append(
                        {
                            "TableName": table.get("TableName"),
                            "TableArn": table.get("TableArn"),
                            "TableStatus": table.get("TableStatus"),
                            "BillingModeSummary": table.get("BillingModeSummary"),
                            "ProvisionedThroughput": table.get("ProvisionedThroughput"),
                            "TableSizeBytes": table.get("TableSizeBytes", 0),
                            "ItemCount": table.get("ItemCount", 0),
                            "Region": dynamodb.meta.region_name,
                        }
                    )
                except Exception:
                    # Skip tables we can't describe
                    pass
    except Exception:
        pass
    return out


def list_elasticache_clusters(
    session: boto3.Session, region_name: Optional[str] = None
) -> List[Dict[str, Any]]:
    """List ElastiCache clusters (both Redis and Memcached) in a region."""
    elasticache = (
        session.client("elasticache", region_name=region_name)
        if region_name
        else session.client("elasticache")
    )
    out = []
    try:
        # Get cache clusters
        paginator = elasticache.get_paginator("describe_cache_clusters")
        for page in paginator.paginate():
            for cluster in page.get("CacheClusters", []):
                out.append(
                    {
                        "CacheClusterId": cluster.get("CacheClusterId"),
                        "CacheNodeType": cluster.get("CacheNodeType"),
                        "Engine": cluster.get("Engine"),
                        "NumCacheNodes": cluster.get("NumCacheNodes", 1),
                        "CacheClusterStatus": cluster.get("CacheClusterStatus"),
                        "ARN": cluster.get("ARN"),
                        "Region": elasticache.meta.region_name,
                    }
                )

        # Also get replication groups (Redis multi-node)
        try:
            rg_paginator = elasticache.get_paginator("describe_replication_groups")
            for page in rg_paginator.paginate():
                for rg in page.get("ReplicationGroups", []):
                    out.append(
                        {
                            "ReplicationGroupId": rg.get("ReplicationGroupId"),
                            "CacheNodeType": rg.get("CacheNodeType"),
                            "Engine": "redis",
                            "NumCacheNodes": len(rg.get("MemberClusters", [])),
                            "Status": rg.get("Status"),
                            "ARN": rg.get("ARN"),
                            "Region": elasticache.meta.region_name,
                        }
                    )
        except Exception:
            pass
    except Exception:
        pass
    return out


def list_cloudfront_distributions(session: boto3.Session) -> List[Dict[str, Any]]:
    """List CloudFront distributions (global service - always use us-east-1)."""
    cloudfront = session.client("cloudfront", region_name="us-east-1")
    out = []
    try:
        paginator = cloudfront.get_paginator("list_distributions")
        for page in paginator.paginate():
            dist_list = page.get("DistributionList", {})
            for dist_summary in dist_list.get("Items", []):
                out.append(
                    {
                        "Id": dist_summary.get("Id"),
                        "ARN": dist_summary.get("ARN"),
                        "DomainName": dist_summary.get("DomainName"),
                        "Status": dist_summary.get("Status"),
                        "Enabled": dist_summary.get("Enabled"),
                        "PriceClass": dist_summary.get("PriceClass"),
                        "Origins": dist_summary.get("Origins", {}).get("Items", []),
                    }
                )
    except Exception:
        pass
    return out


def list_route53_hosted_zones(session: boto3.Session) -> List[Dict[str, Any]]:
    """List Route 53 hosted zones (global service - always use us-east-1)."""
    route53 = session.client("route53", region_name="us-east-1")
    out = []
    try:
        paginator = route53.get_paginator("list_hosted_zones")
        for page in paginator.paginate():
            for zone in page.get("HostedZones", []):
                out.append(
                    {
                        "Id": zone.get("Id"),
                        "Name": zone.get("Name"),
                        "Config": zone.get("Config", {}),
                        "ResourceRecordSetCount": zone.get("ResourceRecordSetCount", 0),
                    }
                )
    except Exception:
        pass
    return out


def get_enabled_regions(session: boto3.Session) -> List[str]:
    """Return a list of enabled EC2 regions for the account (fallback to all public regions)."""
    # Ensure we have a region for the EC2 endpoint resolution
    default_region = (
        getattr(session, "region_name", None)
        or os.environ.get("STACKSAGE_DEFAULT_REGION")
        or os.environ.get("AWS_DEFAULT_REGION")
        or "us-east-1"
    )
    ec2 = session.client("ec2", region_name=default_region)
    try:
        regions = ec2.describe_regions(AllRegions=False).get("Regions", [])
        return [r.get("RegionName") for r in regions]
    except Exception:
        # Fallback list (subset) if call fails
        return [
            "us-east-1",
            "us-east-2",
            "us-west-1",
            "us-west-2",
            "eu-west-1",
            "eu-central-1",
            "ap-south-1",
            "ap-southeast-1",
            "ap-southeast-2",
        ]


def scan_resources(
    session: boto3.Session,
    regions: Optional[List[str]] = None,
    include_tags: bool = False,
) -> Dict[str, List[Dict[str, Any]]]:
    """Scan resources across specified regions; S3, CloudFront, and Route 53 are global per account.

    Returns keys:
    - ec2, ebs, rds, s3, snapshots, nat_gateways, eips, load_balancers, vpc_endpoints
    - ecs_clusters, ecs_services
    - dynamodb_tables, elasticache_redis_clusters, elasticache_memcached_clusters
    - cloudfront_distributions, route53_hosted_zones
    """
    if not regions:
        regions = get_enabled_regions(session)

    results = {
        "ec2": [],
        "ebs": [],
        "rds": [],
        "snapshots": [],
        "nat_gateways": [],
        "eips": [],
        "load_balancers": [],
        "vpc_endpoints": [],
        "ecs_clusters": [],
        "ecs_services": [],
        "dynamodb_tables": [],
        "elasticache_redis_clusters": [],
        "elasticache_memcached_clusters": [],
        # Global services (fetched once)
        "s3": list_s3(session),
        "cloudfront_distributions": list_cloudfront_distributions(session),
        "route53_hosted_zones": list_route53_hosted_zones(session),
    }

    def scan_region(reg: str) -> Dict[str, List[Dict[str, Any]]]:
        elasticache_clusters = safe_list(
            lambda: list_elasticache_clusters(session, region_name=reg),
            resource="elasticache",
            region=reg,
        )
        # Separate ElastiCache clusters by engine type
        redis_clusters = [c for c in elasticache_clusters if c.get("Engine") == "redis"]
        memcached_clusters = [
            c for c in elasticache_clusters if c.get("Engine") == "memcached"
        ]

        return {
            "ec2": safe_list(
                lambda: list_ec2(session, region_name=reg), resource="ec2", region=reg
            ),
            "ebs": safe_list(
                lambda: list_ebs(session, region_name=reg), resource="ebs", region=reg
            ),
            "rds": safe_list(
                lambda: list_rds(session, region_name=reg, include_tags=include_tags),
                resource="rds",
                region=reg,
            ),
            "snapshots": safe_list(
                lambda: list_snapshots(session, region_name=reg),
                resource="snapshots",
                region=reg,
            ),
            "nat_gateways": safe_list(
                lambda: list_nat_gateways(session, region_name=reg),
                resource="nat_gateways",
                region=reg,
            ),
            "eips": safe_list(
                lambda: list_eips(session, region_name=reg), resource="eips", region=reg
            ),
            "load_balancers": safe_list(
                lambda: list_load_balancers(session, region_name=reg),
                resource="load_balancers",
                region=reg,
            ),
            "vpc_endpoints": safe_list(
                lambda: list_vpc_endpoints(session, region_name=reg),
                resource="vpc_endpoints",
                region=reg,
            ),
            "ecs_clusters": safe_list(
                lambda: list_ecs_clusters(
                    session, region_name=reg, include_tags=include_tags
                ),
                resource="ecs_clusters",
                region=reg,
            ),
            "ecs_services": safe_list(
                lambda: list_ecs_services(
                    session, region_name=reg, include_tags=include_tags
                ),
                resource="ecs_services",
                region=reg,
            ),
            "dynamodb_tables": safe_list(
                lambda: list_dynamodb_tables(session, region_name=reg),
                resource="dynamodb_tables",
                region=reg,
            ),
            "elasticache_redis_clusters": redis_clusters,
            "elasticache_memcached_clusters": memcached_clusters,
        }

    # Bounded concurrency over regions
    with ThreadPoolExecutor(max_workers=min(8, len(regions))) as ex:
        futures = {ex.submit(scan_region, reg): reg for reg in regions}
        for fut in as_completed(futures):
            reg = futures[fut]
            try:
                region_data = fut.result()
            except ClientError as exc:
                if _should_skip(exc):
                    code = exc.response.get("Error", {}).get("Code", "")
                    reason = (
                        f"insufficient permissions ({code})"
                        if code in _ACCESS_DENIED_CODES
                        else f"AWS service error ({code})"
                    )
                    logger.warning("Skipping region %s — %s", reg, reason)
                    continue
                raise
            for k, v in region_data.items():
                results[k].extend(v)

    return results
