"""
client.py
Implements a client to interact with phederation instances.
This is a quality-of-life service to demonstrate and ease new client implementations.
It is also heavily used in testing all functionality of the software.
"""

from http import HTTPStatus
import json
from logging import Logger
from typing import Any, cast

from fastapi.datastructures import QueryParams
import httpx
from pydantic import BaseModel, Field
from pydantic.types import SecretStr

from phederation.cache.dictcache import DictCache
from phederation.federation.ratelimit import RateLimiter
from phederation.models import APCreate, ValidCollection, object_from_type
from phederation.models.activities import APActivity, APMigrate
from phederation.models.actors import APActor
from phederation.models.objects import APDocument, APObject
from phederation.models.proofs import to_sha256
from phederation.security import Authentication
from phederation.utils import ActivityPubBaseWithId, NodeInfo
from phederation.utils.base import (
    InstanceEndpoints,
    ObjectId,
    ResolverResult,
    UrlType,
    actor_id_from_username,
    urljoin,
)
from phederation.utils.exceptions import DeliveryError, ResolverError
from phederation.utils.logging import configure_logger
from phederation.utils.serialization import to_json_string
from phederation.utils.settings import KeycloakSettings, PhedSettings
from phederation.utils.version import PHEDERATION_HEADERS

from .delivery import ActivityDelivery, DeliveryResult
from .discovery import InstanceDiscovery
from .resolver import ActivityPubResolver


