from dataclasses import dataclass
from datetime import datetime, timezone
from http import HTTPStatus
from logging import Logger
from typing import Any, cast
from urllib.parse import urlparse

import httpx

from phederation.cache import BaseCache, with_cache
from phederation.cache.base import WithCache
from phederation.utils import NodeInfo, ObjectId
from phederation.utils.base import APDateTime, InstanceEndpoints, urljoin
from phederation.utils.exceptions import DiscoveryError, catch_exceptions
from phederation.utils.logging import configure_logger
from phederation.utils.settings import PhedSettings
from phederation.utils.version import PHEDERATION_USERAGENT


@dataclass
class InstanceInfo:
    """Information about the instance software. Shared across the network."""

    domain: str
    nodeinfo: None | NodeInfo
    software_version: None | str
    instance_actor: None | dict[str, Any]
    shared_inbox: None | ObjectId
    endpoints: dict[str, str] | InstanceEndpoints
    features: dict[str, bool]
    last_updated: APDateTime

    @staticmethod
    def from_dict(base_url: str, info_dict: dict[str, Any]):
        nodeinfo_dict = cast(dict[str, Any] | None, info_dict.get("nodeinfo"))
        if not nodeinfo_dict:
            return None
        nodeinfo = NodeInfo.deserialize(nodeinfo_dict)
        if not isinstance(nodeinfo, NodeInfo):
            return None
        instance_actor = cast(dict[str, Any] | None, info_dict.get("instance_actor"))
        shared_inbox = cast(ObjectId | None, info_dict.get("shared_inbox"))
        endpoints = cast(dict[str, str], info_dict.get("endpoints"))
        features = cast(dict[str, bool], info_dict.get("features"))

        return InstanceInfo(
            domain=urlparse(base_url).netloc,
            nodeinfo=nodeinfo,
            software_version=nodeinfo.software.get("version") if nodeinfo else None,
            instance_actor=instance_actor,
            shared_inbox=shared_inbox,
            endpoints=endpoints,
            features=features,
            last_updated=datetime.now(timezone.utc),
        )


