"""
federation/resolver.py
ActivityPub resolver implementation.
"""

from collections.abc import AsyncIterator
from http import HTTPStatus
from json import JSONDecodeError
import json
from logging import Logger
from typing import Any, cast
from urllib.parse import urlparse

import httpx
from pydantic import SecretStr
from urllib3.util import Url

from phederation.cache import BaseCache
from phederation.cache.base import WithCache, with_cache
from phederation.federation.instance import APInstance
from phederation.models import (
    APAccount,
    APActivity,
    APActor,
    APCollection,
    APCollectionPage,
    APObject,
    APOrderedCollection,
    APOrderedCollectionPage,
    APPrivateKey,
    dereference,
    object_from_type,
)
from phederation.models.objects import dereference_or_raise
from phederation.models.proofs import DataIntegrityProof
from phederation.storage import StorageBackend
from phederation.utils import ObjectId, PUBLIC_URLS, UrlType, validate_object_id
from phederation.utils.base import (
    AccessType,
    ActivityType,
    NodeInfo,
    ObjectType,
    ResolverResult,
    assemble_id_url,
    collection_id_from_name,
    username_from_actor_id,
)
from phederation.utils.exceptions import (
    ActivityPubException,
    AuthorizationError,
    ResolverError,
)
from phederation.utils.logging import configure_logger
from phederation.utils.settings import PhedSettings
from phederation.utils.version import PHEDERATION_USERAGENT


