from __future__ import annotations

from difflib import get_close_matches
import json
from pathlib import Path
import re
from typing import Any, Literal

import yaml

from aws_guardrails.catalog import load_iam_action_catalog
from aws_guardrails.loaders import DuplicateKeyError, TaggedValue, load_json_document, load_yaml_document
from aws_guardrails.models import Issue, ValidationResult

DocumentKind = Literal["auto", "iam", "cloudformation"]
PolicyUsage = Literal["generic", "identity", "resource", "trust"]

_IAM_TOP_LEVEL_KEYS = {"Version", "Id", "Statement"}
_IAM_STATEMENT_KEYS = {
    "Sid",
    "Principal",
    "NotPrincipal",
    "Effect",
    "Action",
    "NotAction",
    "Resource",
    "NotResource",
    "Condition",
}
_CFN_CORE_SECTIONS = {
    "AWSTemplateFormatVersion",
    "Transform",
    "Description",
    "Metadata",
    "Parameters",
    "Mappings",
    "Conditions",
    "Rules",
    "Resources",
    "Outputs",
    "Globals",
}
_SENSITIVE_PARAMETER_HINTS = (
    "password",
    "secret",
    "token",
    "apikey",
    "api_key",
    "accesskey",
    "secretkey",
    "privatekey",
    "private_key",
    "certificate",
    "credential",
)


def validate_path(path: str | Path, kind: DocumentKind = "auto") -> list[ValidationResult]:
    candidate = Path(path)
    if not candidate.exists():
        return [ValidationResult(path=candidate, document_type=str(kind), issues=(Issue("FS002", "Path does not exist."),))]
    files = list(_iter_candidate_files(candidate))
    if not files:
        return [ValidationResult(path=candidate, document_type=str(kind), issues=(Issue("FS001", "No supported files found."),))]
    return [validate_file(file_path, kind=kind) for file_path in files]


def validate_file(path: str | Path, kind: DocumentKind = "auto") -> ValidationResult:
    file_path = Path(path)
    text = file_path.read_text(encoding="utf-8")
    resolved_kind = kind
    if kind == "auto":
        resolved_kind = _detect_document_kind(file_path, text)

    if resolved_kind == "iam":
        return _validate_iam_file(file_path, text)
    if resolved_kind == "cloudformation":
        return _validate_cloudformation_file(file_path, text)

    return ValidationResult(
        path=file_path,
        document_type="unknown",
        issues=(Issue("DOC001", "Could not infer document type. Use --kind."),),
    )


def _iter_candidate_files(path: Path):
    if path.is_file():
        yield path
        return
    for child in sorted(path.rglob("*")):
        if child.is_file() and child.suffix.lower() in {".json", ".yaml", ".yml"}:
            yield child


def _detect_document_kind(path: Path, text: str) -> DocumentKind:
    suffix = path.suffix.lower()
    if suffix in {".yaml", ".yml"}:
        return "cloudformation"
    if suffix != ".json":
        return "auto"

    try:
        document = load_json_document(text)
    except (json.JSONDecodeError, DuplicateKeyError):
        if '"Statement"' in text:
            return "iam"
        if any(marker in text for marker in ('"Resources"', '"Transform"', '"AWSTemplateFormatVersion"', '"Globals"')):
            return "cloudformation"
        return "auto"

    if isinstance(document, dict):
        keys = set(document)
        if "Statement" in keys and not keys.intersection({"Resources", "Transform", "Parameters", "Globals"}):
            return "iam"
        if keys.intersection({"Resources", "Transform", "Parameters", "Mappings", "Globals", "AWSTemplateFormatVersion"}):
            return "cloudformation"
    return "auto"


def _validate_iam_file(path: Path, text: str) -> ValidationResult:
    issues: list[Issue] = []
    try:
        document = load_json_document(text)
    except json.JSONDecodeError as error:
        issues.append(Issue("JSON001", error.msg, line=error.lineno, column=error.colno))
        return ValidationResult(path=path, document_type="iam", issues=tuple(issues))
    except DuplicateKeyError as error:
        issues.append(Issue("JSON002", str(error), line=error.line, column=error.column))
        return ValidationResult(path=path, document_type="iam", issues=tuple(issues))

    issues.extend(_validate_iam_document(document, usage="generic"))
    return ValidationResult(path=path, document_type="iam", issues=tuple(issues))


