import asyncio
from collections.abc import Awaitable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from http import HTTPStatus
import json
from json import JSONDecodeError
from logging import Logger
from typing import Any, BinaryIO, Callable, cast, override
from urllib.parse import urlparse

from fastapi.datastructures import UploadFile
import httpx
from pydantic import SecretStr
from urllib3.util import Url

from phederation.federation.discovery import InstanceDiscovery
from phederation.federation.instance import APInstance
from phederation.federation.ratelimit import RateLimiter
from phederation.federation.resolver import ActivityPubResolver
from phederation.models import APObject, CRUD, ValidCollection, dereference
from phederation.models.activities import APActivity, ActivityInQueue
from phederation.models.proofs import to_sha256
from phederation.security.http_signatures import HTTPSignatureVerifier
from phederation.security.key_management import KeyManager
from phederation.storage.base import StorageBackend
from phederation.utils import UrlType, validate_object_id
from phederation.utils.base import AccessType, ActivityType, ObjectId
from phederation.utils.exceptions import (
    ActivityPubException,
    AuthorizationError,
    DecodingError,
    DeliveryError,
    ResolverError,
    StorageError,
)
from phederation.utils.logging import configure_logger
from phederation.utils.serialization import to_json_string
from phederation.utils.settings import DeliveryQueueType, PhedSettings
from phederation.utils.version import PHEDERATION_HEADERS


@dataclass
class DeliveryResult:
    """Result of delivering a message to one or more recipients.
    Contains information for each recipient in success/failed lists."""

    success: list[str | dict[str, Any]] = field(default_factory=lambda: [])
    """Lists the responses for successful deliveries."""

    failed: list[str | dict[str, Any]] = field(default_factory=lambda: [])
    """Lists the responses for failed deliveries."""

    status_code: None | int = None
    """Combined status code. Is only set to a positive message if all recipients could be reached, otherwise to an error code."""

    error_message: None | str = None
    """Combined error message for all failed deliveries."""

    @staticmethod
    def OK():
        return DeliveryResult(success=["OK"], status_code=HTTPStatus.OK)

    def __post_init__(self):
        self.success = self.success or []
        self.failed = self.failed or []

    @override
    def __str__(self):
        return to_json_string({"success": self.success, "failed": self.failed, "status_code": self.status_code, "error_message": self.error_message})

    @override
    def __repr__(self):
        return str(self)

    def get_success_id(self) -> ObjectId | None:
        """Returns the ObjectId of a successful delivery.
        Used mostly when delivering a Create activity and requesting the id of the activity as it was created on the instance.

        Returns:
            ObjectId | None: Id if successful, None otherwise.
        """
        if self.success:
            first_success = self.success[0]
            if isinstance(first_success, dict) and "Location" in first_success:
                id: str | None = first_success.get("Location", None)
                if id:
                    return id
            if isinstance(first_success, str):
                try:
                    return first_success
                except Exception:
                    return None
        return None

    def get_success_id_or_raise(self) -> ObjectId:
        id = self.get_success_id()
        if not id:
            raise DeliveryError("No id found in DeliveryResult")
        return id

    @staticmethod
    def create_from_list(delivery_results: list["DeliveryResult"]) -> "DeliveryResult":
        """Combine a list of DeliveryResult objects into a single one.
        This will combine the error_message strings by joining them, and discard any status_message.

        Args:
            delivery_results (list[DeliveryResult]): list of results.

        Returns:
            DeliveryResult: The combined result object.
        """
        result = DeliveryResult()
        if len(delivery_results) == 0:
            result.error_message = "Nothing delivered."
            result.status_code = HTTPStatus.BAD_REQUEST
            return result
        result.success = []
        result.failed = []
        result.status_code = None
        for d in delivery_results:
            result.success += d.success
            result.failed += d.failed
        result.error_message = "; ".join([d.error_message for d in delivery_results if d.error_message])
        # status codes: if list only contains one item, set to that one. If at least one error, set to BAD_REQUEST
        if len(delivery_results) == 1:
            result.status_code = delivery_results[0].status_code
        elif result.error_message and len(result.error_message) > 0:
            result.status_code = HTTPStatus.BAD_REQUEST
        return result


