from http import HTTPStatus
from httpx._client import AsyncClient
from json import JSONDecodeError
from logging import Logger
from typing import Annotated, Any, TypeAlias, cast

from fastapi import HTTPException, Path, Request
from fastapi.security import OAuth2AuthorizationCodeBearer
import httpx
from keycloak import KeycloakOpenID
from keycloak.exceptions import KeycloakAuthenticationError, KeycloakPostError
from pydantic import (
    BaseModel,
    Field,
    PlainSerializer,
    PlainValidator,
    SecretStr,
    WithJsonSchema,
)
import requests

from phederation.models import APActor
from phederation.utils import ObjectId
from phederation.utils.base import actor_id_from_username, urljoin
from phederation.utils.exceptions import AuthenticationError, UserError
from phederation.utils.logging import configure_logger
from phederation.utils.settings import KeycloakSettings, PhedSettings

from .actors import ActorManager


def serialize_token(token: SecretStr | None):
    if not token:
        return ""
    return token.get_secret_value()


def deserialize_token(token: Any):  # pyright: ignore[reportAny]
    if isinstance(token, SecretStr):
        return token
    if isinstance(token, str):
        return SecretStr(token)
    return None


class TokenSchema(BaseModel):
    token: str


_schema_token = TokenSchema.model_json_schema() | {"token": "SecretStr"}

"""This allows to serialize the UserInfo class, so that it can be used in FastAPI routes.
It is a security concern, but better than making the access_token a string."""
SecretStrAnnotated: TypeAlias = Annotated[
    SecretStr | None, Field(validate_default=True), WithJsonSchema(_schema_token), PlainSerializer(serialize_token), PlainValidator(deserialize_token)
]


class UserInfo(BaseModel):
    preferred_username: str
    actor_id: ObjectId
    email: str | None = None
    full_name: str | None = None
    access_token: SecretStrAnnotated = None
    groups: list[str] | None = None