def _validate_cloudformation_file(path: Path, text: str) -> ValidationResult:
    issues: list[Issue] = []
    suffix = path.suffix.lower()
    try:
        document = load_yaml_document(text) if suffix in {".yaml", ".yml"} else load_json_document(text)
    except json.JSONDecodeError as error:
        issues.append(Issue("JSON001", error.msg, line=error.lineno, column=error.colno))
        return ValidationResult(path=path, document_type="cloudformation", issues=tuple(issues))
    except DuplicateKeyError as error:
        issues.append(Issue("DOC002", str(error), line=error.line, column=error.column))
        return ValidationResult(path=path, document_type="cloudformation", issues=tuple(issues))
    except yaml.YAMLError as error:
        line = column = None
        mark = getattr(error, "problem_mark", None)
        if mark is not None:
            line = mark.line + 1
            column = mark.column + 1
        issues.append(Issue("YAML001", str(error).splitlines()[0], line=line, column=column))
        return ValidationResult(path=path, document_type="cloudformation", issues=tuple(issues))

    issues.extend(_validate_cloudformation_document(document))
    return ValidationResult(path=path, document_type="cloudformation", issues=tuple(issues))


def _validate_iam_document(document: Any, usage: PolicyUsage = "generic", statement_path_prefix: str = "Statement") -> list[Issue]:
    issues: list[Issue] = []
    if not isinstance(document, dict):
        return [Issue("IAM001", "IAM policy document must be a JSON object.")]

    extra_keys = sorted(set(document) - _IAM_TOP_LEVEL_KEYS)
    for key in extra_keys:
        issues.append(Issue("IAM002", f"Unexpected top-level key {key!r} in IAM policy."))

    if "Version" in document and document["Version"] not in {"2008-10-17", "2012-10-17"}:
        issues.append(Issue("IAM003", "Version must be '2008-10-17' or '2012-10-17'."))

    if "Id" in document and not isinstance(document["Id"], str):
        issues.append(Issue("IAM004", "Id must be a string when present."))

    if "Statement" not in document:
        issues.append(Issue("IAM005", "Statement is required in an IAM policy."))
        return issues

    statements = document["Statement"]
    if isinstance(statements, dict):
        statements = [statements]
    elif not isinstance(statements, list) or not statements:
        issues.append(Issue("IAM006", "Statement must be an object or a non-empty array of objects."))
        return issues

    for index, statement in enumerate(statements):
        issues.extend(_validate_statement(statement, index, usage=usage, statement_path_prefix=statement_path_prefix))

    return issues


