"""
server.py
ActivityPubServer, calls handlers for activities in inbox and outbox.
"""

from abc import ABC
import asyncio
from collections.abc import AsyncIterator, Awaitable
from http import HTTPStatus
from logging import Logger
from typing import Callable

from pydantic import SecretStr
import temporalio
from temporalio.client import Client
from temporalio.contrib.pydantic import PydanticPayloadConverter
import temporalio.converter

from phederation.cache import BaseCache
from phederation.cache.dictcache import DictCache
from phederation.collections.collections import CollectionManager
from phederation.collections.collections_nodb import CollectionManagerNoDB
from phederation.federation.delivery import DeliveryResult
from phederation.federation.ratelimit import RateLimiter
from phederation.handlers.accept import AcceptHandler
from phederation.handlers.addremove import AddRemoveHandler
from phederation.handlers.announce import AnnounceHandler
from phederation.handlers.base import ActivityHandler
from phederation.handlers.block import BlockHandler
from phederation.handlers.create import CreateHandler
from phederation.handlers.delete import DeleteHandler
from phederation.handlers.follow import FollowHandler
from phederation.handlers.like import LikeHandler
from phederation.handlers.maintenance import MaintenanceHandler
from phederation.handlers.media import MediaHandler
from phederation.handlers.migrate import MigrationHandler
from phederation.handlers.reject import RejectHandler
from phederation.handlers.undo import UndoHandler
from phederation.handlers.update import UpdateHandler
from phederation.models import (
    APActivity,
    APObject,
    APOrderedCollectionPage,
    ActorType,
    ValidCollection,
    dereference,
)
from phederation.models.activities import APCreate, APUpdate
from phederation.models.actors import APAccount, APActor
from phederation.models.objects import dereference_or_raise
from phederation.models.proofs import DataIntegrityProof
from phederation.security.actors import ActorManager
from phederation.security.key_management import KeyManager
from phederation.security.worker_encryption import EncryptionCodec
from phederation.storage.base import StorageBackend, fill_database_url_from_env
from phederation.storage.nosql import NoSQLStorageBackend
from phederation.utils import ObjectId, PUBLIC_URLS
from phederation.utils.base import (
    AccessType,
    ActivityType,
    NodeInfo,
    ObjectType,
    UrlType,
    actor_id_from_username,
    collection_id_from_name,
)
from phederation.utils.exceptions import (
    DeliveryError,
    HandlerError,
    SecurityError,
    ValidationError,
    catch_exceptions,
)
from phederation.utils.logging import configure_logger
from phederation.utils.settings import PhedSettings, StorageProviderType

from .delivery import ActivityDelivery
from .discovery import InstanceDiscovery
from .instance import APInstance
from .resolver import ActivityPubResolver


class ActivityPubServerBase(ABC):
    storage: StorageBackend
    key_manager: KeyManager
    actor_manager: ActorManager
    discovery: InstanceDiscovery
    delivery: ActivityDelivery
    rate_limiter: RateLimiter
    resolver: ActivityPubResolver
    instance: APInstance
    collections: CollectionManager
    worker_client: Client | None
    settings: PhedSettings