class Authentication:
    def __init__(self, settings: PhedSettings, actor_manager: ActorManager | None = None, keycloak_settings_client: KeycloakSettings | None = None):
        self.settings: PhedSettings = settings
        self.keycloak_settings: KeycloakSettings = KeycloakSettings()
        self.keycloak_openid: KeycloakOpenID = KeycloakOpenID(
            server_url=self.settings.domain.keycloak_hostname,
            realm_name=self.keycloak_settings.keycloak_realm,
            client_id=self.keycloak_settings.keycloak_client_id,
            client_secret_key=self.keycloak_settings.keycloak_client_secret.get_secret_value(),
            verify=True,
        )
        self.oauth2_scheme_code: OAuth2AuthorizationCodeBearer = OAuth2AuthorizationCodeBearer(
            tokenUrl=f"{self.settings.domain.keycloak_hostname}/realms/{self.keycloak_settings.keycloak_realm}/protocol/openid-connect/token",
            authorizationUrl=f"{self.settings.domain.keycloak_hostname}/realms/{self.keycloak_settings.keycloak_realm}/protocol/openid-connect/auth",
            refreshUrl=f"{self.settings.domain.keycloak_hostname}/realms/{self.keycloak_settings.keycloak_realm}/protocol/openid-connect/token",
            scopes={"openid": "Default openid scope"},
            auto_error=False,  # optional authentication, for routes where the result is "Public" items if somebody is not authenticated
        )
        if keycloak_settings_client:
            self.keycloak_settings = keycloak_settings_client
            self.keycloak_openid = KeycloakOpenID(
                server_url=settings.domain.keycloak_hostname,
                realm_name=keycloak_settings_client.keycloak_realm,
                client_id=keycloak_settings_client.keycloak_client_id,
                client_secret_key=keycloak_settings_client.keycloak_client_secret.get_secret_value(),
                verify=True,
            )
        self.actor_manager: ActorManager | None = actor_manager

        # this can be changed in unit tests to mock url requests
        self.requests: AsyncClient = httpx.AsyncClient()

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

    async def __call__(
        self,
        request: Request,
    ) -> UserInfo | None:
        """
        Authentication endpoint that requires a valid token for elevated access.
        """
        token = await self.oauth2_scheme_code(request)
        self.logger.debug(f"Calling authentication endpoint, token={"***" if token and len(token) > 0 else "(not set)"}")
        if not token:
            return None
        user_info = await self.verify_token(SecretStr(token))
        return user_info

    async def verify_local_username(
        self, username: Annotated[str, Path(description="Username of the actor on this instance, without the URL.")]
    ) -> APActor:
        """
        Checks if the actor with the given username is local to this instance.

        Args:
            username (str): username to verify.

        Returns:
            APActor object.

        Raises:
            UserError if the actor does not exist locally.
        """
        if not self.actor_manager:
            raise AuthenticationError(f"User '{username}' not found: actor_manager is not set (probably because this is not an instance)")

        actor_id = actor_id_from_username(self.settings.domain.base_url, username=username)
        if not actor_id:
            raise AuthenticationError(f"User '{username}' not found: actor_id is None")
        actor = await self.actor_manager.actor_from_storage(id=actor_id)
        if not actor:
            raise UserError(f"User with actor id '{actor_id}' not found on this instance")
        return actor

    def get_openid_config(self) -> dict[str, str]:
        return self.keycloak_openid.well_known()  # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]

    def authenticate_user(self, username: str, password: SecretStr, redirect_uri: str | None = None) -> SecretStr:
        """
        Authenticate the user using Keycloak and return an access token.
        """
        try:
            if not redirect_uri:
                redirect_uri = self.settings.domain.base_url
            token: dict[str, Any] | None = self.keycloak_openid.token(  # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
                username, password.get_secret_value(), redirect_uri=redirect_uri
            )
            if not token:
                raise KeycloakAuthenticationError("Unauthorized, token not available")
            access_token = token.get("access_token", None)
            if not isinstance(access_token, str):
                raise KeycloakAuthenticationError("Unauthorized, access_token not available")
            return SecretStr(access_token)
        except KeycloakAuthenticationError as e:
            self.logger.warning(f"Keycloak: login unsuccessful; KeycloakAuthenticationError error: {e}.")
            raise HTTPException(
                status_code=HTTPStatus.UNAUTHORIZED,
                detail="Unauthorized",
            )
        except KeycloakPostError as e:
            self.logger.warning(f"Keycloak: login unsuccessful; KeycloakPostError error: {e}.")
            raise HTTPException(
                status_code=HTTPStatus.BAD_REQUEST,
                detail="POST/Client error",
            )

    async def verify_token(self, token: SecretStr | None) -> UserInfo | None:
        """
        Verify the given token and return user information if it is valid, None if it is not.
        """
        try:
            if token is None:
                return None

            keycloak_url: str = self.settings.domain.keycloak_docker_hostname
            realm_name: str = self.keycloak_openid.realm_name
            headers = cast(dict[str, Any], self.keycloak_openid.connection.headers)
            headers["Authorization"] = f"Bearer {token.get_secret_value()}"
            complete_url = urljoin(keycloak_url, "realms", realm_name, "protocol/openid-connect/userinfo")

            response: requests.Response | httpx.Response
            try:
                self.logger.info(f"Sending token authorization to url {complete_url}")
                response = await self.requests.get(url=complete_url, headers=headers, follow_redirects=True)
            except (httpx.ConnectError, requests.ConnectionError) as e:
                self.logger.error(f"Could not connect to keycloak, error: {e}. Url: {complete_url}")
                return None
            if response.status_code == HTTPStatus.UNAUTHORIZED:
                return None

            try:
                user_info = cast(dict[str, Any], response.json())
                # user_info = self.keycloak_openid.userinfo(token) # this does not connect to the internal keycloak inside docker
            except JSONDecodeError as e:
                self.logger.error(
                    f"Authentication: JSONDecodeError, status: {response.status_code}",
                    extra={"status_code": response.status_code, "content": response.content.decode("utf-8")},
                )
                return None

            if not user_info:
                self.logger.error(f"Authentication: Invalid token.")
                return None

            if not "preferred_username" in user_info:
                self.logger.error(f"Authentication: Invalid data received from keycloak: preferred_username is not set (token={token}).")
                return None

            self.logger.debug(f"New user info received for user '{user_info.get("preferred_username")}'")
            preferred_username = user_info.get("preferred_username", None)
            if not isinstance(preferred_username, str):
                return None
            user_info = UserInfo(
                preferred_username=preferred_username,
                email=user_info.get("email"),
                full_name=user_info.get("name") or user_info.get("full_name"),
                groups=user_info.get("groups"),
                access_token=token,
                actor_id=actor_id_from_username(base_url=self.settings.domain.base_url, username=preferred_username),
            )

            if self.actor_manager and self.settings.security.create_actor_from_token:
                # check if the user already exists. If not, create them locally.
                username = user_info.preferred_username
                try:
                    _ = await self.verify_local_username(username=username)
                except UserError:
                    self.logger.info(f"Creating local user '{username}' because they successfully logged into keycloak")
                    await self.actor_manager.create_actor_from_username(username)
            
            return user_info
        except KeycloakAuthenticationError as e:
            self.logger.warning(f"Keycloak: login unsuccessful; error: {e}.")
            return None