def _validate_statement(
    statement: Any,
    index: int,
    usage: PolicyUsage = "generic",
    statement_path_prefix: str = "Statement",
) -> list[Issue]:
    prefix = f"{statement_path_prefix}[{index}]"
    issues: list[Issue] = []

    if not isinstance(statement, dict):
        return [Issue("IAM007", f"{prefix} must be an object.")]

    extra_keys = sorted(set(statement) - _IAM_STATEMENT_KEYS)
    for key in extra_keys:
        issues.append(Issue("IAM008", f"{prefix} contains unsupported key {key!r}."))

    if "Sid" in statement and not isinstance(statement["Sid"], str):
        issues.append(Issue("IAM009", f"{prefix}.Sid must be a string."))

    if statement.get("Effect") not in {"Allow", "Deny"}:
        issues.append(Issue("IAM010", f"{prefix}.Effect must be 'Allow' or 'Deny'."))

    if ("Action" in statement) == ("NotAction" in statement):
        issues.append(Issue("IAM011", f"{prefix} must contain exactly one of Action or NotAction."))
    else:
        action_key = "Action" if "Action" in statement else "NotAction"
        if not _is_string_or_list_of_strings(statement[action_key]):
            issues.append(Issue("IAM012", f"{prefix}.{action_key} must be a string or a list of strings."))
        else:
            issues.extend(_validate_action_values(statement[action_key], f"{prefix}.{action_key}"))

    if "Resource" in statement and "NotResource" in statement:
        issues.append(Issue("IAM013", f"{prefix} cannot contain both Resource and NotResource."))
    elif "Resource" in statement:
        if not _is_string_or_list_of_strings(statement["Resource"]):
            issues.append(Issue("IAM014", f"{prefix}.Resource must be a string or a list of strings."))
    elif "NotResource" in statement:
        if not _is_string_or_list_of_strings(statement["NotResource"]):
            issues.append(Issue("IAM015", f"{prefix}.NotResource must be a string or a list of strings."))

    if "Principal" in statement and "NotPrincipal" in statement:
        issues.append(Issue("IAM016", f"{prefix} cannot contain both Principal and NotPrincipal."))
    elif "Principal" in statement and not _is_valid_principal(statement["Principal"]):
        issues.append(Issue("IAM017", f"{prefix}.Principal must be a string, object, or list of strings."))
    elif "NotPrincipal" in statement and not _is_valid_principal(statement["NotPrincipal"]):
        issues.append(Issue("IAM018", f"{prefix}.NotPrincipal must be a string, object, or list of strings."))

    if "Condition" in statement and not isinstance(statement["Condition"], dict):
        issues.append(Issue("IAM019", f"{prefix}.Condition must be an object."))

    if usage == "identity":
        if "Principal" in statement:
            issues.append(Issue("IAM020", f"{prefix}.Principal is not valid in an identity-based policy."))
        if "NotPrincipal" in statement:
            issues.append(Issue("IAM021", f"{prefix}.NotPrincipal is not valid in an identity-based policy."))

    if usage == "trust":
        if "Resource" in statement or "NotResource" in statement:
            issues.append(Issue("IAM022", f"{prefix} cannot contain Resource or NotResource in a role trust policy."))
        if "NotPrincipal" in statement:
            issues.append(Issue("IAM023", f"{prefix}.NotPrincipal is not supported in a role trust policy."))
        if "Principal" not in statement:
            issues.append(Issue("IAM024", f"{prefix}.Principal is required in a role trust policy."))
        elif _principal_is_public(statement["Principal"]):
            issues.append(Issue("IAM025", f"{prefix}.Principal must not allow public assume-role access.", severity="SECURITY"))

    issues.extend(_validate_statement_security(statement, prefix, usage))
    return issues


def _validate_cloudformation_document(document: Any) -> list[Issue]:
    issues: list[Issue] = []
    if not isinstance(document, dict):
        return [Issue("CFN001", "CloudFormation template must be an object at the top level.")]

    for section in sorted(set(document) - _CFN_CORE_SECTIONS):
        issues.append(Issue("CFN002", f"Unknown top-level section {section!r}.", severity="WARNING"))

    if "AWSTemplateFormatVersion" in document and not isinstance(document["AWSTemplateFormatVersion"], str):
        issues.append(Issue("CFN003", "AWSTemplateFormatVersion must be a string."))
    if "Description" in document and not isinstance(document["Description"], str):
        issues.append(Issue("CFN004", "Description must be a string."))
    if "Transform" in document and not isinstance(document["Transform"], (str, list, dict, TaggedValue)):
        issues.append(Issue("CFN005", "Transform must be a string, list, object, or intrinsic expression."))

    mapping_sections = ("Metadata", "Parameters", "Mappings", "Conditions", "Rules", "Resources", "Outputs", "Globals")
    for section in mapping_sections:
        if section in document and not isinstance(document[section], dict):
            issues.append(Issue("CFN006", f"{section} must be an object."))

    resources = document.get("Resources")
    if isinstance(resources, dict):
        for logical_id, resource in resources.items():
            issues.extend(_validate_resource(logical_id, resource))

    parameters = document.get("Parameters")
    if isinstance(parameters, dict):
        for parameter_name, parameter in parameters.items():
            if not isinstance(parameter, dict):
                issues.append(Issue("CFN007", f"Parameters.{parameter_name} must be an object."))
                continue
            if not isinstance(parameter.get("Type"), str):
                issues.append(Issue("CFN008", f"Parameters.{parameter_name}.Type must be a string."))
            issues.extend(_validate_parameter_security(parameter_name, parameter))

    issues.extend(_validate_embedded_iam_in_cloudformation(document))

    return issues