class ActivityPubResolver(WithCache):
    """Resolves ActivityPub resources.

    Features:
     - Resolve objects and activities over the network.
     - Local resolution inside an instance, from storage or a dictionary."""

    def __init__(
        self,
        settings: PhedSettings,
        storage: StorageBackend | None = None,
        instance: APInstance | None = None,
        cache: BaseCache | None = None,
        timeout: int = 3,
    ):
        """Create new resolver.

        Args:
            settings (PhedSettings): The settings for the instance, as much as they are available.
            local_domain (str | None, optional): base_url for the local instance. Defaults to None.
            storage (StorageBackend | None, optional): Storage backend, if not in c2s mode. Defaults to None.
            instance (APInstance | None, optional): Instance, if not in c2s mode. Defaults to None.
            cache (BaseCache | None, optional): Optional cache for actor, object, and collection data. Defaults to None.
            timeout (int, optional): seconds until timeout for resolver requests. Defaults to 3.
        """
        self.settings: PhedSettings = settings
        self.cache: BaseCache | None = cache
        self.timeout: int = timeout
        self.storage: StorageBackend | None = storage
        self.instance: APInstance | None = instance
        self.local_domain: str | None = settings.domain.base_url
        # 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)

    def is_local(self, url: ObjectId) -> bool:
        return self.local_domain is not None and str(url).startswith(self.local_domain)

    async def try_resolve_from_storage(
        self, url: ObjectId, url_type: UrlType
    ) -> APObject | APActivity | APCollection | APOrderedCollection | APActor | APPrivateKey | DataIntegrityProof | NodeInfo | None:
        """Tries to resolve an object with the given id from the local storage.

        Note that this does **not** attach a DataIntegrityProof to the object here, even if it is required by settings.
        Currently, only "/users", "/activities" and "/objects" paths are resolved,
        and only if they directly point to an actor, activity, or object (not collections of them).

        Args:
            url (ObjectId): Id of the object to resolve.
            url_type (UrlType): the type of the url to resolve (Users, Activities, ...). This is required because not all urls make it obvious what objects they are resolving.

        Returns:
            APObject | APActivity | APCollection | APOrderedCollection | APActor | APPrivateKey | DataIntegrityProof | NodeInfo | None: Object resolved from storage.
        """
        # do *not* check if the url is local, so that we can properly resolve cached remote objects
        # if self.is_local(url):
        if self.storage:
            try:
                if url_type == UrlType.Users:
                    return await self.storage.actor.read(id=url)
                elif url_type == UrlType.Activities:
                    return await self.storage.activity.read(id=url)
                elif url_type == UrlType.Objects:
                    return await self.storage.object.read(id=url)
                elif url_type == UrlType.Keys:
                    return await self.storage.key.read(id=url)
                elif url_type == UrlType.Proofs:
                    return await self.storage.proof.read(id=url)
                elif url_type == UrlType.NodeInfo:
                    return await self.storage.node_info.read(id=url)
            except ActivityPubException as e:
                self.logger.debug(e.message)
            except Exception as e:
                self.logger.debug(f"Could not resolve {url} from local storage; {e}")

    @with_cache(__name__)
    async def resolve(
        self, url: ObjectId, url_type: UrlType, as_bytes: bool = False, access_token: SecretStr | None = None
    ) -> (
        AsyncIterator[bytes]
        | APObject
        | APActivity
        | APCollection
        | APOrderedCollection
        | APActor
        | APAccount
        | APPrivateKey
        | DataIntegrityProof
        | NodeInfo
    ):

        result = await self.try_resolve_from_storage(url, url_type=url_type)
        if result:
            if (
                isinstance(result, DataIntegrityProof)
                or isinstance(result, NodeInfo)
                or AccessType.from_visibility(result.visibility) == AccessType.PUBLIC
            ):
                return result
            elif access_token:
                # do not expose the object, and try to resolve it over the network
                result = None
            else:
                raise AuthorizationError(f"Cannot resolve, not authorized (result.visibility={result.visibility})")
        if not result:
            if self.instance and await self.instance.is_blocked_instance(url):
                raise AuthorizationError(message=f"Cannot resolve, instance for url {url} is blocked")

            headers = {
                "Accept": 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
                "User-Agent": PHEDERATION_USERAGENT,
            }

            # only use the token if the request is on the local server!
            # Otherwise, the access token for the user gets sent to servers on the network, a security leak.
            if access_token:
                if self.is_local(url):
                    headers["Authorization"] = f"Bearer {access_token.get_secret_value()}"
                    headers["WWW-Authenticate"] = "Bearer"
                else:
                    self.logger.warning("Trying to access remote resource with user access token. This is a security issue - will not use the token.")

            try:
                if self.is_local(url) and self.instance:
                    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 self.requests.get(str(url), headers=headers, timeout=self.timeout)
            except httpx.ConnectError as e:
                result = ResolverResult(content=None, error_message=f"Connection error for {url}", status_code=None)
                raise ResolverError(f"Failed to resolve {url}, error: {e}", response=result)

            if response.status_code != HTTPStatus.OK:
                result = self.result_from_response(response, message=f"Failed to resolve url {url}")
                raise ResolverError(message=f"Failed to resolve url {url}: {response}, {response.content}", response=result)

            try:
                if as_bytes:
                    # response_bytes = response.iter_bytes()  TODO: use iter_bytes instead, to slow down download
                    result = response.aiter_bytes()
                else:
                    response_dict = cast(dict[str, Any], response.json())
                    result = self.resolve_from_dict(response_dict)
            except JSONDecodeError as e:
                result = self.result_from_response(response, message=f"JSONDecodeError for response from url {url}")
                raise ResolverError(f"ActivityPubResolver: JSONDecodeError for response {response}: {e}.", response=result)

        return result

    def result_from_response(self, response: httpx.Response, message: str | None = None):
        content_str = response.content.decode(errors="ignore")
        try:
            content_dict = cast(dict[str, str], json.loads(content_str))
        except JSONDecodeError:
            content_dict = {"bytes": content_str}
        return ResolverResult(
            content=content_dict,
            error_message=message,
            status_code=response.status_code,
        )

    async def resolve_media(self, file_id: ObjectId, access_token: SecretStr | None = None) -> AsyncIterator[bytes]:
        """Resolves the content of the file.

        Returns an async iterator to download the file content.

        Args:
            file_id (ObjectId): The url to download the file.
            access_token (SecretStr | None, optional): Access token of the user that can access the file. Defaults to None.

        Raises:
            ResolverError: If the file cannot be accessed.

        Returns:
            AsyncIterator[bytes]: Iterator for the bytes of the file.
        """
        self.logger.info(f"Resolving media with id {file_id} over network")

        result = await self.resolve(file_id, url_type=UrlType.Media, as_bytes=True, access_token=access_token)
        if not isinstance(result, AsyncIterator):
            raise ResolverError(f"Cannot download media from '{file_id}', is not a bytes array.")
        return result

    async def resolve_actor(self, actor_id: ObjectId | str | dict[str, Any] | APObject | None, access_token: SecretStr | None = None) -> APActor:
        """
        Resolve an actor by ID or account.

        This will try to resolve in the following order.
          1. From cache,
          2. From local storage (even for remote actors),
          3. From remote network / instance.
        This order ensures that actors are cached locally and not a lot of fetch requests are required.

        Args:
            actor_id: Actor ID or account (user@domain)

        Returns:
            Actor data or None if not found
        """
        if isinstance(actor_id, APObject):
            actor_id = actor_id.id
        if not actor_id:
            raise ResolverError("Cannot resolve actor_id, is None")
        if isinstance(actor_id, dict):
            actor = self.resolve_from_dict(actor_id)
            if not isinstance(actor, APActor):
                raise ResolverError(f"Failed to deserialize APActor (was type '{type(actor)}'): {actor.serialize()}")
            return actor
        if isinstance(actor_id, APActor):
            # nothing to do, just return it
            return actor_id

        actor = await self.resolve(actor_id, url_type=UrlType.Users, access_token=access_token)

        if not isinstance(actor, APActor):
            raise ResolverError(f"ActivityPubResolver: Failed to deserialize actor {actor_id}, type: '{type(actor).__name__}'.")

        return actor

    async def resolve_account(self, actor_id: ObjectId, access_token: SecretStr | None = None):
        parsed_url = urlparse(actor_id)
        base_url = validate_object_id(Url(scheme=parsed_url.scheme, host=parsed_url.netloc).url)
        username = username_from_actor_id(actor_id)
        account_url = assemble_id_url(UrlType.Accounts, base_url=base_url, primary=username)
        account = await self.resolve_object(account_url, url_type=UrlType.Accounts, access_token=access_token)

        if not isinstance(account, APAccount):
            raise ResolverError(f"ActivityPubResolver: Failed to deserialize account for {actor_id}, type: '{type(account).__name__}'.")
        return account

    def resolve_from_dict(
        self, object_dict: dict[str, Any]
    ) -> APObject | APActivity | APCollection | APOrderedCollection | APActor | APAccount | APPrivateKey | DataIntegrityProof | NodeInfo:
        object_type = object_dict.get("type", None)
        if not object_type:
            object_type = "Object"
        if not isinstance(object_type, str):
            raise ResolverError("Resolving type from object_dict did not result in a string")

        obj = object_from_type(object_dict)

        if (
            not isinstance(obj, APObject)
            and not isinstance(obj, APPrivateKey)
            and not isinstance(obj, DataIntegrityProof)
            and not isinstance(obj, NodeInfo)
        ):
            raise ResolverError(f"ActivityPubResolver: Cannot deserialize object (type {(type(obj))}): {object_dict}.")
        return obj

    async def resolve_nodeinfo(self, id: ObjectId | str | dict[str, Any] | NodeInfo | None, access_token: SecretStr | None = None) -> NodeInfo:
        if not id:
            raise ResolverError("Cannot resolve NodeInfo, id is None")
        # nothing to do, just return it
        if isinstance(id, NodeInfo):
            return id

        if isinstance(id, dict):
            obj = self.resolve_from_dict(id)
        else:
            obj = await self.resolve(id, url_type=UrlType.NodeInfo, access_token=access_token)
        if not isinstance(obj, NodeInfo):
            raise ResolverError("Object to resolve must be a NodeInfo. Use resolve_object, resolve_proof, or resolve_media for other types of url.")

        return obj

    async def resolve_proof(
        self, proof_id: ObjectId | str | dict[str, Any] | DataIntegrityProof | None, access_token: SecretStr | None = None
    ) -> DataIntegrityProof:
        if not proof_id:
            raise ResolverError("Cannot resolve object, id is None")
        # nothing to do, just return it
        if isinstance(proof_id, DataIntegrityProof):
            return proof_id

        if isinstance(proof_id, dict):
            obj = self.resolve_from_dict(proof_id)
        else:
            obj = await self.resolve(proof_id, url_type=UrlType.Proofs, access_token=access_token)
        if not isinstance(obj, DataIntegrityProof):
            raise ResolverError("Object to resolve must be a DataIntegrityProof. Use resolve_object or resolve_media for other types of url.")

        return obj

    async def resolve_object(
        self, object_id: ObjectId | str | dict[str, Any] | APObject | None, access_token: SecretStr | None = None, url_type: UrlType = UrlType.Unknown
    ) -> APObject | APActivity | APCollection | APOrderedCollection | APActor | APAccount | APPrivateKey:
        """
        Resolve an object by ID.

        Args:
            object_id: Object ID

        Returns:
            Object data or None if not found
        """
        if not object_id:
            raise ResolverError("Cannot resolve object, id is None")
        # nothing to do, just return it
        if isinstance(object_id, APObject) or isinstance(object_id, APPrivateKey):
            return object_id

        if isinstance(object_id, dict):
            obj = self.resolve_from_dict(object_id)
        else:
            if isinstance(object_id, APObject):
                object_type = dereference_or_raise(object_id, "type")
                url_type = UrlType.from_object_type(object_type)
            obj = await self.resolve(object_id, url_type=url_type, access_token=access_token)
        if isinstance(obj, AsyncIterator):
            raise ResolverError("Object to resolve must not be a media file, use resolve_media")
        if isinstance(obj, DataIntegrityProof):
            raise ResolverError("Object to resolve must not be a DataIntegrityProof, use resolve_proof")
        if isinstance(obj, NodeInfo):
            raise ResolverError("Object to resolve must not be a NodeInfo, use resolve_nodeinfo")

        return obj

    async def resolve_activity(
        self, activity_id: ObjectId | str | dict[str, Any] | APActivity | APObject | None, access_token: SecretStr | None = None
    ) -> APActivity:
        """
        Resolve an activity by ID.

        Args:
            activity_id: Activity ID

        Returns:
            Activity data or None if not found
        """
        activity = await self.resolve_object(activity_id, url_type=UrlType.Activities, access_token=access_token)
        if not isinstance(activity, APActivity):
            raise ResolverError(f"Failed to deserialize APActivity (is type {type(activity)}): {activity.serialize() if activity else 'None'}")
        return activity

    async def resolve_items(self, collection_id: ObjectId, max_pages: int = 100, access_token: SecretStr | None = None) -> list[ObjectId]:
        """Resolve all items in a paginated collection.

        This includes all pages and all contained items, until max_pages is reached.
        The items themselves are still represented as ObjectId alone, not fully resolved.

        Args:
            collection_id (ObjectId): id of the collection to resolve all items from.
            max_pages (int, optional): maximum number of pages to resolve. Defaults to 100.
            access_token (str | None, optional): token for authentication. Defaults to None.

        Raises:
            ResolverError: If the collection or requested page cannot be found.

        Returns:
            list[ObjectId]: List of all ids of the items.
        """
        collection_obj = await self.resolve_object(collection_id, url_type=UrlType.Collections, access_token=access_token)
        if not isinstance(collection_obj, APCollection):
            raise ResolverError("Could not resolve APCollection")

        resolved_items: list[ObjectId] = []
        total_items = collection_obj.total_items or 0

        if total_items == 0:
            return []

        items = collection_obj.items or []
        if isinstance(collection_obj, APOrderedCollection):
            items = collection_obj.ordered_items or []

        # the next page to visit depends on if the current collection is a generic collection or already a page
        next_page = dereference(collection_obj, "first")
        if collection_obj.type in [ObjectType.COLLECTION_PAGE.value, ObjectType.ORDERED_COLLECTION_PAGE.value]:
            next_page = dereference(collection_obj, "next")

        visited_pages: list[ObjectId] = []
        pages_to_visit: list[APCollection] = []

        if collection_obj.id:
            visited_pages.append(collection_obj.id)

        # at least try to visit the first page, if we visit any pages at all. From that page we will then visit all pages.
        if max_pages > 0 and next_page:
            next_page = await self.resolve_object(next_page, access_token=access_token)
            if isinstance(next_page, APCollection):
                pages_to_visit.append(next_page)

        for _ in range(max_pages):
            for item in items:
                if isinstance(item, str):
                    resolved_items.append(item)
                    continue
                item_id = dereference(item, "id")
                if not item_id:
                    raise ResolverError(f"Could not resolve id of item {item} in collection {collection_id}. Aborting.")

                # if we have visited the given item before, do not add its items again
                if item_id in visited_pages:
                    continue

                item_obj = await self.resolve_object(object_id=item_id, access_token=access_token)

                if any(
                    isinstance(item_obj, item_type) for item_type in [APCollection, APCollectionPage, APOrderedCollection, APOrderedCollectionPage]
                ):
                    # resolve the items directly, do not paginate here
                    resolved_items.extend(await self.resolve_items(item_id, max_pages=1, access_token=access_token))
                    visited_pages.append(item_id)
                    next_page = dereference(item, "next")
                    if next_page:
                        next_page = await self.resolve_object(next_page, access_token=access_token)
                        if isinstance(next_page, APCollection):
                            pages_to_visit.append(next_page)
                else:
                    resolved_items.append(item_id)
            items = pages_to_visit
            pages_to_visit = []

            if len(resolved_items) >= total_items:
                break

        return resolved_items

    async def resolve_collection_id(self, collection: str, actor_id: ObjectId, access_token: SecretStr | None = None) -> ObjectId:
        """Resolves only the id of a collection of the given actor or object.

        Args:
            actor_id (ObjectId): id of the actor.
            collection (str): name of the collection, e.g. "follower", "inbox", "outbox", "shares" (for objects), ...

        Returns:
            ObjectId: id of the collection

        Raises:
            ResolverError: If the collection cannot be found
        """
        # TODO: fix that we have to use "resolve_object" here because the actor_id may not actually be from an actor.
        response_obj = await self.resolve_object(object_id=actor_id, access_token=access_token)

        if not hasattr(response_obj, collection):
            raise ResolverError(
                f"ActivityPubResolver: actor/object {actor_id} does not have a collection '{collection}'. Dictionary: {response_obj}."
            )

        # now resolve the collection id using the link in the response
        collection_id = dereference(response_obj, key=collection)

        if not collection_id:
            # this sually means the collection is empty, so "None" was returned, not serialized.
            collection_id = collection_id_from_name(id=actor_id, name=collection)

        return collection_id

    async def resolve_collection(
        self, collection: str, actor_id: ObjectId, access_token: SecretStr | None = None
    ) -> APCollection | APOrderedCollection:
        """Resolves a collection of the given actor or object.
        The items or ordered_items property may not be fully resolved by this method, because it may contain a large number of objects.
        Pagination must be used to fully resolve it.

        Args:
            actor_id (ObjectId): id of the actor.
            collection (str): name of the collection, e.g. "follower", "inbox", "outbox", "shares" (for objects), ...

        Returns:
            APCollection | APOrderedCollection, depending on the type of collection.
        """
        # now resolve the collection using the link in the response
        collection_id = await self.resolve_collection_id(collection=collection, actor_id=actor_id, access_token=access_token)

        collection_obj = await self.resolve_object(object_id=collection_id, access_token=access_token)
        if not collection_obj:
            raise ResolverError(f"Collection with id '{collection_id}' was not accessible.")
        if not collection_obj.id:  # the case for collections
            collection_obj.id = collection_id

        if isinstance(collection_obj, APCollection):
            return collection_obj
        else:
            raise ResolverError(
                f"Collection with id '{collection_id}' did not resolve to an APCollection(Page) or APOrderedCollection(Page). Was: {collection_obj.serialize()}"
            )

    async def resolve_inboxes(self, recipients: list[ObjectId], depth: int = 3, access_token: SecretStr | None = None) -> list[ObjectId]:
        if depth < 0:
            self.logger.debug(f"ActivityPubResolver: resolution depth for recipient list reached.")
            return recipients

        # add instance actor inbox to recipients if this should be sent to Public
        recipients_without_public: list[ObjectId] = []
        for recipient_id in recipients:
            if recipient_id in PUBLIC_URLS:
                # add actors of federated instances, i.e. send to "everybody"
                if self.instance:
                    federated_inboxes = await self.instance.get_federated_instance_actors(access=AccessType.PRIVATE)
                    recipients.extend(federated_inboxes)
            else:
                recipients_without_public.append(recipient_id)
        recipients = recipients_without_public

        # deduplicate list
        recipients = list(set(recipients))

        # resolve inboxes of all actors
        resolved_list: list[ObjectId] = []
        for recipient_id in recipients:
            # try to resolve the id as a general object
            result_obj = await self.resolve_object(object_id=recipient_id, access_token=access_token)
            if isinstance(result_obj, APCollection) or isinstance(result_obj, APCollectionPage):
                if not result_obj.id:
                    raise ResolverError("Collection does not have an id")
                items = await self.resolve_items(collection_id=result_obj.id, access_token=access_token)

                items_resolved: list[ObjectId] = []
                for item in items:
                    item_id = dereference(item, key="id") if isinstance(item, dict) else item
                    if item_id:
                        items_resolved.append(item_id)

                # resolve all groups in the new list, too
                resolved_list += await self.resolve_inboxes(items_resolved, depth=depth - 1, access_token=access_token)
            elif isinstance(result_obj, APActivity) and result_obj.type == ActivityType.FOLLOW.value:
                actor_id = dereference(result_obj, key="actor")
                if not actor_id:
                    raise ResolverError("actor_id is none in resolved APFollow object")
                if self.instance and await self.instance.is_banned_actor(actor_id):
                    self.logger.debug(f"Not delivering to banned actor: {actor_id}")
                else:
                    actor = await self.resolve_actor(actor_id, access_token=access_token)
                    resolved_list.append(actor.inbox)
            elif isinstance(result_obj, APActor):
                if self.instance and await self.instance.is_banned_actor(result_obj.id):
                    self.logger.debug(f"Not delivering to banned actor: {result_obj.id}")
                else:
                    resolved_list.append(result_obj.inbox)
            else:
                self.logger.error(f"Cannot resolve recipient: {recipient_id} (wrong type: {type(result_obj)}). Failing silently.")
        return resolved_list

    async def resolve_access_type(self, object: APObject, actor_requesting_access: ObjectId | None):
        """Returns the given object if the given user is allowed to access it.

        Args:
            object (APObject): object to return.
            userinfo_requesting_access (UserInfo | None): Description of the actor trying to access the object.

        Returns:
            APObject | None: If the object is accessible by the user, else None.
        """
        if object.visibility.lower() == "public":
            return object

        actors_with_access = [dereference(object, "attributed_to"), dereference(object, "actor")]
        actors_with_access += await object.get_recipients()
        actors_with_access = list(set(actors_with_access))
        actors_with_access = [a for a in actors_with_access if a is not None]

        object_access = AccessType.from_visibility(object.visibility)
        if (
            (object_access == AccessType.PRIVATE or object_access == AccessType.FOLLOWER)
            and actor_requesting_access
            and actor_requesting_access in actors_with_access
        ):
            return object
        # TODO: handle follower lists
        # if attributed_to_id and object.visibility == AccessType.FOLLOWER.value and actor_requesting_access and access_token:
        #     collection_id = collection_id_from_name(id=attributed_to_id, name=UrlType.Follower.value)
        #     follower = await self.resolve_items(collection_id=collection_id, access_token=access_token)
        #     if actor_requesting_access in follower:
        #         return object
