from datetime import datetime, timezone
import enum
import json
from logging import Logger
import os
from typing import Any, cast
from urllib.parse import urlparse

from pydantic import HttpUrl

from phederation.cache import BaseCache
from phederation.cache.base import WithCache, with_cache
from phederation.collections.collections import CollectionManager
from phederation.federation.discovery import InstanceDiscovery, InstanceInfo
from phederation.models import RelativeLink, dereference
from phederation.models.activities import APActivity
from phederation.models.actors import APActor
from phederation.utils import ObjectId
from phederation.utils.base import (
    AccessType,
    NodeInfo,
    UrlType,
    actor_id_from_username,
    assemble_id_url,
    collection_id_from_name,
    urljoin,
)
from phederation.utils.logging import configure_logger
from phederation.utils.settings import PhedSettings
from phederation.utils.version import PHEDERATION_SOFTWARE_NAME, PHEDERATION_VERSION


class InstanceCollectionType(str, enum.Enum):
    Federated = "federated"
    FederatedInstanceActors = "federated_instance_actors"
    Blocked = "blocked"
    BannedActors = "bannedactors"
    FederatedSharedInboxes = "federated_shared_inboxes"
    MaintenanceCommands = UrlType.Maintenance.value
    ActivitiesIncoming = UrlType.ActivitiesIncoming.value
    ActivitiesOutgoing = UrlType.ActivitiesOutgoing.value