def _validate_resource(logical_id: str, resource: Any) -> list[Issue]:
    if not isinstance(resource, dict):
        return [Issue("CFN009", f"Resources.{logical_id} must be an object.")]
    if not isinstance(resource.get("Type"), str):
        return [Issue("CFN010", f"Resources.{logical_id}.Type must be a string.")]
    return []


def _is_string_or_list_of_strings(value: Any) -> bool:
    if _is_string_like(value):
        return True
    if isinstance(value, list) and value and all(_is_string_like(item) for item in value):
        return True
    return False


def _is_valid_principal(value: Any) -> bool:
    if _is_string_like(value):
        return True
    if isinstance(value, list):
        return all(_is_string_like(item) for item in value)
    if isinstance(value, dict):
        return all(_is_string_or_list_of_strings(item) for item in value.values())
    return False


def _validate_statement_security(statement: dict[str, Any], prefix: str, usage: PolicyUsage) -> list[Issue]:
    issues: list[Issue] = []
    if statement.get("Effect") != "Allow":
        return issues

    action_values = _iter_string_values(statement.get("Action"))
    resource_values = _iter_string_values(statement.get("Resource"))

    if _is_broad_allow_statement(action_values, resource_values):
        issues.append(
            Issue(
                "IAM100",
                f"{prefix} grants wildcard actions on all resources. Scope it down for least privilege.",
                severity="SECURITY",
            )
        )
    else:
        action_level = _wildcard_level(action_values, kind="action")
        if action_level == "full":
            issues.append(Issue("IAM101", f"{prefix}.Action grants all AWS actions.", severity="SECURITY"))
        elif action_level == "service":
            issues.append(Issue("IAM102", f"{prefix}.Action grants every action in a service namespace.", severity="SECURITY"))
        elif action_level == "pattern":
            issues.append(Issue("IAM103", f"{prefix}.Action uses wildcard patterns. Prefer explicit API actions.", severity="WARNING"))

        resource_level = _wildcard_level(resource_values, kind="resource")
        resource_scope_possible = _resource_scope_possible(action_values)
        if resource_level == "full" and resource_scope_possible:
            issues.append(Issue("IAM104", f"{prefix}.Resource targets all resources. Tighten the resource scope where possible.", severity="WARNING"))
        elif resource_level == "pattern" and resource_scope_possible:
            issues.append(Issue("IAM105", f"{prefix}.Resource uses wildcard patterns. Narrow it if the service supports resource-level permissions.", severity="WARNING"))

    if "NotAction" in statement:
        issues.append(
            Issue(
                "IAM106",
                f"{prefix} uses NotAction with Allow, which can allow more permissions than intended.",
                severity="SECURITY",
            )
        )

    if "NotResource" in statement:
        severity = "SECURITY" if _wildcard_level(action_values, kind="action") in {"full", "service"} else "WARNING"
        issues.append(
            Issue(
                "IAM107",
                f"{prefix} uses NotResource with Allow. Prefer explicit allowlists for least privilege.",
                severity=severity,
            )
        )

    if "NotPrincipal" in statement:
        issues.append(
            Issue(
                "IAM108",
                f"{prefix} uses NotPrincipal with Allow, which is difficult to reason about safely.",
                severity="SECURITY",
            )
        )

    if usage in {"generic", "resource"} and "Principal" in statement and _principal_is_public(statement["Principal"]):
        issues.append(Issue("IAM109", f"{prefix}.Principal allows public or anonymous access.", severity="SECURITY"))

    if _statement_includes_action(statement, "iam:PassRole"):
        if "NotResource" in statement or _wildcard_level(resource_values, kind="resource") in {"full", "pattern"}:
            issues.append(
                Issue(
                    "IAM110",
                    f"{prefix} allows iam:PassRole on wildcard resources. Restrict it to specific role ARNs.",
                    severity="SECURITY",
                )
            )
        if not _condition_has_key(statement.get("Condition"), "iam:PassedToService"):
            issues.append(
                Issue(
                    "IAM111",
                    f"{prefix} allows iam:PassRole without an iam:PassedToService condition.",
                    severity="WARNING",
                )
            )

    if _statement_includes_action(statement, "iam:CreateServiceLinkedRole"):
        if "NotResource" in statement or _wildcard_level(resource_values, kind="resource") in {"full", "pattern"}:
            issues.append(
                Issue(
                    "IAM112",
                    f"{prefix} allows iam:CreateServiceLinkedRole broadly. Restrict it to required service-linked role resources.",
                    severity="WARNING",
                )
            )

    return issues