class APDeliveryQueue:
    """The message queue of all attempted deliveries that have not been sent yet.

    Stores all activities in storage and then pulls them out sequentially, ordered by their to_sent date.
    """

    def __init__(self, storage: StorageBackend, maxsize: int = 0) -> None:
        self.maxsize: int = maxsize
        self.storage: StorageBackend = storage
        self.message_queue: CRUD[ActivityInQueue]

    async def initialize(self):
        self.message_queue = self.storage.table(model_class=ActivityInQueue, name=UrlType.MessageQueue.value)

    async def size(self):
        return await self.message_queue.count()

    async def empty(self):
        return await self.size() == 0

    async def full(self):
        return self.maxsize > 0 and await self.size() >= self.maxsize

    async def put(self, activity: APActivity, recipients: list[ObjectId], send_at: datetime | None = None):
        if not send_at:
            send_at = datetime.now(timezone.utc)
        activity_to_send = ActivityInQueue(id=activity.id, recipients=recipients, activity=activity, send_at=send_at)
        return await self.message_queue.create(activity_to_send, raise_if_exists=False)

    @staticmethod
    def _sort_by_date(activity_to_send: ActivityInQueue):
        return activity_to_send.send_at

    async def pop(self):
        queue = await self.message_queue.select()
        if len(queue) > 0:
            queue.sort(key=APDeliveryQueue._sort_by_date)
            first_activity = queue[0]
            if not first_activity.id:
                raise StorageError("Activity in queue does not have an id assigned")
            _ = await self.message_queue.delete(id=first_activity.id)
            return first_activity
        else:
            return None

    async def clear(self):
        items = await self.message_queue.select()
        _ = await self.message_queue.delete([item.id for item in items if item.id is not None])