class APInstance(WithCache):
    """
    Holds information about the current instance in the ActivityPub federation.
    """

    def __init__(
        self,
        settings: PhedSettings,
        instance_name: str,
        collections: CollectionManager,
        discovery: InstanceDiscovery,
        settings_path: str,
        cache: BaseCache | None = None,
    ):
        self.cache: BaseCache | None = cache
        self.settings: PhedSettings = settings
        self.settings_path: str = settings_path
        self.discovery: InstanceDiscovery = discovery
        self.collections: CollectionManager = collections
        self.instance_name: str = instance_name

        # this is the instance actor, a SERVICE actor
        self.actor_id: ObjectId = actor_id_from_username(base_url=self.settings.domain.base_url, username=instance_name)
        self.netloc: str = urlparse(self.actor_id).netloc

        # this is the instance id, where special collections can be obtained in admin mode
        self.instance_id: ObjectId = assemble_id_url(type=UrlType.Instance, base_url=self.settings.domain.admin_url)
        self.federated_instances: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.Federated.value)
        self.federated_instance_actors: ObjectId = collection_id_from_name(
            id=self.instance_id, name=InstanceCollectionType.FederatedInstanceActors.value
        )
        self.blocked_instances: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.Blocked.value)
        self.banned_actors: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.BannedActors.value)
        self.maintenance_commands: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.MaintenanceCommands.value)
        self.activities_incoming: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.ActivitiesIncoming.value)
        self.activities_outgoing: ObjectId = collection_id_from_name(id=self.instance_id, name=InstanceCollectionType.ActivitiesOutgoing.value)
        self._node_info: NodeInfo = NodeInfo()

        # TODO: enable to set this in settings
        self.federated_instances_access: AccessType = AccessType.PRIVATE
        self.blocked_instances_access: AccessType = AccessType.PRIVATE
        self.banned_actors_access: AccessType = AccessType.PRIVATE

        self.logger: Logger = configure_logger(name=__name__, prefix=self.settings.federation.logging_prefix)

    async def initialize(self):
        self._node_info = NodeInfo(
            software={
                "name": PHEDERATION_SOFTWARE_NAME,
                "version": PHEDERATION_VERSION,
                "homepage": self.settings.domain.url,
            },
            protocols=["activitypub"],
            services={"inbound": [], "outbound": []},
            open_registrations=False,
            metadata={"nodeName": self.instance_name, "nodeDescription": f"{self.instance_name} is a {PHEDERATION_SOFTWARE_NAME} software instance."},
            updated_at=datetime.now(timezone.utc),
        )

        self.federated_instances = await self.collections.create_collection(collection_id=self.federated_instances)
        self.federated_instance_actors = await self.collections.create_collection(collection_id=self.federated_instance_actors)
        self.blocked_instances = await self.collections.create_collection(collection_id=self.blocked_instances)
        self.banned_actors = await self.collections.create_collection(collection_id=self.banned_actors)
        self.maintenance_commands = await self.collections.create_collection(collection_id=self.maintenance_commands)
        self.activities_incoming = await self.collections.create_collection(collection_id=self.activities_incoming)
        self.activities_outgoing = await self.collections.create_collection(collection_id=self.activities_outgoing)

    async def log_incoming(self, activity: APActivity):
        if not activity.id:
            self.logger.debug(f"Could not log incoming {activity.type} activity: does not have an id")
        else:
            if self.settings.statistics.enabled and self.settings.statistics.log_activities_incoming:
                await self.collections.add_to_collection(
                    collection_id=self.activities_incoming, items=activity.id, access=AccessType.PRIVATE, add_only_once=False
                )

    async def log_outgoing(self, activity: APActivity):
        if not activity.id:
            self.logger.debug(f"Could not log outgoing {activity.type} activity: does not have an id")
        else:
            if self.settings.statistics.enabled and self.settings.statistics.log_activities_outgoing:
                self.logger.debug(f"Logging outgoing activity {activity.id}")
                await self.collections.add_to_collection(
                    collection_id=self.activities_outgoing, items=activity.id, access=AccessType.PRIVATE, add_only_once=False
                )

    async def federate(self, instance_id: ObjectId):
        """Given a generic url, saves the id in the list of federated instances, if it is not already in it.
        Also tries to discover the InstanceInfo using discover_instance, and (if accessible) stores the shared_inbox of the remote instance. This can then be used to deliver public objects to it.

        Args:
            instance_id (ObjectId): generic url, will be converted to scheme://netloc (no path, query, fragemnts) and processed.
        """
        parsed_url = urlparse(instance_id)
        instance_id = HttpUrl.build(scheme=parsed_url.scheme, host=parsed_url.netloc).__str__()
        # only federate other instances, not self
        if self.netloc != parsed_url.netloc:
            federated_instance_ids = await self._get_federated_instances(collection=self.federated_instances, access=self.federated_instances_access)
            if not instance_id in federated_instance_ids:
                self.logger.info(f"Federating new instance: {instance_id}, currently federated: {federated_instance_ids}")
                # Now discover information about the remote instance, and save its shared inbox, if available
                instance_info = await self.discovery.discover_instance(instance_id)
                instance_actor = dereference(instance_info.instance_actor, "id")
                if not instance_actor:
                    self.logger.warning(f"Instance actor of instance to federate ({instance_id}) is None")
                    return

                await self.collections.add_to_collection(
                    collection_id=self.federated_instances, items=instance_id, access=self.federated_instances_access, add_only_once=True
                )
                await self.collections.add_to_collection(
                    collection_id=self.federated_instance_actors, items=instance_actor, access=self.federated_instances_access, add_only_once=True
                )

                # clear cache to reload everything from storage next time it gets accessed
                if self.cache:
                    self.cache.clear(__name__)

    async def defederate(self, instance_id: ObjectId):
        parsed_url = urlparse(instance_id)
        instance_id = HttpUrl.build(scheme=parsed_url.scheme, host=parsed_url.netloc).__str__()
        # only defederate other instances, not self
        if self.netloc != parsed_url.netloc:
            if instance_id in await self._get_federated_instances(collection=self.federated_instances, access=self.federated_instances_access):
                await self.collections.remove_from_collection(
                    collection_id=self.federated_instances, items=instance_id, access=self.federated_instances_access
                )
                instance_info = await self.discovery.discover_instance(instance_id)
                instance_actor = dereference(instance_info.instance_actor, "id")
                if instance_actor:
                    await self.collections.remove_from_collection(
                        collection_id=self.federated_instance_actors,
                        items=instance_actor,
                        access=self.federated_instances_access,
                    )
                if self.cache:
                    self.cache.clear(__name__)

    @with_cache(__name__)
    async def _get_federated_instances(self, collection: ObjectId, access: AccessType = AccessType.PUBLIC) -> list[ObjectId]:
        federated = await self.collections.get_collection_items(collection_id=collection, access=access)
        return federated

    async def get_federated_instance_actors(self, access: AccessType = AccessType.PUBLIC) -> list[ObjectId]:
        """Get the instance actor of all federated instances.

        Args:
            access (AccessType, optional): The access type for the instances to retrieve. Defaults to AccessType.PUBLIC.

        Returns:
            list[ObjectId]: List of urls for instance actors all federated instances, depending on the access type set.
        """
        return await self._get_federated_instances(collection=self.federated_instance_actors, access=access)

    async def block_instance(self, url: ObjectId):
        """Adds the given instance url to the list of blocked instances.

        A blocked instance is not able to send or retrieve information from the current instance.

        Args:
            url (ObjectId): Url of the instance to block.
        """
        await self.defederate(url)
        await self.collections.add_to_collection(
            collection_id=self.blocked_instances, items=url, access=self.blocked_instances_access, add_only_once=True
        )
        if self.cache:
            self.cache.clear(__name__)

    async def unblock_instance(self, url: ObjectId):
        """Undo the block_instance command.

        Args:
            url (ObjectId): Url of the instance to unblock.
        """
        await self.collections.remove_from_collection(collection_id=self.blocked_instances, items=url, access=self.blocked_instances_access)
        if self.cache:
            self.cache.clear(__name__)

    async def _get_blocked_instances(self, access: AccessType = AccessType.PUBLIC) -> list[ObjectId]:
        return await self._get_federated_instances(collection=self.blocked_instances, access=access)

    async def is_blocked_instance(self, url: ObjectId):
        url_parsed = urlparse(url)
        url_netloc = url_parsed.netloc
        blocked_actors = await self._get_blocked_instances(access=self.blocked_instances_access)
        for blocked in blocked_actors:
            blocked_netloc = urlparse(blocked).netloc
            if blocked_netloc.strip("/") == url_netloc.strip("/"):
                return True
        # if the url is not blocked, federate it.
        instance_id = HttpUrl.build(scheme=url_parsed.scheme, host=url_parsed.netloc).__str__()
        await self.federate(instance_id=instance_id)
        return False

    async def ban_actor(self, actor_id: ObjectId):
        await self.collections.add_to_collection(
            collection_id=self.banned_actors, items=actor_id, access=self.banned_actors_access, add_only_once=True
        )
        if self.cache:
            self.cache.delete(self.banned_actors)

    async def unban_actor(self, actor_id: ObjectId):
        for _ in range(1000):  # remove all previous bans
            if await self.is_banned_actor(actor_id=actor_id):
                await self.collections.remove_from_collection(collection_id=self.banned_actors, items=actor_id, access=self.banned_actors_access)
            else:
                break
        if self.cache:
            self.cache.clear(__name__)

    async def is_banned_actor(self, actor_id: ObjectId | None):
        if not actor_id:
            return True
        return actor_id in await self._get_banned_actors(access=self.banned_actors_access)

    async def _get_banned_actors(self, access: AccessType = AccessType.PUBLIC) -> list[ObjectId]:
        return await self._get_federated_instances(collection=self.banned_actors, access=access)

    @property
    def actor(self):
        return self.actor_id

    @with_cache(prefix="nodeinfo getter")
    async def get_nodeinfo(self) -> NodeInfo:
        nodeinfos = await self.collections.storage.node_info.select(access=AccessType.PUBLIC)

        def sort_infos(key: NodeInfo):
            return key.updated_at

        nodeinfos.sort(key=sort_infos)
        if len(nodeinfos) > 0:
            self._node_info = nodeinfos[-1]
        return self._node_info

    def get_nodeinfo_links(self) -> dict[str, list[RelativeLink]]:
        nodeinfo_schema_version = "2.1"
        return {
            "links": [
                RelativeLink(
                    rel=urljoin("http://nodeinfo.diaspora.software/ns/schema/", nodeinfo_schema_version),
                    href=urljoin(self.settings.domain.base_url, "nodeinfo", nodeinfo_schema_version),
                )
            ]
        }

    def get_nodeinfo_schema(self) -> dict[str, Any]:
        with open(os.path.join(self.settings_path, "nodeinfo.schema.json"), "r+") as nodeinfo_schema_file:
            nodeinfo_schema = cast(dict[str, Any], json.load(nodeinfo_schema_file))
            nodeinfo_schema_id = cast(str, nodeinfo_schema.get("id", ""))
            nodeinfo_schema["id"] = nodeinfo_schema_id.replace("{DOMAIN}", self.settings.domain.url)
            return nodeinfo_schema

    async def get_instance_info(self) -> InstanceInfo:
        actor: APActor | None = await self.collections.storage.actor.read(id=self.actor_id)
        shared_inbox = urljoin(self.settings.domain.base_url, "inbox")
        features = {"activitypub": True, "webfinger": True, "nodeinfo": True, "shared_inbox": True, "media_proxy": True}
        endpoints = {
            "shared_inbox": shared_inbox,
            "nodeinfo": urljoin(self.settings.domain.base_url, "/.well-known/nodeinfo"),
            "webfinger": urljoin(self.settings.domain.base_url, "/.well-known/webfinger"),
        }
        if "resolve" in self.settings.federation.endpoints:
            endpoints["resolve"] = urljoin(self.settings.domain.base_url, UrlType.Resolve.value)
            features["resolve"] = True
        if "collections" in self.settings.federation.endpoints:
            features["collections"] = True
        return InstanceInfo(
            domain=self.settings.domain.base_url,
            nodeinfo=await self.get_nodeinfo(),
            endpoints=endpoints,
            features=features,
            instance_actor=actor.serialize() if actor else None,
            shared_inbox=shared_inbox,
            software_version=PHEDERATION_VERSION,
            last_updated=datetime.now(timezone.utc),
        )