def _validate_parameter_security(parameter_name: str, parameter: dict[str, Any]) -> list[Issue]:
    issues: list[Issue] = []
    if not _looks_sensitive_name(parameter_name):
        return issues

    if parameter.get("NoEcho") is not True:
        issues.append(
            Issue(
                "CFN101",
                f"Parameters.{parameter_name} looks sensitive and should set NoEcho: true.",
                severity="WARNING",
            )
        )

    default_value = _string_value(parameter.get("Default"))
    if default_value and not default_value.startswith("{{resolve:"):
        issues.append(
            Issue(
                "CFN102",
                f"Parameters.{parameter_name} looks sensitive and has a literal default value. Prefer dynamic references or runtime input.",
                severity="SECURITY",
            )
        )
    return issues


def _validate_embedded_iam_in_cloudformation(document: dict[str, Any]) -> list[Issue]:
    issues: list[Issue] = []
    resources = document.get("Resources")
    if not isinstance(resources, dict):
        return issues

    for logical_id, resource in resources.items():
        if not isinstance(resource, dict):
            continue
        resource_type = resource.get("Type")
        properties = resource.get("Properties")
        if not isinstance(properties, dict) or not isinstance(resource_type, str):
            continue

        resource_prefix = f"Resources.{logical_id}.Properties"
        if resource_type == "AWS::IAM::Role":
            issues.extend(
                _validate_embedded_policy_document(
                    properties.get("AssumeRolePolicyDocument"),
                    usage="trust",
                    statement_path_prefix=f"{resource_prefix}.AssumeRolePolicyDocument.Statement",
                )
            )
            issues.extend(_validate_inline_policy_list(properties.get("Policies"), f"{resource_prefix}.Policies", usage="identity"))
            continue

        if resource_type in {"AWS::IAM::ManagedPolicy", "AWS::IAM::Policy"}:
            issues.extend(
                _validate_embedded_policy_document(
                    properties.get("PolicyDocument"),
                    usage="identity",
                    statement_path_prefix=f"{resource_prefix}.PolicyDocument.Statement",
                )
            )
            continue

        if resource_type in {"AWS::IAM::User", "AWS::IAM::Group"}:
            issues.extend(_validate_inline_policy_list(properties.get("Policies"), f"{resource_prefix}.Policies", usage="identity"))
            continue

        if "PolicyDocument" in properties:
            issues.extend(
                _validate_embedded_policy_document(
                    properties.get("PolicyDocument"),
                    usage="generic",
                    statement_path_prefix=f"{resource_prefix}.PolicyDocument.Statement",
                )
            )

        if "KeyPolicy" in properties:
            issues.extend(
                _validate_embedded_policy_document(
                    properties.get("KeyPolicy"),
                    usage="resource",
                    statement_path_prefix=f"{resource_prefix}.KeyPolicy.Statement",
                )
            )

    return issues