class ActivityPubServer(ActivityPubServerBase):
    """
    Base ActivityPub server implementation.

    Features:
     - Activity processing
     - Queue management
     - Storage operations
     - Federation protocols
    """

    def __init__(
        self,
        settings: PhedSettings,
        delivery_workflow_from_integration: Callable[[Client | None, PhedSettings], Awaitable[DeliveryResult]] | None = None,
    ):
        self.settings: PhedSettings = settings
        self.logger: Logger = configure_logger(__name__, prefix=settings.federation.logging_prefix)

        self.netloc: str = settings.domain.netloc
        self.base_url: str = settings.domain.base_url
        self.delivery_workflow_from_integration: Callable[[Client | None, PhedSettings], Awaitable[DeliveryResult]] | None = (
            delivery_workflow_from_integration
        )
        self.fernet_key_id: ObjectId | None = None

    def create_cache(self) -> DictCache | None:
        if self.settings.federation.cache_max > 0 and self.settings.federation.cache_ttl > 0:
            cache = DictCache(
                ttl=self.settings.federation.cache_ttl,
                max_size=self.settings.federation.cache_max,
            )
        else:
            cache = None
        return cache

    async def create_components(self):
        """Create all components of the server, including storage, key management, and caches."""
        # Create components, and then initialize them through the server
        # StorageBackend.register_provider(StorageProviders.Sqlite, provider_class=NeoSqliteBackend)
        StorageBackend.register_provider(StorageProviderType.Mongodb, provider_class=NoSQLStorageBackend)

        self.storage: StorageBackend = await StorageBackend.create(settings=self.settings)

        self.collections: CollectionManager = CollectionManagerNoDB(storage=self.storage, page_size=self.settings.storage.page_size)

        self.cache: BaseCache | None = self.create_cache()

        self.rate_limiter: RateLimiter = RateLimiter(cache=self.cache or DictCache(), settings=self.settings)

        self.discovery: InstanceDiscovery = InstanceDiscovery(
            settings=self.settings,
            cache=self.cache,
            request_timeout=self.settings.federation.delivery_timeout,
        )

        self.instance: APInstance = APInstance(
            settings=self.settings,
            instance_name=self.settings.federation.instance_name,
            collections=self.collections,
            discovery=self.discovery,
            settings_path="settings",
            cache=self.cache,
        )

        self.resolver: ActivityPubResolver = ActivityPubResolver(
            settings=self.settings, cache=self.cache, storage=self.storage, instance=self.instance
        )

        self.key_manager: KeyManager = KeyManager(
            settings=self.settings,
            cache=self.cache,
            resolver=self.resolver,
            storage=self.storage,
        )

        self.actor_manager: ActorManager = ActorManager(
            settings=self.settings, storage=self.storage, key_manager=self.key_manager, collections=self.collections
        )

        # will be created in initialize()
        self.worker_client: Client | None = None

        self.delivery: ActivityDelivery = ActivityDelivery(
            key_manager=self.key_manager,
            resolver=self.resolver,
            discovery=self.discovery,
            instance=self.instance,
            settings=self.settings,
            rate_limiter=self.rate_limiter,
        )

    HANDLERS: dict[ActivityType, type[ActivityHandler]] = {
        ActivityType.CREATE: CreateHandler,
        ActivityType.FOLLOW: FollowHandler,
        ActivityType.LIKE: LikeHandler,
        ActivityType.DELETE: DeleteHandler,
        ActivityType.ANNOUNCE: AnnounceHandler,
        ActivityType.UPDATE: UpdateHandler,
        ActivityType.ADD: AddRemoveHandler,
        ActivityType.REMOVE: AddRemoveHandler,
        ActivityType.ACCEPT: AcceptHandler,
        ActivityType.REJECT: RejectHandler,
        ActivityType.UNDO: UndoHandler,
        ActivityType.BLOCK: BlockHandler,
        ActivityType.MAINTENANCE: MaintenanceHandler,
        ActivityType.MIGRATE: MigrationHandler,
    }

    async def initialize_handlers(self):
        # Initialize handlers
        self.media_handler: MediaHandler = MediaHandler(
            settings=self.settings,
            resolver=self.resolver,
            storage_backend=self.storage,
        )
        self.handlers: dict[
            ActivityType,
            ActivityHandler,
        ] = {
            key: ActivityPubServer.HANDLERS[key](
                actor_manager=self.actor_manager,
                key_manager=self.key_manager,
                storage=self.storage,
                collections=self.collections,
                resolver=self.resolver,
                delivery=self.delivery,
            )
            for key in ActivityPubServer.HANDLERS.keys()
        }

    async def connect_to_temporal(self, key_manager: KeyManager) -> Callable[[], Awaitable[DeliveryResult]] | None:
        """Try to connect to the temporalio backend (three times).
        If sucessful, initialize the temporal client. When enabled in settings.worker.encrypt_in_transit, use the `EncryptionCodec`
        class with an encryption key generated by the key_manager. The key is stored in the database, so that all gunicorn workers have access to it.
        """
        if self.settings.worker.enabled:
            instance_actor_id = self.instance.actor_id
            fernet_key: bytes | None = None
            if self.settings.worker.encrypt_in_transit:
                self.fernet_key_id, fernet_key = await key_manager.generate_fernet_key(actor_id=instance_actor_id)
            client_url = fill_database_url_from_env(url=SecretStr("temporalio://${TEMPORAL_HOST}:${TEMPORAL_PORT}"))
            for k_try in range(3):
                try:
                    if self.settings.worker.encrypt_in_transit:
                        if not fernet_key:
                            raise SecurityError("Encryption key for temporalio messages is None")
                        payload_codec = EncryptionCodec(fernet_key=fernet_key, logging_prefix=self.settings.federation.logging_prefix)
                        data_converter = temporalio.converter.DataConverter(
                            payload_converter_class=PydanticPayloadConverter, payload_codec=payload_codec
                        )
                    else:
                        data_converter = temporalio.converter.DataConverter(payload_converter_class=PydanticPayloadConverter)

                    self.worker_client = await Client.connect(
                        client_url.get_secret_value(),
                        data_converter=data_converter,
                    )
                    self.logger.debug(f"Connected to temporal client.")
                    break
                except Exception as e:
                    self.logger.debug(f"Could not connect to temporal client. Error '{e}', trying {k_try+1}/3 times...")
                    await asyncio.sleep(3)
            if not self.worker_client:
                self.logger.error(f"Could not connect to temporal client, tried 3 times.")

            async def delivery_workflow_with_fixed_worker():
                if self.delivery_workflow_from_integration and self.worker_client:
                    return await self.delivery_workflow_from_integration(self.worker_client, self.settings)
                else:
                    raise DeliveryError("Worker client delivery is not set up")

            return delivery_workflow_with_fixed_worker

    async def initialize(self) -> None:
        """Initialize server components."""
        # First, we create all necessary components of the server (uninitialized).
        await self.create_components()
        # Then, we initialize them. Note that the order of initialization matters here,
        # because e.g. storage must be initialized before key management and the connection to temporal.
        await self.initialize_handlers()

        await self.storage.initialize()
        if self.settings.storage.database.clear_on_initialize:
            await self.storage.clear()

        await self.collections.initialize()
        await self.key_manager.initialize()
        await self.discovery.initialize()
        await self.instance.initialize()
        delivery_workflow = await self.connect_to_temporal(key_manager=self.key_manager)
        await self.delivery.initialize(
            local_delivery_inbox=self.handle_inbox, local_delivery_outbox=self.handle_outbox, delivery_workflow=delivery_workflow
        )

    async def create_instance_actors(self):
        # create user collection if it does not exist
        collection_users = collection_id_from_name(id=self.base_url, name="users")
        users = await self.storage.collection.read(id=collection_users)
        if not users:
            _ = await self.collections.create_collection(collection_id=collection_users)

        # create instance actor
        inbox_url = collection_id_from_name(id=self.instance.actor_id, name=ValidCollection.Inbox.value)
        outbox_url = collection_id_from_name(id=self.instance.actor_id, name=ValidCollection.Outbox.value)
        instance_actor = APActor(
            name=self.settings.federation.instance_name,
            preferred_username=self.settings.federation.instance_name,
            id=self.instance.actor_id,
            type=ObjectType.SERVICE.value,
            discoverable=False,
            inbox=inbox_url,
            outbox=outbox_url,
        )
        _ = await self.actor_manager.create_actor(activity=APCreate(object=instance_actor, actor=self.instance.actor_id))

        # create testuser
        testuser_name = "testuser"
        testuser_id = actor_id_from_username(base_url=self.settings.domain.base_url, username=testuser_name)
        inbox_url = collection_id_from_name(id=testuser_id, name=ValidCollection.Inbox.value)
        outbox_url = collection_id_from_name(id=testuser_id, name=ValidCollection.Outbox.value)
        instance_actor = APActor(
            name=testuser_name,
            preferred_username=testuser_name,
            id=self.instance.actor_id,
            type=ActorType.PERSON.value,
            discoverable=False,
            inbox=inbox_url,
            outbox=outbox_url,
        )
        _ = await self.actor_manager.create_actor(activity=APCreate(object=instance_actor, actor=self.instance.actor_id))

        self.logger.info(f"Created instance actors: {testuser_name}, {self.settings.federation.instance_name}")

    async def _clean_inbox_activity(self, activity: APActivity) -> APActivity:
        """Cleans attributes of the given activity.
        This assumes that the activity is only for the given actors inbox.
        Hence, it will remove all to, cc, bto, bcc properties, and
        set the "target" property to the "to" actor.

        Args:
            activity (APActivity): Activity to clean.

        Returns:
            APActivity: Cleaned activity.
        """
        if activity.to and len(activity.to) > 0:
            activity.target = activity.to[0]
        activity.to = None
        activity.cc = None
        activity.bto = None
        activity.bcc = None
        activity.audience = None
        return activity

    async def resolve_multiple(self, ids_to_resolve: list[ObjectId], actor_for_authorization: ObjectId | None = None):
        """Resolve multiple objects on the local instance and store them in a new collection.

        Args:
            ids_to_resolve (list[ObjectId]): The ids to resolve. Must be on the local instance, otherwise the object is not resolved.
            actor_for_authorization (ObjectId | None, optional): Actor for authorization of the individual resolve calls. Defaults to None.
        """
        # TODO: use a temporal worker to offload the resolving tasks?
        resolved_objects = await asyncio.gather(
            *[
                self.resolver.try_resolve_from_storage(url=object_id, url_type=UrlType.from_local_url(object_id))
                for object_id in ids_to_resolve
                if self.resolver.is_local(object_id)
            ]
        )

        # check access
        available_objects: list[APObject | DataIntegrityProof | NodeInfo] = []
        for obj in resolved_objects:
            if isinstance(obj, APObject):
                obj_resolved = await self.resolver.resolve_access_type(object=obj, actor_requesting_access=actor_for_authorization)
            else:
                obj_resolved = obj
            if obj_resolved and not isinstance(obj_resolved, AsyncIterator):
                available_objects.append(obj_resolved)

        return available_objects

    async def handle_pagination(
        self,
        collection_id: ObjectId,
        actor_id: ObjectId | None = None,
        actor_for_authorization: ObjectId | None = None,
        page: int | None = None,
    ) -> APOrderedCollectionPage:
        """
        Handles the pagination of the given collection of the given user.
        If the page is None, the first page of the collection is returned.
        Only one of actor_id or object_id can be set (the other must be None).
        If actor_id and object_id are None, this method can return "all users" or "all activities", based on the "collection" string.

        Args:
            collection_id (ObjectId): the id of the collection (followers, following, ...) to handle.
            actor_id (ObjectId): id of the actor which the collection belongs to (for objects: the attributed_to actor). Default: None
            actor_for_authorization (ObjectId | None): id of the actor to use for authorization. Default: None (only public items)
            page (int): The page number to load. Each page has a maximum of self.storage.settings.page_size items. Default: None (load first page).

        Returns:
            APOrderedCollection
        """

        access: AccessType = await self.determine_access(actor_id=actor_id, actor_for_authorization=actor_for_authorization)

        collection_obj = await self.collections.get_collection_page(
            collection_id=collection_id, attributed_to=actor_id, page=page or 1, access=access
        )

        return collection_obj

    async def determine_access(self, actor_id: ObjectId | None, actor_for_authorization: ObjectId | None) -> AccessType:
        """Determines the access level of objects, given an actor with the items, and an actor that tries for authorization.

        Args:
            actor_id (ObjectId | None): The actor somebody is trying to access items from.
            actor_for_authorization (ObjectId | None): The actor that is trying to access.

        Returns:
            AccessType: The access type (PUBLIC, PRIVATE, FOLLOWER).
        """
        access = AccessType.PUBLIC
        if actor_id and actor_id == actor_for_authorization:
            access = AccessType.PRIVATE
        if actor_for_authorization and actor_id and actor_id != actor_for_authorization:
            self.logger.warning(f"SERVER: trying follower collection, as {actor_id} != {actor_for_authorization}")
            actor = await self.resolver.resolve_actor(actor_id=actor_id)
            follower_collection_id = dereference(actor, ValidCollection.Followers.value)
            if follower_collection_id:
                followers = await self.resolver.resolve_items(collection_id=follower_collection_id)
                if actor_for_authorization in followers:
                    access = AccessType.FOLLOWER
        return access

    async def update_account(self, activity: APUpdate):
        """Update the account given in the .object field of the activity.

        Args:
            activity (APUpdate): An update activity with a user account to update.

        Returns:
            ObjectId: Id of the account that was updated.
        """
        # TODO: implement this
        activity = activity
        account = activity.object
        if not isinstance(account, APAccount) or account.id is None:
            raise ValidationError("User account does not have a proper id.")
        return account.id

    @catch_exceptions(HandlerError, "Failed handling activity in inbox")
    async def handle_inbox(self, activity: APActivity, inbox_owner_id: ObjectId | None = None) -> DeliveryResult:
        """
        Handle incoming activity.

        Args:
            activity: ActivityPub activity
            inbox_owner_id: ObjectId | None, optional id of the owner of the inbox.

        Returns:
            DeliveryResult: result of delivery to the inbox.

        Raises:
            HandlerError: If activity handling fails
        """
        self.logger.info(f"New activity {activity.type} to handle in server inbox of actor {inbox_owner_id}")
        if not inbox_owner_id:
            self.logger.debug(f"This is handling the SHARED inbox of the instance. Calling 'handle' for all actors in the receipient fields...")
            results: list[DeliveryResult] = []
            recipients: list[ObjectId] = await activity.get_recipients()

            # The server MUST remove the bto and/or bcc properties, if they exist, from the ActivityStreams object before delivery, but MUST utilize the addressing originally stored on the bto / bcc properties for determining recipients in delivery.
            # https://www.w3.org/TR/activitypub/#client-to-server-interactions
            activity = await ActivityDelivery.remove_hidden_recipients(activity)

            activity = await self._clean_inbox_activity(activity=activity)
            for receipient in recipients:
                activity.target = receipient
                if activity.target in PUBLIC_URLS:
                    receipient = self.instance.actor
                results.append(await self.handle_inbox(activity=activity, inbox_owner_id=receipient))
            return DeliveryResult.create_from_list(results)

        result = DeliveryResult()

        # attempts to deliver to an inbox on a non-federated server SHOULD result in a 405 Method Not Allowed response.
        # TODO: implement this behavior, including federated/non-federated servers.

        # verify properties
        if not activity.id:
            raise HandlerError(
                f"ActivityPubServer: Activity {activity.type} does not have an id.",
            )
        activity_id = activity.id

        sender_id = dereference(activity, "actor")
        if not sender_id:
            raise HandlerError(
                "There was not 'actor' in the activity in the inbox. Failing.",
            )

        # check if actor is banned on this instance, discard the message if they are
        if await self.instance.is_banned_actor(actor_id=sender_id):
            self.logger.debug(f"Received activity from banned actor '{inbox_owner_id}'. Discarding it.")
            result.status_code = HTTPStatus.UNAUTHORIZED
            result.error_message = "Unauthorized"
            result.failed = [activity_id]
            return result

        # Get activity type
        activity_type = ActivityType(activity.type)
        if not activity_type:
            raise HandlerError("ActivityPubServer: Activity has no type")

        # Check if the actor_id is actually an actor on this instance. If not, fail silently.
        actor = await self.storage.actor.read(id=inbox_owner_id)
        if not actor or not actor.local:
            self.logger.debug(f"ActivityPubServer: actor {inbox_owner_id} is not an actor on this instance. Failing silently.")
            result.failed.append(inbox_owner_id)
            result.error_message = f"Actor {inbox_owner_id} is not an actor on this instance."
            result.status_code = HTTPStatus.NOT_FOUND
            return result

        # store the activity locally so that we record what is sent to this instance.
        # It may already exist from other actors on this instance that also received it.
        # In that case, just ignore the "create".
        activity_id = await self.storage.activity.create(data=activity, raise_if_exists=False)

        # check if the receiving actor is blocking the sending actor. If yes, do not proceed.
        collection_blocks = collection_id_from_name(id=inbox_owner_id, name="blocks")
        if await self.collections.contains(collection_id=collection_blocks, item=sender_id):
            self.logger.debug(f"actor_id={inbox_owner_id} is blocking object_id={sender_id}, not proceeding with activity {activity.id}")

            result.failed = [activity_id]
            result.error_message = "Blocked"
            result.status_code = HTTPStatus.FORBIDDEN
            return result

        # This Activity is added by the receiver as an item in the inbox OrderedCollection.
        # https://www.w3.org/TR/activitypub/#server-to-server-interactions
        # Add to inbox collection of inbox_owner_id
        collection_inbox = collection_id_from_name(id=inbox_owner_id, name="inbox")
        access = AccessType.from_visibility(activity.visibility)
        _ = await self.collections.add_to_collection(collection_id=collection_inbox, items=activity.id, access=access)

        # Get appropriate handler
        handler = self.handlers.get(activity_type)
        if not handler:
            raise HandlerError(
                f"No handler for activity type {activity_type}",
            )

        # clean activity
        activity = await self._clean_inbox_activity(activity)

        # validate activity
        activity = await handler.validate(activity=activity)

        # log the activity as incoming
        await self.instance.log_incoming(activity=activity)

        # handle activity
        _ = await handler.process_inbox(activity=activity)

        self.logger.info(f"Handled inbox activity {activity_type}: {activity_id}")
        result.success = [activity_id]
        result.status_code = HTTPStatus.OK
        return result

    async def _proof_content_hash(self, object: APObject | None):
        if object is None:
            return None
        if self.key_manager.settings.media.proof_object_hash:
            if not object.hash:
                raise SecurityError(f"Could not find a 'hash' field attached to the {object.type} with id '{object.id}'")
            actor = await self.resolver.resolve_actor(dereference(object, "attributed_to"))
            actor_key = await self.key_manager.resolve_current_key(actor.public_key_as_ids())
            if not actor_key:
                raise SecurityError(f"Could not find a current public_key for the actor {actor.id} of object {object.type} with id '{object.id}'")
            object.proof = await self.key_manager.proof_string(string_to_sign=object.hash, private_key_id=actor_key.id)
            return object
        else:
            return object

    async def _wrap_in_create(self, object: APObject) -> APActivity:
        # Attributed to creating user
        object.attributed_to = dereference(data=object, key="actor")

        if not object.attributed_to:
            raise HandlerError(f"Object {object.id} does not have property attributed_to; cannot wrap in Create activity.")

        # Wrap the object in a Create activity. This copies all receipients from the object to the activity.
        activity_create = CreateHandler.wrap(object=object, actor_id=object.attributed_to)
        return activity_create

    @catch_exceptions(HandlerError, "Failed handling activity in outbox")
    async def handle_outbox(self, activity: APActivity | APObject) -> DeliveryResult:
        """
        Handle outgoing activity.

        Args:
            activity: ActivityPub activity

        Returns:
            Activity ID

        Raises:
            DeliveryError: If delivery fails
        """
        self.logger.info(f"New {activity.visibility.lower()} activity to handle in outbox: {activity.type}")

        actor_id = dereference(data=activity, key="actor")
        if not actor_id:
            raise HandlerError(f"Id of actor in outbox for activity {activity.type} is not set")

        # check if actor is banned on this instance, discard the message if they are
        if await self.instance.is_banned_actor(actor_id=actor_id):
            self.logger.debug(f"Received activity from banned actor '{actor_id}'. Discarding it.")
            result = DeliveryResult(failed=[actor_id], error_message="Unauthorized", status_code=HTTPStatus.UNAUTHORIZED)
            return result

        # rate limit actor outbox to avoid spammers
        if not self.rate_limiter.check_rate_limit(domain=actor_id, strip_domain=False):
            self.logger.debug(f"Actor '{actor_id}' is rate limited. Discarding activity {activity.type}.")
            result = DeliveryResult(failed=[actor_id], error_message="Rate limited", status_code=HTTPStatus.TOO_MANY_REQUESTS)
            return result

        # If not an activity: a single non-Activity object which will be wrapped in a Create activity by the server.
        # https://www.w3.org/TR/activitypub/#client-to-server-interactions
        if not activity.type or ObjectType.__contains__(activity.type):
            activity = await self._wrap_in_create(object=activity)

        if not isinstance(activity, APActivity):
            raise DeliveryError("Activity is not a proper APActivity")

        # All activities must have an actor doing them.
        if not activity.actor:
            raise DeliveryError(
                "ActivityPubServer: Activity did not have an 'actor' attribute set.",
            )

        if not ActivityType.__contains__(activity.type):
            raise DeliveryError(
                f"ActivityPubServer: Cannot handle activity with type {activity.type}",
            )

        if ActivityType(activity.type) == ActivityType.ANNOUNCE:
            to: list[ObjectId] = activity.to or []
            actor_obj = await self.resolver.resolve_actor(dereference(activity, key="actor"))
            if actor_obj.id != self.instance.actor_id:
                # only add the followers of actors that are not the instance actor
                if not actor_obj.followers:
                    self.logger.error(f"Actor {dereference(activity, key="actor")} in Announce activity does not have followers; dict={actor_obj}")
                    raise DeliveryError(
                        f"ActivityPubServer: Actor in Announce activity does not have followers",
                    )
                to.append(actor_obj.followers)
                activity.to = to

        recipient_ids = await activity.get_recipients()

        # The server MUST remove the bto and/or bcc properties, if they exist, from the ActivityStreams object before delivery, but MUST utilize the addressing originally stored on the bto / bcc properties for determining recipients in delivery.
        # https://www.w3.org/TR/activitypub/#client-to-server-interactions
        activity = await ActivityDelivery.remove_hidden_recipients(activity)

        # Servers MUST also exclude actors from the list which are the same as the actor of the Activity being notified about. That is, actors shouldn't have their own activities delivered to themselves.
        # https://www.w3.org/TR/activitypub/#delivery
        # actor_id = dereference(data=activity, key="actor")
        if actor_id in recipient_ids:
            recipient_ids.remove(actor_id)

        # Servers performing delivery to the inbox or shared_inbox properties of actors on other servers MUST provide the object property in the activity: Create, Update, Delete, Follow, Add, Remove, Like, Block, Undo. Additionally, servers performing server to server delivery of the following activities MUST also provide the target property: Add, Remove.
        # https://www.w3.org/TR/activitypub/#server-to-server-interactions
        # TODO: verify that the above is satisfied before delivery

        activity_type = ActivityType(activity.type)

        activity = await self.process_outbox_activity(activity)

        # log the activity as outgoing
        await self.instance.log_outgoing(activity=activity)

        # Deliver the activity to recipients (server2server), if any.
        # Note that this includes the bcc and bto ones, even though the activity itself does not have them set anymore.
        result = await self.delivery.deliver_activity(activity=activity, recipients=recipient_ids)

        self.logger.info(
            f"ActivityPubServer: Processed outbox activity {activity_type} with id {activity.id}, delivered to approx. {len(result.success)} recipients, including self. {len(result.failed)} deliveries failed."
        )
        return result

    async def process_outbox_activity(self, activity: APActivity, is_maintenance: bool = False):

        # If an Activity is submitted with a value in the id property, servers MUST ignore this and generate a new id for the Activity.
        # https://www.w3.org/TR/activitypub/#client-to-server-interactions
        activity.id = None

        activity_type = ActivityType(activity.type)
        actor_id = dereference_or_raise(data=activity, key="actor")

        if activity_type == ActivityType.MAINTENANCE and not is_maintenance:
            raise HandlerError(
                f"Cannot handle Maintenance activity: not in maintenance mode",
            )

        # validate the activity, dropping/changing attributes depending on the handler
        handler = self.handlers.get(activity_type, None)
        if not handler:
            raise HandlerError(
                f"ActivityPubServer: No handler for activity type: {activity_type}",
            )
        activity = await handler.validate(activity=activity)

        # Proof content if required by settings
        if activity_type == ActivityType.CREATE and self.settings.media.proof_object_hash:
            activity.object = await self._proof_content_hash(activity.get_object_or_None())

        # For non-transient objects, the server MUST attach an id to both the wrapping Create and its wrapped Object.
        # https://www.w3.org/TR/activitypub/#client-to-server-interactions
        activity.id = await self.storage.activity.create(data=activity)

        # Determine access level from visibility
        access: AccessType = AccessType.PUBLIC if activity.visibility.lower() == "public" else AccessType.PRIVATE

        # Store in the outbox collection
        collection_outbox = collection_id_from_name(id=actor_id, name="outbox")
        _ = await self.collections.add_to_collection(collection_id=collection_outbox, items=activity.id, access=access)

        # Handle the activity in the outbox of the local actor
        activity = await handler.process_outbox(activity=activity)

        return activity