class ActivityPubClient(BaseModel):
    """
    ActivityPubClient that can be used in client2server applications to easily setup and communicate with a phederation server using the activitypub protocol.

    Applications can use the 'delivery' and 'resolver' properties of a Client instance to deliver activities, and to resolve information about instances and actors in the federation. The 'host_url' must be set to the phederation server url, and the 'actor_id' must be set to the actor that is operating the client. This actor must be registered on the server.
    """

    settings: PhedSettings = Field(default=..., description="The settings for the client-side phederation setup.")
    instance_actor_id: ObjectId | None = Field(
        default=None,
        description="The id of the instance actor for the host instance. Must be a resolvable URL (as defined by ActivityPub), or None if it should be discovered from the host settings.",
    )
    keycloak_settings: KeycloakSettings | None = Field(description="Settings to reach the keycloak instance for user authentication (OAuth2).")
    username: str | None = Field(default=None, description="Username of the actor to use for login")
    password: SecretStr | None = Field(default=None, description="Password of the actor to use for login")
    access_token: SecretStr | None = Field(default=None, description="Current access_token of the actor to use for authentication")
    _initialized: bool = False

    def initialize(self):
        if not self._initialized:
            self._discovery: InstanceDiscovery = InstanceDiscovery(settings=self.settings)
            self._resolver: ActivityPubResolver = ActivityPubResolver(settings=self.settings)
            self._rate_limiter: RateLimiter = RateLimiter(cache=DictCache(), settings=self.settings)
            self._delivery: ActivityDelivery = ActivityDelivery(
                discovery=self._discovery,
                resolver=self._resolver,
                rate_limiter=self._rate_limiter,
                settings=self.settings,
            )
            self._logger: Logger = configure_logger(__name__, prefix=self.settings.federation.logging_prefix + "_client")
            if self.keycloak_settings is None:
                self._logger.warning("Client does not have keycloak settings set. Will not be able to authenticate")
            self._authentication: Authentication = Authentication(settings=self.settings, keycloak_settings_client=self.keycloak_settings)
        self._initialized = True

    async def resolve_multiple(self, urls: list[ObjectId]):
        """Tries to resolve multiple objects given by their id.
        This will use the /resolve endpoint of the instance.
        The objects in the list can be of different types.
        The object ids must be for the instance connected to this client (because this is using the instance resolve endpoint)

        Args:
            urls (list[ObjectId]): Ids of objects to resolve.

        Returns:
        list[APObject | DataIntegrityProof | NodeInfo]: list of resolved objects. If an object id cannot be resolved, it is not included. The list is not necessarily in the same order as the given urls, but the objects have their own ids, so can be identified again.
        """
        # TODO: allow to resolve from multiple instance endpoints
        # get resolve endpoint from instance
        if not self._initialized:
            raise ResolverError("Must initialize the client first")
        instance_info = await self._discovery.discover_instance(self.settings.domain.base_url)
        resolve_endpoint: ObjectId | None = None
        if isinstance(instance_info.endpoints, InstanceEndpoints):
            resolve_endpoint = instance_info.endpoints.resolve
        elif "resolve" in list(instance_info.endpoints.keys()):
            resolve_endpoint = instance_info.endpoints.get("resolve", None)
        if resolve_endpoint:
            try:
                headers = await self._headers(with_token=True)
                urls_encoded = json.dumps(urls)
                response = await self.delivery.requests.post(resolve_endpoint, headers=headers, content=urls_encoded)
                result_bytes = await response.aread()
                result_json = cast(list[dict[str, Any]], json.loads(result_bytes.decode(encoding="utf-8")))
                result_objects = [object_from_type(obj_dict) for obj_dict in result_json]
                result_objects_with_id = [obj for obj in result_objects if isinstance(obj, ActivityPubBaseWithId)]
                return result_objects_with_id
            except Exception as e:
                raise ResolverError(f"Could not resolve from /resolve endpoint; error: {e}")
        raise ResolverError(f"The instance does not have a /resolve endpoint")

    @property
    def actor_id(self):
        """The id of the actor who is using this client.
        A resolvable URL (as defined by ActivityPub), or None if no specific actor is using it."""
        if self.username:
            return actor_id_from_username(self.settings.domain.base_url, username=self.username)
        else:
            raise ValueError("Actor_id not available in ActivityPubClient: field 'username' is not set")

    def get_token(self) -> SecretStr | None:
        if self.access_token:
            return self.access_token
        if not self._authentication or not self.username or not self.password:
            token = None
        else:
            token = self._authentication.authenticate_user(username=self.username, password=self.password)
            self.access_token = token
        return token

    async def deliver_activity(self, activity: APActivity):
        if not self.actor_id:
            raise DeliveryError("Could not deliver activity, actor_id is not set")
        token = self.get_token()
        obj = activity.object
        if self.settings.media.create_file_hash and isinstance(obj, APObject):
            obj.hash = to_sha256(obj.content or "")

        result: DeliveryResult | None = None
        for k in range(self.settings.federation.max_retries):
            try:
                result = await self.delivery.deliver_to_actor_outbox(activity, actor_id=self.actor_id, token=token)
                break
            except httpx.ReadTimeout:
                self._logger.warning(f"Http ReadTimeout when delivering activity, trying again {k+1}/{self.settings.federation.max_retries}...")
        if not result:
            result = DeliveryResult(
                success=[],
                failed=[self.actor_id],
                status_code=HTTPStatus.BAD_GATEWAY,
                error_message=f"Httpx ReadTimeout errors, tried {self.settings.federation.max_retries} times.",
            )
        return result

    async def deliver_maintenance(self, activity: APActivity):
        token = self.get_token()
        obj = activity.object
        if self.settings.media.create_file_hash and isinstance(obj, APObject):
            obj.hash = to_sha256(obj.content or "")

        maintenance_route = urljoin(self.settings.domain.admin_url, UrlType.Maintenance.value)
        result = await self.delivery.deliver_to_url(activity, url=maintenance_route, token=token)
        return result

    async def resolve_nodeinfo(self) -> NodeInfo | None:
        base_url = self.settings.domain.base_url
        nodeinfo = await self._discovery.discover_nodeinfo(base_url=base_url)
        return nodeinfo

    async def register_user(self):
        """Registers the user associated with this client on the instance keycloak.

        Note that this right now only works with the MockKeycloak, not an actual keycloak instance.

        Raises:
            DeliveryError: If the client is not set up properly.

        Returns:
            fastapi.Response: the response from the keycloak after registration.
        """
        if not self.actor_id or not self.keycloak_settings:
            raise DeliveryError("Could not register user, actor_id or keycloak settings not defined")
        if not self.password or not self.username:
            raise DeliveryError("Could not register user, username or password not defined")

        url = urljoin(self.settings.domain.keycloak_hostname, f"/realms/{self.keycloak_settings.keycloak_realm}/protocol/openid-connect/register")
        headers = await self._headers(with_token=False)
        password_string = self.password.get_secret_value()
        params = {"username": self.username, "password": password_string, "redirect_uri": self.settings.domain.base_url}
        result = await self.delivery.requests.put(url=url, params=QueryParams(params), headers=headers)
        return result

    async def create_account(
        self,
    ):
        if not self.actor_id or not self.username:
            raise DeliveryError("Could not create account, actor_id or username is not set")
        actor_id = actor_id_from_username(self.settings.domain.base_url, username=self.username)
        inbox_url = urljoin(actor_id, ValidCollection.Inbox.value)
        outbox_url = urljoin(actor_id, ValidCollection.Outbox.value)
        activity = APCreate(actor=self.actor_id, object=APActor(preferred_username=self.username, inbox=inbox_url, outbox=outbox_url))
        url = urljoin(self.settings.domain.base_url, ValidCollection.Users.value)
        headers = await self._headers(with_token=True)
        body_serialized = to_json_string(activity.serialize())
        self._logger.info(f"Sending user acount registration for user {self.username} to instance url {url}")
        result = await self.delivery.requests.put(url=url, content=body_serialized, headers=headers)
        return result

    async def _headers(self, with_token: bool = True):
        headers = PHEDERATION_HEADERS
        headers["Host"] = self.settings.domain.url
        if with_token:
            token = self.get_token()
            if token:
                headers["Authorization"] = f"Bearer {token.get_secret_value()}"
                headers["WWW-Authenticate"] = "Bearer"
        return headers

    @property
    def delivery(self):
        self.initialize()
        return self._delivery

    @property
    def resolver(self):
        self.initialize()
        return self._resolver

    async def discover_instance_actor(self):
        if self.instance_actor_id is None:
            host_instance_url = self.settings.domain.base_url
            actor = await self._discovery.discover_instance_actor(host_instance_url)
            if actor and "id" in actor:
                self.instance_actor_id = actor.get("id", None)
        return self.instance_actor_id

    async def download_user_data(self):
        if not self.username:
            raise ResolverError("Username is not set in the client, cannot download user data")
        # send the "assemble user data" command
        create_userdata = APMigrate(actor=self.actor_id)
        result = await self.deliver_activity(activity=create_userdata)
        activity_id = result.get_success_id_or_raise()

        # retrieve the activity to get the file url
        token = self.get_token()
        activity = await self.resolver.resolve_activity(activity_id=activity_id, access_token=token)
        file_descriptor_document = await self.resolver.resolve_object(activity.object)
        file_urls = file_descriptor_document.url

        if not file_urls:
            response = ResolverResult(
                content=file_descriptor_document.id, status_code=None, error_message="Url is not contained in the user data document"
            )
            raise ResolverError("Could not resolve any user data file urls", response=response)

        # get the actor media endpoint and download the data
        files: list[bytes] = []
        for file_url in file_urls:
            resolved_file = b"".join([line async for line in await self.resolver.resolve_media(file_id=file_url, access_token=token)])
            files.append(resolved_file)
        return files

    async def user_migration_initialize(self, client_for_new_instance: "ActivityPubClient"):
        activity = APMigrate(actor=self.actor_id, actor_to=client_for_new_instance.actor_id, object=None)
        result = await self.deliver_activity(activity)
        return result

    async def user_migration_status(self, migration_activity_id: ObjectId):
        token = self.get_token()
        activity = await self.resolver.resolve_activity(migration_activity_id, access_token=token)
        if activity.object:
            obj = await self.resolver.resolve_object(activity.object, access_token=token)
            return cast(APDocument, obj)