def _validate_inline_policy_list(value: Any, policy_list_prefix: str, usage: PolicyUsage) -> list[Issue]:
    issues: list[Issue] = []
    if value is None:
        return issues
    if not isinstance(value, list):
        return [Issue("CFN103", f"{policy_list_prefix} must be a list when present.")]

    for index, item in enumerate(value):
        if not isinstance(item, dict):
            issues.append(Issue("CFN104", f"{policy_list_prefix}[{index}] must be an object."))
            continue
        issues.extend(
            _validate_embedded_policy_document(
                item.get("PolicyDocument"),
                usage=usage,
                statement_path_prefix=f"{policy_list_prefix}[{index}].PolicyDocument.Statement",
            )
        )
    return issues


def _validate_embedded_policy_document(value: Any, usage: PolicyUsage, statement_path_prefix: str) -> list[Issue]:
    if value is None or isinstance(value, TaggedValue):
        return []
    if not isinstance(value, dict):
        return [Issue("CFN105", f"{statement_path_prefix.rsplit('.Statement', 1)[0]} must be an object.")]
    return _validate_iam_document(value, usage=usage, statement_path_prefix=statement_path_prefix)


def _is_string_like(value: Any) -> bool:
    return isinstance(value, str) or isinstance(value, TaggedValue) and isinstance(value.value, str)


def _string_value(value: Any) -> str | None:
    if isinstance(value, str):
        return value
    if isinstance(value, TaggedValue) and isinstance(value.value, str):
        return value.value
    return None


def _iter_string_values(value: Any) -> list[str]:
    string_value = _string_value(value)
    if string_value is not None:
        return [string_value]
    if isinstance(value, list):
        items: list[str] = []
        for item in value:
            string_item = _string_value(item)
            if string_item is not None:
                items.append(string_item)
        return items
    return []


def _wildcard_level(values: list[str], kind: Literal["action", "resource"]) -> str | None:
    if not values:
        return None
    for value in values:
        if value == "*":
            return "full"
    if kind == "action":
        for value in values:
            if re.fullmatch(r"[^:]+:\*", value):
                return "service"
    for value in values:
        if "*" in value or "?" in value:
            return "pattern"
    return None


def _is_broad_allow_statement(actions: list[str], resources: list[str]) -> bool:
    return _wildcard_level(actions, kind="action") in {"full", "service"} and _wildcard_level(resources, kind="resource") == "full"


def _statement_includes_action(statement: dict[str, Any], target_action: str) -> bool:
    if "Action" not in statement:
        return False
    return any(action.lower() == target_action.lower() for action in _iter_string_values(statement["Action"]))


def _condition_has_key(condition: Any, expected_key: str) -> bool:
    if not isinstance(condition, dict):
        return False
    expected_key = expected_key.lower()
    for value in condition.values():
        if isinstance(value, dict):
            for actual_key in value:
                if isinstance(actual_key, str) and actual_key.lower() == expected_key:
                    return True
            if _condition_has_key(value, expected_key):
                return True
        elif isinstance(value, list):
            for item in value:
                if _condition_has_key(item, expected_key):
                    return True
    return False


def _principal_is_public(value: Any) -> bool:
    if _string_value(value) == "*":
        return True
    if isinstance(value, list):
        return any(_string_value(item) == "*" for item in value)
    if isinstance(value, dict):
        for principal_value in value.values():
            if _string_value(principal_value) == "*":
                return True
            if isinstance(principal_value, list) and any(_string_value(item) == "*" for item in principal_value):
                return True
    return False


def _looks_sensitive_name(name: str) -> bool:
    normalized = re.sub(r"[^a-z0-9]+", "", name.lower())
    return any(hint in normalized for hint in _SENSITIVE_PARAMETER_HINTS)


def _validate_action_values(value: Any, path: str) -> list[Issue]:
    issues: list[Issue] = []
    for action in _iter_string_values(value):
        issue = _validate_action_string(action, path)
        if issue is not None:
            issues.append(issue)
    return issues