class ActivityDelivery:
    """Activity delivery across the federated network.

    Features:
     - Deliver activities immediately or with temporalio workflow.
     - Cleanup activities before sending them.
     - Detect local delivery and switch to faster delivery, bypassing the network.
    """

    def __init__(
        self,
        discovery: InstanceDiscovery,
        resolver: ActivityPubResolver,
        settings: PhedSettings,
        rate_limiter: RateLimiter,
        instance: APInstance | None = None,
        key_manager: KeyManager | None = None,
    ):
        """Create a new ActivityDelivery.

        Args:
            discovery: To obtain URLs for instances and actors.
            resolver: To resolve actor ids.
            settings: To configure timeouts, retries, delays etc.
            delivery_workflow: For concurrent delivery in temporalio.
            rate_limiter: Rate limiting the delivery to not overload remote instances.
            instance: To access information on blocked and federated instances.
            key_manager: For authentication.
        """
        self.key_manager: KeyManager | None = key_manager
        self.discovery: InstanceDiscovery = discovery
        self.resolver: ActivityPubResolver = resolver
        self.instance: APInstance | None = instance
        self.settings: PhedSettings = settings
        self.local_delivery_inbox: Callable[[APActivity, ObjectId | None], Awaitable[DeliveryResult]] | None = None
        self.local_delivery_outbox: Callable[[APActivity | APObject], Awaitable[DeliveryResult]] | None = None
        self.delivery_workflow: Callable[[], Awaitable[DeliveryResult]] | None = None
        self.delivery_queue: APDeliveryQueue | None = APDeliveryQueue(storage=self.key_manager.storage) if self.key_manager else None
        self.signature_verifier: HTTPSignatureVerifier | None = HTTPSignatureVerifier(key_manager=key_manager) if key_manager else None
        self.rate_limiter: RateLimiter = rate_limiter
        # this can be set by the test suite to replace sending actual http requests and instead use the test_client
        self.requests: httpx.AsyncClient = httpx.AsyncClient()
        self.logger: Logger = configure_logger(__name__, prefix=self.settings.federation.logging_prefix)

    async def initialize(
        self,
        local_delivery_inbox: Callable[[APActivity, ObjectId | None], Awaitable[DeliveryResult]],
        local_delivery_outbox: Callable[[APActivity | APObject], Awaitable[DeliveryResult]],
        delivery_workflow: Callable[[], Awaitable[DeliveryResult]] | None = None,
    ) -> None:
        """Initializes the delivery with local routing for inbox and outbox (from `ActivityPubServer`).
        Also enables to pass a temporalio delivery workflow method to send activities to a list of recipients.
        """
        self.local_delivery_inbox = local_delivery_inbox
        self.local_delivery_outbox = local_delivery_outbox
        self.delivery_workflow = delivery_workflow
        if self.delivery_queue:
            await self.delivery_queue.initialize()

    async def check_is_local(self, actor_id: str) -> bool:
        parsed_url = urlparse(actor_id)
        actor_domain = validate_object_id(Url(scheme=parsed_url.scheme, host=parsed_url.netloc).url)
        return actor_domain.lower() == self.settings.domain.base_url.lower()

    @staticmethod
    async def remove_hidden_recipients(activity: APActivity) -> APActivity:
        """Make sure that both bto and bcc fields of activity and object are removed.

        Args:
            activity (APActivity): An activity, potentially with an object, both of which can have bto and bcc fields set.

        Returns:
            APActivity: The activity with all bto and bcc fields removed, also from the object.
        """
        if activity.object:
            obj = activity.object
            if isinstance(obj, dict):
                obj = APObject.deserialize(obj)
            if isinstance(obj, APObject):
                obj.bto = None
                obj.bcc = None
                activity.object = obj
        activity.bto = None
        activity.bcc = None
        return activity

    async def deliver_activity(self, activity: APActivity, recipients: list[ObjectId]) -> DeliveryResult:
        """Tries to deliver the given activity server2server, to all listed receipients inboxes.

        Args:
            activity (APActivity): activity, ready to be submitted.
            recipients (list[ObjectId]): List of receipients actor ids (list[URI string]).

        Returns:
            DeliveryResult: Contains all success and failed lists from all individual deliveries, concatenated.
        """
        activity = await ActivityDelivery.remove_hidden_recipients(activity)
        if not activity.id:
            raise DeliveryError("Activity to deliver did not get an id in outbox")

        result = DeliveryResult(success=[], failed=[activity.id], status_code=HTTPStatus.NOT_IMPLEMENTED)

        delivery_queuing = DeliveryQueueType(self.settings.federation.delivery_queuing)
        if delivery_queuing == DeliveryQueueType.Automatic:
            if self.delivery_workflow and self.delivery_queue:
                delivery_queuing = DeliveryQueueType.Worker
            elif self.delivery_queue:
                delivery_queuing = DeliveryQueueType.Queue
            else:
                delivery_queuing = DeliveryQueueType.Immediate

        if delivery_queuing == DeliveryQueueType.Immediate:
            # The inbox is determined by first retrieving the target actor's JSON-LD representation and then looking up the inbox property. If a recipient is a Collection or OrderedCollection, then the server MUST dereference the collection (with the user's credentials) and discover inboxes for each item in the collection. Servers MUST limit the number of layers of indirections through collections which will be performed, which MAY be one.
            # https://www.w3.org/TR/activitypub/#server-to-server-interactions
            recipient_inboxes = await self.resolver.resolve_inboxes(recipients)

            # Servers MUST de-duplicate the final recipient list.
            # https://www.w3.org/TR/activitypub/#delivery
            recipient_inboxes = list(set(recipient_inboxes))

            # also send the activity to all receipients synchronously
            if len(recipient_inboxes) > 0:
                process_queue: list[Awaitable[DeliveryResult]] = []
                for recipient_inbox in recipient_inboxes:
                    process_queue.append(self.deliver_to_url(activity=activity, url=recipient_inbox))

                result = DeliveryResult.create_from_list(await asyncio.gather(*process_queue))
            else:
                result = DeliveryResult(success=[activity.id], status_code=HTTPStatus.CREATED)
        elif delivery_queuing == DeliveryQueueType.Queue or delivery_queuing == DeliveryQueueType.Worker:
            if self.delivery_queue:
                activity_id = await self.delivery_queue.put(activity=activity, recipients=recipients)
                result = DeliveryResult(success=[activity_id], status_code=HTTPStatus.CONTINUE)
            else:
                raise DeliveryError("Delivery queue is not setup")

        if delivery_queuing == DeliveryQueueType.Worker:
            if self.delivery_workflow:
                _ = await self.delivery_workflow()
                result = DeliveryResult(success=[activity.id], status_code=HTTPStatus.CONTINUE)
            else:
                raise DeliveryError("Delivery workflow is not setup")

        return result

    async def deliver_to_local_actor(self, activity: APActivity, actor_id: str, collection: ValidCollection) -> DeliveryResult:
        """Deliver activity to local actor.
        For example, the given url points to inbox or outbox of an actor that exists on the local instance.
        In this case, the delivery can be optimized a lot compared to sending it over the network.

        Args:
            activity (APActivity): to deliver.
            actor_id (str): id of the actor this activity should be delivered to.
            collection (ValidCollection): check to which collection of the actor to send to (only inbox and outbox are supported)

        Returns:
            DeliveryResult: Result of delivery, as if it was sent over the network.
        """
        if not await self.check_is_local(actor_id):
            raise DeliveryError(f"Local delivery to actor '{actor_id}' failed, should only be to the local domain '{self.settings.domain.netloc}'")

        if not self.local_delivery_inbox or not self.local_delivery_outbox:
            raise DeliveryError("Local delivery failed because it is not set up")

        result = DeliveryResult()
        try:
            if collection == ValidCollection.Inbox:
                result = await self.local_delivery_inbox(activity, actor_id)
            elif collection == ValidCollection.Outbox:
                result = await self.local_delivery_outbox(activity)
            else:
                raise DeliveryError(f"Local delivery only works for inbox/outbox right now. Wrong collection choice: '{collection.value}'")

        except ActivityPubException as e:
            self.logger.debug(e.message)
            result.failed.append(actor_id)
            result.error_message = e.user_message
        except Exception as e:
            self.logger.debug(f"Exception while delivering to local actor: {e}")
            result.failed.append(actor_id)
            result.error_message = str(e)

        return result

    async def deliver_to_url(
        self, activity: APActivity, url: ObjectId, retry_count: int = 0, data: UploadFile | None = None, token: SecretStr | None = None
    ) -> DeliveryResult:
        """Deliver activity to a given url (e.g. inbox or outbox of actor).

        Args:
            activity (APActivity): to deliver.
            url (str): url where the activity is to be delivered to.
            retry_count (int, optional): how often the delivery has been tried already. Defaults to 0.
            data (UploadFile, optional): data of a file to upload. Only used in c2s communication (i.e. by client apps).
            token (str, optional): if this is set, the delivery will not use header signing and instead pass the Authorization: Bearer token.

        Returns:
            DeliveryResult: Result of delivery after retries.
        """
        if self.instance and await self.instance.is_blocked_instance(url):
            raise AuthorizationError(f"Instance for url {url} is blocked, cannot deliver")

        parsed_url = urlparse(str(url))
        domain = parsed_url.netloc
        result = DeliveryResult()

        try:
            # Check rate limit
            if not self.rate_limiter.check_rate_limit(domain):
                wait_time = self.rate_limiter.get_wait_time(domain)
                self.logger.info(f"Waiting {int(wait_time)} seconds for rate limit of domain {domain} to stop")
                await asyncio.sleep(wait_time)

            # Prepare headers with host
            headers = PHEDERATION_HEADERS.copy()
            headers["Host"] = domain

            # Get key id of the actor sending this
            actor_id = dereference(activity, key="actor")
            if actor_id is None:
                raise DeliveryError(f"ActivityDelivery: No actor in activity found: {activity}")

            # Add a content hash of the content of the activity.object
            activity = await self.add_hash_to_activity_object(activity)

            # Use standardized JSON serialization
            activity_dict = activity.serialize()
            body_json = to_json_string(activity_dict)

            if data:
                files: dict[str, tuple[None | str, str | BinaryIO, str]] = {
                    "activity_form": (None, body_json, "application/json"),
                    "file": (data.filename, data.file, data.content_type or "text/plain"),
                }
                _ = headers.pop("Content-Type")  # is going to be set by the AsyncClient to multipart
            else:
                files = {}

            if token:
                headers["Authorization"] = f"Bearer {token.get_secret_value()}"
                headers["WWW-Authenticate"] = "Bearer"

            if not token and self.key_manager and self.signature_verifier:
                try:
                    key_id = await self.key_manager.get_key_id_from_actor(actor_id=actor_id)
                except ResolverError as r:
                    self.logger.debug(f"ActivityDelivery: KeyManager throws ResolverError: {r}. Failing silently")
                    result.error_message = "Could not resolve actor."
                    result.status_code = HTTPStatus.CONTINUE
                    result.failed.append(url)  # TODO: should we add to success instead?
                    return result

                # Sign request using HTTPSignatureVerifier
                signed_headers = await self.signature_verifier.sign_request(
                    method="POST",
                    path=parsed_url.path,
                    headers=headers,
                    body_serialized=body_json,
                    key_id=key_id,  # Pass original dict for digest calculation
                )
            else:
                # do not sign headers, use token authentication if available
                signed_headers = headers

            # Make request with pre-serialized JSON or Files
            async def post_files(url: str, client: httpx.AsyncClient):
                return await client.post(url, files=files, headers=signed_headers, timeout=self.settings.federation.delivery_timeout)

            async def post_json(url: str, client: httpx.AsyncClient):
                return await client.post(url, content=body_json, headers=signed_headers, timeout=self.settings.federation.delivery_timeout)

            post_action = post_files if data else post_json

            try:
                # if await self.check_is_local(url):
                #     url_parsed = urlparse(url)
                #     url_local = Url(
                #         scheme="http",
                #         host=self.settings.domain.docker_url,
                #         port=self.settings.domain.docker_port,
                #         fragment=url_parsed.fragment if len(url_parsed.fragment) > 0 else None,
                #         query=url_parsed.query if len(url_parsed.query) > 0 else None,
                #         path=url_parsed.path if len(url_parsed.path) > 0 else None,
                #     ).url
                #     url = url_local
                response = await post_action(url=url, client=self.requests)
            except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as e:
                self.logger.debug(f"Error when connecting to url {url}: {type(e).__name__} {e}")
                result.error_message = "Error when connecting to url."
                result.status_code = HTTPStatus.CONTINUE
                result.failed.append(url)
                return result

            response_headers: dict[str, str] = {h.lower(): response.headers[h] for h in response.headers.keys()}

            # Update rate limit state
            if state := self.rate_limiter.update_rate_limit(domain, headers=response_headers):
                self.logger.info(f"Updated rate limit on instance {self.settings.federation.instance_name} for domain {domain}: {state}")

            try:
                response_content: dict[str, Any] = cast(dict[str, Any], json.loads(response.content.decode("utf8")))
            except JSONDecodeError as e:
                raise DecodingError(message=f"JSONDecodeError for response_content '{response.content}'.")

            if "location" in response_headers:
                response_content["Location"] = response_headers["location"]

            if response.status_code in (HTTPStatus.OK, HTTPStatus.CREATED, HTTPStatus.ACCEPTED):
                result.success.append(response_content)
                result.status_code = response.status_code
                return result

            if (
                response.status_code in (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.GATEWAY_TIMEOUT)
                and retry_count < self.settings.federation.max_retries
            ):
                # Calculate retry delay
                retry_after = int(response_headers.get("retry-after", self.settings.federation.retry_delay))
                self.logger.warning(f"Retry delivery of activity with id={activity.id} after waiting {retry_after} seconds. Retry_count: {retry_count + 1} / {self.settings.federation.max_retries}...")
                await asyncio.sleep(retry_after)

                # Retry delivery
                return await self.deliver_to_url(activity=activity, url=url, retry_count=retry_count + 1)

            self.logger.warning(
                f"Delivery Error for activity type {activity.type}: url={url}, content={response_content}, headers={response_headers}"
            )
            result.failed.append(url)
            result.status_code = response.status_code
            result.error_message = str(response_content) if len(response_content) > 0 else "No response text received"

        except JSONDecodeError as e:
            self.logger.error(f"Delivery JSONDecodeError: {e}")
            result.failed.append(url)
            result.status_code = HTTPStatus.BAD_REQUEST
            result.error_message = f"Delivery exception: cannot decode."
        except ActivityPubException as e:
            self.logger.error(f"Delivery Exception: {e.message}")
            result.failed.append(url)
            result.status_code = e.status_code
            result.error_message = f"Delivery exception: {e.user_message or 'unknown'}"

        return result

    async def deliver_to_actor_inbox(self, activity: APActivity, actor_id: ObjectId) -> DeliveryResult:
        """Deliver activity to an actor's inbox, i.e., server2server communication."""
        try:
            actor = await self.resolver.resolve_actor(actor_id)

            # Remove all to/cc/bto/bcc/audience fields (also from object), because this is just sent to a single actor.
            activity = await ActivityDelivery.remove_hidden_recipients(activity)
            activity.to = None
            activity.cc = None
            activity.bto = None
            activity.bcc = None
            activity.audience = None

            # Check if the actor is local to the instance. If they are, do not deliver via url, just process it locally.
            if await self.check_is_local(actor_id) and self.local_delivery_inbox is not None:
                return await self.deliver_to_local_actor(activity=activity, collection=ValidCollection.Inbox, actor_id=actor_id)
            else:
                # if this was a locally created activity, do not send this tag to others
                # activity.local = None
                return await self.deliver_to_url(activity=activity, url=actor.inbox)

        except ActivityPubException as e:
            self.logger.debug(f"Discovery failed for actor {actor_id}: {e.message}. Failing silently.")
            return DeliveryResult(failed=[actor_id], error_message="Actor not found.", status_code=HTTPStatus.GONE)
        except Exception as e:
            self.logger.warning(f"Failed to deliver to actor {actor_id} inbox: {e}. Failing silently.")
            return DeliveryResult(failed=[actor_id], error_message=str(e))

    async def add_hash_to_activity_object(self, activity: APActivity) -> APActivity:
        """Compute a SHA256 hash of the activity.object content and add it in the 'hash' field of the object.

        This is only done if the activity is "Create", and if there is not already a hash field set in the object.
        Otherwise, the activity and its object are returned unchanged.

        Args:
            activity (APActivity): Activity with an object attached.

        Returns:
            APActivity: The object with a new 'hash' field, if it was created.
        """
        if (activity.type == ActivityType.CREATE.value or activity.type == ActivityType.UPDATE.value) and activity.object:
            hash: str | None = None
            content: str | None = None
            if isinstance(activity.object, APObject):
                hash = activity.object.hash
                content = activity.object.content
            if hash is None:
                hash = to_sha256(str(content))
            if isinstance(activity.object, dict):
                activity.object["hash"] = hash
            elif isinstance(activity.object, APObject):
                activity.object.hash = hash
        return activity

    async def deliver_to_actor_outbox(self, activity: APActivity, actor_id: ObjectId, token: SecretStr | None = None) -> DeliveryResult:
        """Deliver activity to an actor's outbox.
        This is the main method for client2server communication. Instead of signing the header (which would require access to the private key),
        the client must send a token for authentication.

        Args:
            activity (APActivity): Activity to deliver.
            actor_id (ObjectId): id of actor that is performing the activity.
            token (str | None, optional): Authentication token, if available and necessary. Defaults to None.

        Returns:
            DeliveryResult: Result of the delivery to the outbox. Can contain the id of the created activity, if successful.
        """
        try:
            # always hash the content
            activity = await self.add_hash_to_activity_object(activity)

            # Check if the actor is local to the instance. If they are, do not deliver via url, just process it locally.
            if await self.check_is_local(actor_id) and self.local_delivery_outbox is not None:
                return await self.deliver_to_local_actor(activity=activity, collection=ValidCollection.Outbox, actor_id=actor_id)
            else:
                # this branch is used if the actor is not local to the instance, OR if this method is used from a client.
                # In the latter case, a token is available and can be sent (to the instance).
                actor = await self.resolver.resolve_actor(actor_id)
                return await self.deliver_to_url(activity=activity, url=actor.outbox, token=token)
        except ActivityPubException as e:
            self.logger.error(f"Failed to deliver to actor {actor_id} outbox: {e.message}. Failing silently.")
            return DeliveryResult(failed=[actor_id], error_message=e.user_message)
        except Exception as e:
            self.logger.error(f"Failed to deliver to actor {actor_id} outbox: {str(e)}. Failing silently.")
            return DeliveryResult(failed=[actor_id], error_message=str(e))

    async def deliver_to_federation(self, activity: APActivity):
        """Delivers an activity to the entire network of federated instances.

        This is usually done for APUpdate activities, e.g. key rotation or actors that move instance.
        Sharing is done either to the shared inbox (if available) or the instance actor (as a fallback).

        Args:
            activity (APActivity): An activity to share to the shared inboxes or instance actors of federated instances.
        """
        if not self.instance:
            raise DeliveryError("Delivery does not have an instance object set up. Cannot share to federation")
        if not self.local_delivery_outbox:
            raise DeliveryError("Delivery does not have a local_delivery_outbox set up. Cannot share to federation")

        federated_instance_actors = await self.instance.get_federated_instance_actors(access=AccessType.PRIVATE)

        self.logger.debug(f"Current federated instance actor list: {federated_instance_actors}")

        # set the instances in blind-to field (to hide them from each other)
        activity.bto = federated_instance_actors

        # add hash of the key content to the key
        activity = await self.add_hash_to_activity_object(activity)

        # send through instance actor outbox, to add activity id and store the activity properly
        result = await self.local_delivery_outbox(activity)
        self.logger.info(
            f"Delivering activity {activity.type} with object {activity.object} to {len(federated_instance_actors)} federated instances. Current result: {result}"
        )