class InstanceDiscovery(WithCache):
    """Federation instance discovery.

    Features:
     - Instance metadata discovery
     - WebFinger resolution
     - NodeInfo discovery
     - Actor discovery
    """

    def __init__(self, settings: PhedSettings, cache: BaseCache | None = None, request_timeout: int = 10):  # 1 hour
        """Initialize instance discovery.

        Args:
            settings (PhedSettings): To obtain the base_url of the instance.
            cache_ttl (int, optional): Time-to-live for the internal cache. Defaults to 3600.
            request_timeout (int, optional): How many seconds the discovery waits on remote instances until a timeout. Defaults to 10.
        """
        self.settings: PhedSettings = settings
        self.logger: Logger = configure_logger(__name__, prefix=self.settings.federation.logging_prefix)
        self.cache: BaseCache | None = cache
        self.timeout: int = request_timeout
        # 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.headers: dict[str, str] = {"User-Agent": PHEDERATION_USERAGENT, "Accept": "application/activity+json"}

    def is_local(self, url: str):
        return str(url).startswith(self.settings.domain.base_url)

    async def initialize(self) -> None:
        """Initialize discovery."""
        pass

    @catch_exceptions(DiscoveryError, "Failed instance discovery")
    @with_cache(__name__ + ".discover_instance")
    async def discover_instance(self, instance_base_url: str) -> InstanceInfo:
        """
        Discover instance information.

        Args:
            instance_base_url: Instance base url (scheme://netloc)

        Returns:
            InstanceInfo with complete instance data
        """

        # Discover instance components.
        # We first try to directly get the instance info directly, then individually try endpoints
        info = await self.discover_info(base_url=instance_base_url)
        if not info:
            nodeinfo = await self.discover_nodeinfo(base_url=instance_base_url)
            instance_actor = await self.discover_instance_actor(base_url=instance_base_url)
            endpoints = await self.discover_endpoints(base_url=instance_base_url)
            features = await self.discover_features(base_url=instance_base_url)

            shared_inbox = endpoints.get("shared_inbox", None)

            # Build instance info
            info = InstanceInfo(
                domain=urlparse(instance_base_url).netloc,
                nodeinfo=nodeinfo,
                software_version=nodeinfo.software.get("version") if nodeinfo else None,
                instance_actor=instance_actor,
                shared_inbox=shared_inbox if shared_inbox else None,
                endpoints=endpoints,
                features=features,
                last_updated=datetime.now(timezone.utc),
            )

        return info

    @catch_exceptions(DiscoveryError, "Failed instance info discovery")
    @with_cache(__name__ + ".discover_info")
    async def discover_info(self, base_url: str):
        well_known_url: ObjectId = urljoin(base_url, "/.well-known/instanceinfo")
        response = await self.requests.get(well_known_url, headers=self.headers)
        if response.status_code != 200:
            raise DiscoveryError(f"Response in discover_info not ok, status_code={response.status_code}, detail={response.content}")

        info_dict = cast(dict[str, Any], response.json())
        instance_info = InstanceInfo.from_dict(base_url=base_url, info_dict=info_dict)
        return instance_info

    @catch_exceptions(DiscoveryError, "Failed node info discovery")
    @with_cache(__name__ + ".discover_nodeinfo")
    async def discover_nodeinfo(self, base_url: str) -> None | NodeInfo:
        """
        Discover NodeInfo data.

        Implements NodeInfo 2.0 and 2.1 discovery.
        """
        # Try well-known location first
        well_known_url: ObjectId = urljoin(base_url, "/.well-known/nodeinfo")
        response = await self.requests.get(well_known_url, headers=self.headers)
        if response.status_code != 200:
            raise DiscoveryError(f"Response not ok, status_code={response.status_code}, detail={response.content}")

        links = cast(dict[str, Any], response.json())
        nodeinfo_url: str | None = None

        # Find highest supported version
        unresolved_links = cast(list[dict[str, str]], links.get("links", []))
        for link in unresolved_links:
            if link.get("rel") == "http://nodeinfo.diaspora.software/ns/schema/2.1":
                nodeinfo_url = link.get("href")
                break
            elif link.get("rel") == "http://nodeinfo.diaspora.software/ns/schema/2.0":
                nodeinfo_url = link.get("href")

        if not nodeinfo_url:
            raise DiscoveryError(f"Response not ok, nodeinfo_url=None, links={links}, detail={response.content}")

        # Fetch NodeInfo
        nodeinfo_response = await self.requests.get(nodeinfo_url, headers=self.headers)
        if nodeinfo_response.status_code != 200:
            raise DiscoveryError(
                f"Response not ok, nodeinfo_url get failed with status_code={nodeinfo_response.status_code}, detail={nodeinfo_response.content}"
            )

        data = cast(dict[str, Any], nodeinfo_response.json())
        nodeinfo = NodeInfo.deserialize(data)
        if not isinstance(nodeinfo, NodeInfo):
            raise DiscoveryError(f"NodeInfo could not be deserialized properly from data: {data}")
        return nodeinfo

    @catch_exceptions(DiscoveryError, "Failed instance actor discovery")
    async def discover_instance_actor(self, base_url: str) -> None | dict[str, Any]:
        """
        Discover an instance actor.

        Tries multiple common endpoints:
            - /actor
            - /instance
            - /instance/actor
            - /

        Args:
            base_url: the url of the instance, without the actor path added.
        """
        locations: list[ObjectId] = [urljoin(base_url, url) for url in ["/actor", "/instance", "/instance/actor", "/"]]

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

        for url in locations:
            try:
                response = await self.requests.get(url, headers=headers)
                if response.status_code == HTTPStatus.OK:
                    data = cast(dict[str, Any], response.json())
                    actor_type = data.get("type", None)
                    if isinstance(actor_type, str) and actor_type.lower() in ["application", "service"]:
                        return data
                else:
                    self.logger.warning(
                        f"Could not get a proper response from host instance during actor discovery: status_code={response.status_code}, detail={response.content}"
                    )
            except:
                continue

        return None

    async def discover_endpoints(self, base_url: str) -> dict[str, str]:
        """
        Discover instance endpoints.

        Finds common ActivityPub endpoints.

        Args:
            base_url: the url of the instance, without the actor path added.
        """
        endpoints: dict[str, str] = {}

        # Common endpoint paths
        paths = {
            "inbox": "/inbox",
            "outbox": "/outbox",
            "following": "/following",
            "followers": "/followers",
            "featured": "/featured",
            "shared_inbox": "/inbox",
            "nodeinfo": "/.well-known/nodeinfo",
            "webfinger": "/.well-known/webfinger",
        }

        for name, path in paths.items():
            url: ObjectId = urljoin(base_url, path)
            try:
                response = await self.requests.head(url, headers=self.headers)
                if response.status_code != HTTPStatus.NOT_FOUND:
                    endpoints[name] = url
            except Exception as e:
                self.logger.debug(f"Exception in discover_endpoints: {type(e).__name__}, {e}")
                continue

        return endpoints

    async def discover_features(self, base_url: str) -> dict[str, bool]:
        """
        Discover supported features.

        Checks for various federation features:
         - activitypub protocol
         - webfinger
         - nodeinfo
         - shared inbox
         - collections route

        Args:
            base_url: the url of the instance, without the actor path added.
        """
        features = {"activitypub": False, "webfinger": False, "nodeinfo": False, "shared_inbox": False, "collections": False, "media_proxy": False}

        # Check WebFinger
        try:
            webfinger_url = urljoin(base_url, f"/.well-known/webfinger?resource=acct:test@{base_url}")
            response = await self.requests.head(webfinger_url, headers=self.headers)
            features["webfinger"] = response.status_code != 404
        except:
            pass

        # Check NodeInfo
        try:
            nodeinfo_url = urljoin(base_url, "/.well-known/nodeinfo")
            response = await self.requests.head(nodeinfo_url, headers=self.headers)
            features["nodeinfo"] = response.status_code == 200
        except:
            pass

        # Check shared inbox
        try:
            inbox_url = urljoin(base_url, "/inbox")
            response = await self.requests.head(inbox_url, headers=self.headers)
            features["shared_inbox"] = response.status_code != 404
        except:
            pass

        # Check collections
        try:
            collections = ["/following", "/followers", "/featured"]
            check_paths: list[bool] = [await self._check_endpoint(base_url=base_url, path=path) for path in collections]
            features["collections"] = any(check_paths)
        except:
            pass

        # Check media proxy
        try:
            proxy_url = urljoin(base_url, "/proxy")
            response = await self.requests.head(proxy_url, headers=self.headers)
            features["media_proxy"] = response.status_code != 404
        except:
            pass

        # ActivityPub is supported if basic endpoints exist
        features["activitypub"] = features["shared_inbox"] or features["collections"]

        return features

    async def _check_endpoint(self, base_url: str, path: str) -> bool:
        """Check if endpoint exists."""
        try:
            url = urljoin(base_url, path)
            response = await self.requests.head(url, headers=self.headers)
            return response.status_code != 404
        except:
            return False

    async def webfinger(self, resource: str, domain: None | str = None) -> None | dict[str, Any]:
        """
        Perform WebFinger lookup.

        Args:
            resource: Resource to look up (acct: or https:)
            domain: Optional domain override

        Returns:
            WebFinger response data
        """
        try:
            if not domain:
                if resource.startswith("acct:"):
                    domain = resource.split("@")[1]
                else:
                    domain = urlparse(resource).netloc

            url = urljoin(domain, "/.well-known/webfinger?resource={resource}")

            response = await self.requests.get(url, headers=self.headers)
            if response.status_code != 200:
                return None

            return cast(dict[str, Any], response.json())

        except Exception as e:
            self.logger.error(f"WebFinger lookup failed for {resource}: {e}")
            return None

    def close(self) -> None:
        """Clean up resources."""
        pass