def _validate_action_string(action: str, path: str) -> Issue | None:
    if action == "*":
        return None

    if ":" not in action:
        return Issue(
            "IAM030",
            f"{path} contains invalid action {action!r}. Use service-prefix:action-name.",
            suggestion="Use an action like 's3:GetObject' or '*' if you intentionally mean all actions.",
        )

    service_prefix, action_name = action.split(":", 1)
    if not service_prefix or not action_name:
        return Issue(
            "IAM031",
            f"{path} contains malformed action {action!r}. Both the service prefix and action name are required.",
            suggestion="Use the form 'service-prefix:action-name', for example 'iam:CreateRole'.",
        )

    catalog = load_iam_action_catalog()
    normalized_prefix = service_prefix.lower()
    normalized_action = action_name.lower()
    prefix_is_pattern = any(char in service_prefix for char in "*?")
    action_is_pattern = any(char in action_name for char in "*?")

    if not prefix_is_pattern and not catalog.has_service(normalized_prefix):
        return Issue(
            "IAM032",
            f"{path} uses unknown service prefix {service_prefix!r} in action {action!r}.",
            suggestion=_suggest_service_fix(service_prefix, action_name, catalog),
        )

    if prefix_is_pattern or action_is_pattern:
        return None

    if catalog.has_action(normalized_prefix, normalized_action):
        return None

    return Issue(
        "IAM033",
        f"{path} uses unknown action {action!r}.",
        suggestion=_suggest_action_fix(normalized_prefix, action_name, catalog),
    )


def _suggest_service_fix(service_prefix: str, action_name: str, catalog) -> str | None:
    normalized_action = action_name.lower()
    exact_services = catalog.find_services_for_action(normalized_action)
    if len(exact_services) == 1:
        return f"Use '{exact_services[0]}:{action_name}' instead."

    prefix_matches = get_close_matches(service_prefix.lower(), catalog.service_prefixes(), n=3, cutoff=0.75)
    if prefix_matches:
        return f"Use the AWS service namespace '{prefix_matches[0]}' if that is the service you intended."

    if exact_services:
        return f"Use one of these service namespaces for '{action_name}': {', '.join(exact_services[:3])}."
    return None


def _suggest_action_fix(service_prefix: str, action_name: str, catalog) -> str | None:
    normalized_action = action_name.lower()
    exact_services = tuple(service for service in catalog.find_services_for_action(normalized_action) if service != service_prefix)
    if len(exact_services) == 1:
        suggested = f"{exact_services[0]}:{_format_action_for_display(normalized_action, action_name)}"
        return f"Did you mean '{suggested}'?"

    service_actions = catalog.service_actions(service_prefix)
    same_service_matches = get_close_matches(normalized_action, service_actions, n=3, cutoff=0.75)
    if same_service_matches:
        suggested = f"{service_prefix}:{_format_action_for_display(same_service_matches[0], action_name)}"
        return f"Did you mean '{suggested}'?"

    if exact_services:
        candidates = ", ".join(
            f"{service}:{_format_action_for_display(normalized_action, action_name)}" for service in exact_services[:3]
        )
        return f"The action name exists under a different service namespace: {candidates}."
    return None


def _format_action_for_display(candidate: str, template: str) -> str:
    if template.islower():
        return candidate
    if template.isupper():
        return candidate.upper()
    chars = list(candidate)
    uppercase_positions = {index for index, char in enumerate(template) if char.isupper()}
    if not uppercase_positions and template and template[0].isupper():
        uppercase_positions.add(0)
    for index in uppercase_positions:
        if index < len(chars):
            chars[index] = chars[index].upper()
    return "".join(chars)


def _resource_scope_possible(actions: list[str]) -> bool:
    if not actions:
        return True

    catalog = load_iam_action_catalog()
    found_explicit_action = False
    for action in actions:
        if action == "*":
            return True
        if "*" in action or "?" in action:
            return True
        if ":" not in action:
            continue
        prefix, action_name = action.split(":", 1)
        normalized_prefix = prefix.lower()
        normalized_action = action_name.lower()
        if catalog.has_action(normalized_prefix, normalized_action):
            found_explicit_action = True
            if catalog.supports_resource_scope(normalized_prefix, normalized_action):
                return True
    return not found_explicit_action
