import abc
import logging
import threading
from collections.abc import Callable
from typing import Any, TypedDict, Union

from moto.moto_api._internal import mock_random

log = logging.getLogger(__name__)

ExistingIds = Union[list[str], None]
Tags = Union[dict[str, str], list[dict[str, str]], None]

# Custom resource tag to override the generated resource ID.
TAG_KEY_CUSTOM_ID = "_custom_id_"


class IdSourceContext(TypedDict, total=False):
    resource_identifier: "ResourceIdentifier"
    tags: Tags
    existing_ids: ExistingIds


class ResourceIdentifier(abc.ABC):
    """
    Base class for resource identifiers. When implementing a new resource, it is important to set
    the service and resource as they will be used to create the unique identifier for that resource.

    It is recommended to implement the `generate` method using functions decorated with `@moto_id`.
    This will ensure that your resource can be assigned a custom id.
    """

    service: str
    resource: str

    def __init__(self, account_id: str, region: str, name: str):
        self.account_id = account_id
        self.region = region
        self.name = name or ""

    @abc.abstractmethod
    def generate(self, existing_ids: ExistingIds = None, tags: Tags = None) -> str:
        """Method to generate a resource id"""

    @property
    def unique_identifier(self) -> str:
        return ".".join(
            [self.account_id, self.region, self.service, self.resource, self.name]
        )

    def __str__(self) -> str:
        return self.unique_identifier


class MotoIdManager:
    """class to manage custom ids. Do not create instance and instead
    use the `id_manager` instance created below."""

    _custom_ids: dict[str, str]
    _id_sources: list[Callable[[IdSourceContext], Union[str, None]]]

    _lock: threading.RLock

    def __init__(self) -> None:
        self._custom_ids = {}
        self._lock = threading.RLock()
        self._id_sources = []

        self.add_id_source(self.get_id_from_tags)
        self.add_id_source(self.get_custom_id_from_context)

    def get_custom_id(
        self, resource_identifier: ResourceIdentifier
    ) -> Union[str, None]:
        # retrieves a custom_id for a resource. Returns None if no id were registered
        # that matches the `resource_identifier`
        return self._custom_ids.get(resource_identifier.unique_identifier)

    def set_custom_id(
        self, resource_identifier: ResourceIdentifier, custom_id: str
    ) -> None:
        # Do not set a custom_id for a resource no value was found for the name
        if not resource_identifier.name:
            return
        with self._lock:
            self._custom_ids[resource_identifier.unique_identifier] = custom_id

    def unset_custom_id(self, resource_identifier: ResourceIdentifier) -> None:
        # removes a set custom_id for a resource
        with self._lock:
            self._custom_ids.pop(resource_identifier.unique_identifier, None)

    def add_id_source(
        self, id_source: Callable[[IdSourceContext], Union[str, None]]
    ) -> None:
        self._id_sources.append(id_source)

    @staticmethod
    def get_id_from_tags(id_source_context: IdSourceContext) -> Union[str, None]:
        if not (tags := id_source_context.get("tags")):
            return None

        if isinstance(tags, dict):
            return tags.get(TAG_KEY_CUSTOM_ID)

        if isinstance(tags, list):
            return next(
                (
                    tag.get("Value")
                    for tag in tags
                    if tag.get("Key") == TAG_KEY_CUSTOM_ID
                ),
                None,
            )

    def get_custom_id_from_context(
        self, id_source_context: IdSourceContext
    ) -> Union[str, None]:
        # retrieves a custom_id for a resource. Returns None
        if resource_identifier := id_source_context.get("resource_identifier"):
            return self.get_custom_id(resource_identifier)
        return None

    def find_id_from_sources(
        self, id_source_context: IdSourceContext
    ) -> Union[str, None]:
        existing_ids = id_source_context.get("existing_ids") or []
        for id_source in self._id_sources:
            if found_id := id_source(id_source_context):
                if found_id in existing_ids:
                    log.debug(
                        "Found id %s for resource %s, but a resource already exists with this id.",
                        found_id,
                        id_source_context.get("resource_identifier"),
                    )
                else:
                    return found_id

        return None


moto_id_manager = MotoIdManager()


def moto_id(fn: Callable[..., str]) -> Callable[..., str]:
    """
    Decorator for helping in creation of static ids.

    The decorated function should accept the following parameters

    :param resource_identifier
    :param existing_ids
        If provided, we will omit returning a custom id if it is already on the list
    :param tags
        If provided will look for a tag named `_custom_id_`. This will take precedence over registered custom ids
    """

    def _wrapper(
        resource_identifier: ResourceIdentifier,
        existing_ids: ExistingIds = None,
        tags: Tags = None,
        **kwargs: dict[str, Any],
    ) -> str:
        if resource_identifier and (
            found_id := moto_id_manager.find_id_from_sources(
                IdSourceContext(
                    resource_identifier=resource_identifier,
                    existing_ids=existing_ids,
                    tags=tags,
                )
            )
        ):
            return found_id

        return fn(
            resource_identifier=resource_identifier,
            existing_ids=existing_ids,
            tags=tags,
            **kwargs,
        )

    return _wrapper


@moto_id
def generate_str_id(  # type: ignore
    resource_identifier: ResourceIdentifier,
    existing_ids: ExistingIds = None,
    tags: Tags = None,
    length: int = 20,
    include_digits: bool = True,
    lower_case: bool = False,
) -> str:
    return mock_random.get_random_string(length, include_digits, lower_case)
