"""Tier-1 security + exposure + audit-logging posture detectors.

Design goals:
- Privacy-first: prefer aggregate evidence; avoid raw inventory unless needed to remediate.
- Industry-standard checks: root posture, password policy, key hygiene, public exposure, audit logging.
- Graceful permissions: if an API is not authorized, emit a single info finding explaining what was skipped.

These detectors are intended to run only when a live boto3 session is available.
"""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional

SENSITIVE_PORTS = {
    20,
    21,
    22,
    23,
    25,
    53,
    80,
    110,
    135,
    139,
    143,
    443,
    445,
    465,
    5432,
    6379,
    8080,
    8443,
    9200,
    27017,
    3306,
    3389,
}


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _classify_access_error(e: Exception) -> str:
    msg = str(e).lower()
    if "accessdenied" in msg or "access denied" in msg or "not authorized" in msg:
        return "not_authorized"
    return "other"


def _info_skipped(
    *,
    check: str,
    service: str,
    explanation: str,
    region: Optional[str] = None,
    error: Optional[str] = None,
) -> Dict[str, Any]:
    return {
        "type": "posture_check_skipped",
        "resource_type": "account",
        "id": "account",
        "region": region,
        "severity": "info",
        "confidence": 1.0,
        "estimated_monthly_savings_usd": 0,
        "recommended_action": "grant-readonly-permissions",
        "explanation": explanation,
        "reason_codes": (
            ["skipped", "not_authorized"] if error == "not_authorized" else ["skipped"]
        ),
        "evidence": {
            "posture": {
                "check": check,
                "service": service,
                "status": "skipped",
                "error": error,
            }
        },
    }


def _finding(
    *,
    ftype: str,
    resource_type: str,
    rid: str,
    region: Optional[str],
    severity: str,
    confidence: float,
    recommended_action: str,
    explanation: str,
    reason_codes: Optional[List[str]] = None,
    evidence: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    return {
        "type": ftype,
        "resource_type": resource_type,
        "id": rid,
        "region": region,
        "severity": severity,
        "confidence": confidence,
        "estimated_monthly_savings_usd": 0,
        "estimated_monthly_cost_usd": None,
        "recommended_action": recommended_action,
        "explanation": explanation,
        "reason_codes": reason_codes or [],
        "evidence": evidence or {},
        "remediation_commands": [],
    }


def detect_iam_root_posture(session) -> List[Dict[str, Any]]:
    findings: List[Dict[str, Any]] = []
    try:
        iam = session.client("iam")
        summary = iam.get_account_summary().get("SummaryMap", {})
        mfa_enabled = int(summary.get("AccountMFAEnabled") or 0)
        root_keys_present = int(summary.get("AccountAccessKeysPresent") or 0)

        if mfa_enabled == 0:
            findings.append(
                _finding(
                    ftype="iam_root_mfa_disabled",
                    resource_type="account",
                    rid="root",
                    region=None,
                    severity="critical",
                    confidence=1.0,
                    recommended_action="enable-root-mfa",
                    explanation="Root MFA is not enabled. Enable MFA on the root user and restrict root usage.",
                    reason_codes=["security_baseline"],
                    evidence={"iam": {"account_mfa_enabled": False}},
                )
            )

        if root_keys_present > 0:
            findings.append(
                _finding(
                    ftype="iam_root_access_keys_present",
                    resource_type="account",
                    rid="root",
                    region=None,
                    severity="critical",
                    confidence=1.0,
                    recommended_action="remove-root-access-keys",
                    explanation="Root access keys are present. Delete root access keys and use least-privilege IAM roles/users.",
                    reason_codes=["security_baseline"],
                    evidence={"iam": {"root_access_keys_present": True}},
                )
            )

        return findings
    except Exception as e:
        cls = _classify_access_error(e)
        return [
            _info_skipped(
                check="iam_root_posture",
                service="iam",
                explanation="Could not evaluate IAM root posture (MFA/access keys).",
                error=cls,
            )
        ]


def detect_iam_password_policy(session) -> List[Dict[str, Any]]:
    try:
        iam = session.client("iam")
        try:
            policy = iam.get_account_password_policy().get("PasswordPolicy", {})
        except Exception as e:
            # No password policy is common; treat as a finding when readable.
            msg = str(e).lower()
            if "nosuchentity" in msg or "no such entity" in msg:
                return [
                    _finding(
                        ftype="iam_password_policy_missing",
                        resource_type="account",
                        rid="account",
                        region=None,
                        severity="medium",
                        confidence=1.0,
                        recommended_action="set-iam-password-policy",
                        explanation="No IAM account password policy is set. Define a strong password policy (length, complexity, rotation, reuse prevention).",
                        reason_codes=["security_baseline"],
                        evidence={"iam": {"password_policy_present": False}},
                    )
                ]
            cls = _classify_access_error(e)
            return [
                _info_skipped(
                    check="iam_password_policy",
                    service="iam",
                    explanation="Could not evaluate IAM password policy.",
                    error=cls,
                )
            ]

        # Basic baseline (conservative defaults): length>=14, reuse>=24, max age<=90.
        min_len = int(policy.get("MinimumPasswordLength") or 0)
        reuse = int(policy.get("PasswordReusePrevention") or 0)
        max_age = int(policy.get("MaxPasswordAge") or 0)
        require_symbols = bool(policy.get("RequireSymbols"))
        require_numbers = bool(policy.get("RequireNumbers"))
        require_upper = bool(policy.get("RequireUppercaseCharacters"))
        require_lower = bool(policy.get("RequireLowercaseCharacters"))

        issues: List[str] = []
        if min_len < 14:
            issues.append(f"MinimumPasswordLength {min_len} < 14")
        if reuse and reuse < 24:
            issues.append(f"PasswordReusePrevention {reuse} < 24")
        if max_age and max_age > 90:
            issues.append(f"MaxPasswordAge {max_age} > 90")
        if not (
            require_symbols and require_numbers and require_upper and require_lower
        ):
            issues.append(
                "Missing complexity requirements (symbols/numbers/upper/lower)"
            )

        if issues:
            return [
                _finding(
                    ftype="iam_password_policy_weak",
                    resource_type="account",
                    rid="account",
                    region=None,
                    severity="medium",
                    confidence=0.95,
                    recommended_action="harden-iam-password-policy",
                    explanation="IAM password policy is weaker than common baseline settings.",
                    reason_codes=["security_baseline"],
                    evidence={
                        "iam": {
                            "password_policy_present": True,
                            "issues": issues,
                            "minimum_password_length": min_len,
                            "password_reuse_prevention": reuse,
                            "max_password_age": max_age,
                        }
                    },
                )
            ]

        return []
    except Exception as e:
        cls = _classify_access_error(e)
        return [
            _info_skipped(
                check="iam_password_policy",
                service="iam",
                explanation="Could not evaluate IAM password policy.",
                error=cls,
            )
        ]


def detect_iam_access_key_hygiene(
    session, *, max_key_age_days: int = 90
) -> List[Dict[str, Any]]:
    try:
        iam = session.client("iam")
        now = _utcnow()
        total_keys = 0
        old_active_keys = 0
        never_used_keys = 0

        paginator = iam.get_paginator("list_users")
        for page in paginator.paginate():
            for user in page.get("Users", []):
                user_name = user.get("UserName")
                if not user_name:
                    continue
                key_page = iam.list_access_keys(UserName=user_name)
                for meta in key_page.get("AccessKeyMetadata", []):
                    total_keys += 1
                    status = (meta.get("Status") or "").upper()
                    create_date = meta.get("CreateDate")
                    if isinstance(create_date, str):
                        # rare, but be defensive
                        try:
                            create_date = datetime.fromisoformat(create_date)
                        except Exception:
                            create_date = None
                    age_days = None
                    if create_date is not None:
                        try:
                            age_days = int((now - create_date).total_seconds() // 86400)
                        except Exception:
                            age_days = None

                    if (
                        status == "ACTIVE"
                        and age_days is not None
                        and age_days > max_key_age_days
                    ):
                        old_active_keys += 1

                    # last-used lookup is separate API call
                    access_key_id = meta.get("AccessKeyId")
                    if access_key_id:
                        try:
                            last_used = iam.get_access_key_last_used(
                                AccessKeyId=access_key_id
                            )
                            lu = (last_used.get("AccessKeyLastUsed") or {}).get(
                                "LastUsedDate"
                            )
                            if lu is None:
                                never_used_keys += 1
                        except Exception:
                            # If this call is denied, we still want to keep the detector usable.
                            pass

        if total_keys == 0:
            return []

        if old_active_keys == 0 and never_used_keys == 0:
            return []

        return [
            _finding(
                ftype="iam_access_key_hygiene",
                resource_type="account",
                rid="account",
                region=None,
                severity="medium" if old_active_keys else "low",
                confidence=0.9,
                recommended_action="rotate-or-delete-access-keys",
                explanation="Some IAM access keys appear stale or unused. Rotate or delete old/unused keys and prefer role-based access.",
                reason_codes=["security_baseline"],
                evidence={
                    "iam": {
                        "access_keys_total": total_keys,
                        "active_keys_older_than_days": max_key_age_days,
                        "active_keys_old_count": old_active_keys,
                        "never_used_key_count": never_used_keys,
                    }
                },
            )
        ]
    except Exception as e:
        cls = _classify_access_error(e)
        return [
            _info_skipped(
                check="iam_access_key_hygiene",
                service="iam",
                explanation="Could not evaluate IAM access key hygiene.",
                error=cls,
            )
        ]


def detect_security_groups_open_to_world(
    session, *, regions: List[str]
) -> List[Dict[str, Any]]:
    findings: List[Dict[str, Any]] = []
    for region in regions:
        try:
            ec2 = session.client("ec2", region_name=region)
            resp = ec2.describe_security_groups()
            for sg in resp.get("SecurityGroups", []):
                group_id = sg.get("GroupId")
                if not group_id:
                    continue

                exposures: List[Dict[str, Any]] = []
                for perm in sg.get("IpPermissions", []) or []:
                    ip_proto = perm.get("IpProtocol")
                    from_port = perm.get("FromPort")
                    to_port = perm.get("ToPort")
                    ipv4_ranges = [
                        r.get("CidrIp") for r in (perm.get("IpRanges") or [])
                    ]
                    ipv6_ranges = [
                        r.get("CidrIpv6") for r in (perm.get("Ipv6Ranges") or [])
                    ]
                    is_world = ("0.0.0.0/0" in ipv4_ranges) or ("::/0" in ipv6_ranges)
                    if not is_world:
                        continue
                    # If ports not specified (e.g., -1), treat as broad exposure.
                    if from_port is None or to_port is None:
                        exposures.append(
                            {
                                "protocol": ip_proto,
                                "from_port": from_port,
                                "to_port": to_port,
                                "cidr": "world",
                            }
                        )
                        continue
                    # Record only sensitive ports.
                    for p in range(int(from_port), int(to_port) + 1):
                        if p in SENSITIVE_PORTS:
                            exposures.append(
                                {
                                    "protocol": ip_proto,
                                    "port": p,
                                    "cidr": "world",
                                }
                            )
                if exposures:
                    findings.append(
                        _finding(
                            ftype="sg_open_to_world",
                            resource_type="security-group",
                            rid=group_id,
                            region=region,
                            severity="high",
                            confidence=0.95,
                            recommended_action="restrict-security-group-ingress",
                            explanation="Security group allows inbound access from the public internet on sensitive ports.",
                            reason_codes=["public_exposure"],
                            evidence={"network": {"exposures": exposures}},
                        )
                    )
        except Exception as e:
            cls = _classify_access_error(e)
            findings.append(
                _info_skipped(
                    check="security_groups_open_to_world",
                    service="ec2",
                    region=region,
                    explanation="Could not evaluate security group public exposure in this region.",
                    error=cls,
                )
            )
    return findings


def detect_rds_security_basics(
    rds_instances: Iterable[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    findings: List[Dict[str, Any]] = []
    for db in rds_instances or []:
        dbid = db.get("DBInstanceIdentifier") or db.get("id")
        region = db.get("Region") or db.get("region")
        if not dbid:
            continue
        publicly = db.get("PubliclyAccessible")
        encrypted = db.get("StorageEncrypted")
        backup_ret = db.get("BackupRetentionPeriod")

        if publicly is True:
            findings.append(
                _finding(
                    ftype="rds_publicly_accessible",
                    resource_type="rds",
                    rid=dbid,
                    region=region,
                    severity="high",
                    confidence=0.95,
                    recommended_action="make-rds-private",
                    explanation="RDS instance is publicly accessible. Restrict it to private subnets and limit inbound access.",
                    reason_codes=["public_exposure"],
                    evidence={"rds": {"publicly_accessible": True}},
                )
            )
        if encrypted is False:
            findings.append(
                _finding(
                    ftype="rds_storage_not_encrypted",
                    resource_type="rds",
                    rid=dbid,
                    region=region,
                    severity="high",
                    confidence=0.9,
                    recommended_action="enable-rds-encryption",
                    explanation="RDS storage encryption is disabled. Encrypt at rest using KMS.",
                    reason_codes=["security_baseline"],
                    evidence={"rds": {"storage_encrypted": False}},
                )
            )
        if backup_ret is not None:
            try:
                if int(backup_ret) < 7:
                    findings.append(
                        _finding(
                            ftype="rds_backup_retention_low",
                            resource_type="rds",
                            rid=dbid,
                            region=region,
                            severity="medium",
                            confidence=0.85,
                            recommended_action="increase-rds-backup-retention",
                            explanation="RDS backup retention is low. Increase backup retention to improve recovery posture.",
                            reason_codes=["security_baseline"],
                            evidence={
                                "rds": {"backup_retention_days": int(backup_ret)}
                            },
                        )
                    )
            except Exception:
                pass
    return findings


def detect_s3_public_access_basics(
    session, *, bucket_names: List[str], include_bucket_names: bool = False
) -> List[Dict[str, Any]]:
    findings: List[Dict[str, Any]] = []
    # Account-level block public access
    try:
        s3control = session.client("s3control")
        pab = s3control.get_public_access_block(
            AccountId=session.client("sts").get_caller_identity().get("Account")
        )
        cfg = pab.get("PublicAccessBlockConfiguration") or {}
        required = [
            ("BlockPublicAcls", True),
            ("IgnorePublicAcls", True),
            ("BlockPublicPolicy", True),
            ("RestrictPublicBuckets", True),
        ]
        missing = [k for (k, v) in required if bool(cfg.get(k)) is not v]
        if missing:
            findings.append(
                _finding(
                    ftype="s3_account_public_access_block_disabled",
                    resource_type="account",
                    rid="account",
                    region=None,
                    severity="high",
                    confidence=0.9,
                    recommended_action="enable-s3-block-public-access",
                    explanation="S3 account-level Block Public Access is not fully enabled.",
                    reason_codes=["public_exposure"],
                    evidence={"s3": {"account_public_access_block_missing": missing}},
                )
            )
    except Exception as e:
        cls = _classify_access_error(e)
        findings.append(
            _info_skipped(
                check="s3_account_public_access_block",
                service="s3control",
                explanation="Could not evaluate S3 account-level Block Public Access.",
                error=cls,
            )
        )

    # Bucket-level policy status (aggregate)
    public_buckets: List[str] = []
    checked = 0
    try:
        s3 = session.client("s3")
        for name in bucket_names:
            if not name:
                continue
            checked += 1
            try:
                st = s3.get_bucket_policy_status(Bucket=name)
                is_public = bool(((st.get("PolicyStatus") or {}).get("IsPublic")))
                if is_public:
                    public_buckets.append(name)
            except Exception:
                # ignore per-bucket errors (AccessDenied for some buckets, etc.)
                continue
        if public_buckets:
            ev: Dict[str, Any] = {
                "s3": {
                    "buckets_checked": checked,
                    "public_buckets_count": len(public_buckets),
                }
            }
            if include_bucket_names:
                ev["s3"]["public_buckets"] = public_buckets
            findings.append(
                _finding(
                    ftype="s3_buckets_public",
                    resource_type="account",
                    rid="account",
                    region=None,
                    severity="high",
                    confidence=0.8,
                    recommended_action="remediate-public-s3-buckets",
                    explanation="One or more S3 buckets appear publicly accessible via bucket policy.",
                    reason_codes=["public_exposure"],
                    evidence=ev,
                )
            )
    except Exception as e:
        cls = _classify_access_error(e)
        findings.append(
            _info_skipped(
                check="s3_bucket_policy_status",
                service="s3",
                explanation="Could not evaluate S3 bucket policy public status.",
                error=cls,
            )
        )

    return findings


def detect_s3_default_encryption(
    session, *, bucket_names: List[str], include_bucket_names: bool = False
) -> List[Dict[str, Any]]:
    findings: List[Dict[str, Any]] = []
    buckets_missing: List[str] = []
    checked = 0
    try:
        s3 = session.client("s3")
        for name in bucket_names:
            if not name:
                continue
            checked += 1
            try:
                s3.get_bucket_encryption(Bucket=name)
            except Exception as e:
                msg = str(e).lower()
                if "server-side encryption" in msg or "notfound" in msg:
                    buckets_missing.append(name)
                    continue
                # ignore per-bucket access errors
                continue
        if buckets_missing:
            ev: Dict[str, Any] = {
                "s3": {
                    "buckets_checked": checked,
                    "buckets_without_default_encryption_count": len(buckets_missing),
                }
            }
            if include_bucket_names:
                ev["s3"]["buckets_without_default_encryption"] = buckets_missing
            findings.append(
                _finding(
                    ftype="s3_bucket_default_encryption_missing",
                    resource_type="account",
                    rid="account",
                    region=None,
                    severity="medium",
                    confidence=0.8,
                    recommended_action="enable-s3-default-encryption",
                    explanation="One or more S3 buckets are missing default encryption. Enable SSE-KMS or SSE-S3 at the bucket level.",
                    reason_codes=["security_baseline"],
                    evidence=ev,
                )
            )
    except Exception as e:
        cls = _classify_access_error(e)
        findings.append(
            _info_skipped(
                check="s3_bucket_default_encryption",
                service="s3",
                explanation="Could not evaluate S3 bucket default encryption settings.",
                error=cls,
            )
        )
    return findings


def detect_cloudtrail_basics(session, *, regions: List[str]) -> List[Dict[str, Any]]:
    # CloudTrail is regional; check in provided regions and summarize.
    issues: List[str] = []
    trails_seen = 0
    multi_region = 0
    logging_ok = 0
    validation_ok = 0
    kms_ok = 0

    for region in regions:
        try:
            ct = session.client("cloudtrail", region_name=region)
            resp = ct.describe_trails(includeShadowTrails=True)
            trails = resp.get("trailList", []) or []
            trails_seen += len(trails)
            for t in trails:
                if t.get("IsMultiRegionTrail"):
                    multi_region += 1
                if t.get("LogFileValidationEnabled"):
                    validation_ok += 1
                if t.get("KmsKeyId"):
                    kms_ok += 1
                # check status for configured trails
                name = t.get("Name") or t.get("TrailARN")
                if name:
                    try:
                        st = ct.get_trail_status(Name=name)
                        if bool(st.get("IsLogging")):
                            logging_ok += 1
                    except Exception:
                        pass
        except Exception as e:
            cls = _classify_access_error(e)
            return [
                _info_skipped(
                    check="cloudtrail_basics",
                    service="cloudtrail",
                    region=region,
                    explanation="Could not evaluate CloudTrail posture.",
                    error=cls,
                )
            ]

    if trails_seen == 0:
        issues.append("No trails found")
    if multi_region == 0:
        issues.append("No multi-region trail")
    if logging_ok == 0:
        issues.append("No trail appears to be actively logging")

    if issues:
        return [
            _finding(
                ftype="cloudtrail_not_configured",
                resource_type="account",
                rid="account",
                region=None,
                severity="high",
                confidence=0.9,
                recommended_action="enable-cloudtrail",
                explanation="CloudTrail baseline appears incomplete. Enable an org/account trail (multi-region) and ensure it is actively logging.",
                reason_codes=["audit_logging"],
                evidence={
                    "cloudtrail": {
                        "regions_checked": regions,
                        "trails_seen": trails_seen,
                        "multi_region_trails": multi_region,
                        "logging_trails": logging_ok,
                        "log_file_validation_enabled_count": validation_ok,
                        "kms_encrypted_count": kms_ok,
                        "issues": issues,
                    }
                },
            )
        ]

    return []


def detect_ebs_encryption_by_default(
    session, *, region: Optional[str]
) -> List[Dict[str, Any]]:
    if not region:
        return []
    try:
        ec2 = session.client("ec2", region_name=region)
        resp = ec2.get_ebs_encryption_by_default()
        enabled = bool(resp.get("EbsEncryptionByDefault"))
        if not enabled:
            return [
                _finding(
                    ftype="ebs_encryption_by_default_disabled",
                    resource_type="account",
                    rid="account",
                    region=region,
                    severity="medium",
                    confidence=0.85,
                    recommended_action="enable-ebs-encryption-by-default",
                    explanation="EBS encryption by default is disabled in this region.",
                    reason_codes=["security_baseline"],
                    evidence={"ec2": {"ebs_encryption_by_default": False}},
                )
            ]
        return []
    except Exception as e:
        cls = _classify_access_error(e)
        return [
            _info_skipped(
                check="ebs_encryption_by_default",
                service="ec2",
                region=region,
                explanation="Could not evaluate EBS encryption-by-default settings.",
                error=cls,
            )
        ]


def detect_aws_config_basics(session, *, region: str) -> List[Dict[str, Any]]:
    try:
        cfg = session.client("config", region_name=region)
        status = cfg.describe_configuration_recorder_status().get(
            "ConfigurationRecordersStatus", []
        )
        if not status:
            return [
                _finding(
                    ftype="aws_config_not_enabled",
                    resource_type="account",
                    rid="account",
                    region=region,
                    severity="medium",
                    confidence=0.85,
                    recommended_action="enable-aws-config",
                    explanation="AWS Config recorder appears disabled or not configured.",
                    reason_codes=["audit_logging"],
                    evidence={"config": {"recorders_status_count": 0}},
                )
            ]
        recording = any(bool(s.get("recording")) for s in status if isinstance(s, dict))
        if not recording:
            return [
                _finding(
                    ftype="aws_config_not_recording",
                    resource_type="account",
                    rid="account",
                    region=region,
                    severity="medium",
                    confidence=0.85,
                    recommended_action="enable-aws-config-recording",
                    explanation="AWS Config is present but not recording.",
                    reason_codes=["audit_logging"],
                    evidence={"config": {"recorders": status}},
                )
            ]
        return []
    except Exception as e:
        cls = _classify_access_error(e)
        return [
            _info_skipped(
                check="aws_config_basics",
                service="config",
                region=region,
                explanation="Could not evaluate AWS Config recorder status.",
                error=cls,
            )
        ]


def detect_guardduty_enabled(session, *, regions: List[str]) -> List[Dict[str, Any]]:
    disabled: List[str] = []
    for region in regions:
        try:
            gd = session.client("guardduty", region_name=region)
            det = gd.list_detectors().get("DetectorIds", [])
            if not det:
                disabled.append(region)
        except Exception:
            # skip region errors
            continue
    if disabled:
        return [
            _finding(
                ftype="guardduty_not_enabled",
                resource_type="account",
                rid="account",
                region=None,
                severity="medium",
                confidence=0.8,
                recommended_action="enable-guardduty",
                explanation="GuardDuty is not enabled in one or more regions checked.",
                reason_codes=["audit_logging"],
                evidence={"guardduty": {"regions_missing": disabled}},
            )
        ]
    return []


def detect_cloudwatch_alarms_presence(
    session, *, regions: List[str]
) -> List[Dict[str, Any]]:
    total_alarms = 0
    region_counts: Dict[str, int] = {}
    for region in regions:
        try:
            cw = session.client("cloudwatch", region_name=region)
            paginator = cw.get_paginator("describe_alarms")
            count = 0
            for page in paginator.paginate():
                alarms = page.get("MetricAlarms", []) or []
                count += len(alarms)
            region_counts[region] = count
            total_alarms += count
        except Exception as e:
            cls = _classify_access_error(e)
            return [
                _info_skipped(
                    check="cloudwatch_alarms_presence",
                    service="cloudwatch",
                    region=region,
                    explanation="Could not evaluate CloudWatch alarms presence.",
                    error=cls,
                )
            ]

    if total_alarms == 0:
        return [
            _finding(
                ftype="cloudwatch_alarms_missing",
                resource_type="account",
                rid="account",
                region=None,
                severity="low",
                confidence=0.7,
                recommended_action="create-cloudwatch-alarms",
                explanation="No CloudWatch alarms were detected in the checked regions. Alarms improve incident detection and response.",
                reason_codes=["audit_logging"],
                evidence={
                    "cloudwatch": {
                        "alarms_total": total_alarms,
                        "alarms_by_region": region_counts,
                    }
                },
            )
        ]
    return []


def detect_securityhub_enabled(session, *, regions: List[str]) -> List[Dict[str, Any]]:
    disabled: List[str] = []
    for region in regions:
        try:
            sh = session.client("securityhub", region_name=region)
            _ = sh.describe_hub()
        except Exception:
            disabled.append(region)
    if disabled:
        return [
            _finding(
                ftype="securityhub_not_enabled",
                resource_type="account",
                rid="account",
                region=None,
                severity="low",
                confidence=0.75,
                recommended_action="enable-securityhub",
                explanation="Security Hub is not enabled in one or more regions checked.",
                reason_codes=["audit_logging"],
                evidence={"securityhub": {"regions_missing": disabled}},
            )
        ]
    return []


def detect_access_analyzer_external_access(
    session, *, regions: List[str], max_findings_per_analyzer: int = 200
) -> List[Dict[str, Any]]:
    total_active = 0
    analyzers = 0
    for region in regions:
        try:
            aa = session.client("accessanalyzer", region_name=region)
            resp = aa.list_analyzers()
            for an in resp.get("analyzers", []) or []:
                analyzers += 1
                arn = an.get("arn")
                if not arn:
                    continue
                try:
                    fr = aa.list_findings(
                        analyzerArn=arn,
                        filter={"status": {"eq": ["ACTIVE"]}},
                        maxResults=max_findings_per_analyzer,
                    )
                    total_active += len(fr.get("findings", []) or [])
                except Exception:
                    continue
        except Exception:
            continue

    if analyzers == 0:
        return []

    if total_active > 0:
        return [
            _finding(
                ftype="iam_access_analyzer_external_access",
                resource_type="account",
                rid="account",
                region=None,
                severity="high",
                confidence=0.7,
                recommended_action="review-access-analyzer-findings",
                explanation="IAM Access Analyzer reports active external access findings (count only). Review and remediate as appropriate.",
                reason_codes=["public_exposure"],
                evidence={
                    "access_analyzer": {
                        "analyzers_seen": analyzers,
                        "active_external_access_findings_count": total_active,
                        "note": f"Count may be truncated at {max_findings_per_analyzer} per analyzer.",
                    }
                },
            )
        ]

    return []


def detect_incident_response_readiness(
    findings: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    missing: List[str] = []
    finding_types = {f.get("type") for f in findings}
    if "cloudtrail_not_configured" in finding_types:
        missing.append("CloudTrail")
    if "guardduty_not_enabled" in finding_types:
        missing.append("GuardDuty")
    if (
        "aws_config_not_enabled" in finding_types
        or "aws_config_not_recording" in finding_types
    ):
        missing.append("AWS Config")
    if "securityhub_not_enabled" in finding_types:
        missing.append("Security Hub")

    if not missing:
        return []

    severity = "medium" if len(missing) >= 2 else "low"
    return [
        _finding(
            ftype="incident_response_readiness_low",
            resource_type="account",
            rid="account",
            region=None,
            severity=severity,
            confidence=0.7,
            recommended_action="enable-incident-response-baselines",
            explanation="One or more baseline detection services are not enabled. These improve incident response readiness.",
            reason_codes=["audit_logging"],
            evidence={"incident_response": {"missing_controls": missing}},
        )
    ]


def detect_tier1_posture(
    session,
    raw: Dict[str, Any],
    *,
    regions: List[str],
    include_bucket_names: bool = False,
) -> List[Dict[str, Any]]:
    """Run Tier-1 posture detectors.

    Args:
        session: boto3 session
        raw: scanner output
        regions: regions to check for regional services
        include_bucket_names: whether to include bucket names in evidence for public buckets
    """

    findings: List[Dict[str, Any]] = []

    findings.extend(detect_iam_root_posture(session))
    findings.extend(detect_iam_password_policy(session))
    findings.extend(detect_iam_access_key_hygiene(session))

    findings.extend(detect_cloudtrail_basics(session, regions=regions))
    # Config is regional; checking only first region keeps call volume bounded.
    if regions:
        findings.extend(detect_aws_config_basics(session, region=regions[0]))

    # EBS encryption by default is regional; check first region to limit calls.
    if regions:
        findings.extend(detect_ebs_encryption_by_default(session, region=regions[0]))

    findings.extend(detect_guardduty_enabled(session, regions=regions))
    findings.extend(detect_securityhub_enabled(session, regions=regions))

    findings.extend(detect_cloudwatch_alarms_presence(session, regions=regions))

    findings.extend(detect_access_analyzer_external_access(session, regions=regions))

    findings.extend(detect_security_groups_open_to_world(session, regions=regions))
    findings.extend(detect_rds_security_basics(raw.get("rds", [])))

    bucket_names = [
        b.get("Name") for b in (raw.get("s3", []) or []) if isinstance(b, dict)
    ]
    findings.extend(
        detect_s3_public_access_basics(
            session,
            bucket_names=bucket_names,
            include_bucket_names=include_bucket_names,
        )
    )

    findings.extend(
        detect_s3_default_encryption(
            session,
            bucket_names=bucket_names,
            include_bucket_names=include_bucket_names,
        )
    )

    findings.extend(detect_incident_response_readiness(findings))

    return findings
