# Copyright 2010 Jacob Kaplan-Moss
# Copyright 2011 Nebula, Inc.
#
#    Licensed under the Apache License, Version 2.0 (the "License"); you may
#    not use this file except in compliance with the License. You may obtain
#    a copy of the License at
#
#         http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing, software
#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
#    License for the specific language governing permissions and limitations
#    under the License.
"""
Exception definitions.
"""

import json
import re
from typing import Any, TYPE_CHECKING, Union
import warnings

import requests
from requests import exceptions as _rex

from openstack import warnings as os_warnings

if TYPE_CHECKING:
    from openstack import resource


class SDKException(Exception):
    """The base exception class for all exceptions this library raises."""

    def __init__(self, message: str | None = None, extra_data: Any = None):
        self.message = self.__class__.__name__ if message is None else message
        self.extra_data = extra_data
        super().__init__(self.message)


class EndpointNotFound(SDKException):
    """A mismatch occurred between what the client and server expect."""

    def __init__(self, message: str | None = None):
        super().__init__(message)


class InvalidResponse(SDKException):
    """The response from the server is not valid for this request."""

    def __init__(self, message: str | None = None):
        super().__init__(message)


class InvalidRequest(SDKException):
    """The request to the server is not valid."""

    def __init__(self, message: str | None = None):
        super().__init__(message)


class HttpException(SDKException, _rex.HTTPError):
    """The base exception for all HTTP error responses."""

    source: str
    status_code: int | None

    def __init__(
        self,
        message: str | None = 'Error',
        response: requests.Response | None = None,
        http_status: int | None = None,
        details: str | None = None,
        request_id: str | None = None,
    ):
        if http_status is not None:
            warnings.warn(
                "The 'http_status' parameter is unnecessary and will be "
                "removed in a future release",
                os_warnings.RemovedInSDK50Warning,
            )

        if request_id is not None:
            warnings.warn(
                "The 'request_id' parameter is unnecessary and will be "
                "removed in a future release",
                os_warnings.RemovedInSDK50Warning,
            )

        if not message:
            if response is not None:
                message = f"{self.__class__.__name__}: {response.status_code}"
            else:
                message = f"{self.__class__.__name__}: Unknown error"
            status = (
                response.status_code
                if response is not None
                else 'Unknown error'
            )
            message = f'{self.__class__.__name__}: {status}'

        # Call directly rather than via super to control parameters
        SDKException.__init__(self, message=message)
        _rex.HTTPError.__init__(self, message, response=response)

        if response is not None:
            self.request_id = response.headers.get('x-openstack-request-id')
            self.status_code = response.status_code
        else:
            self.request_id = request_id
            self.status_code = http_status
        self.details = details
        self.url = (self.request and self.request.url) or None
        self.method = (self.request and self.request.method) or None
        self.source = "Server"
        if self.status_code is not None and (400 <= self.status_code < 500):
            self.source = "Client"

    def __str__(self) -> str:
        # 'Error' is the default value for self.message. If self.message isn't
        # 'Error', then someone has set a more informative error message
        # and we should use it. If it is 'Error', then we should construct a
        # better message from the information we do have.
        if not self.url or self.message == 'Error':
            return self.message
        if self.url:
            remote_error = f"{self.source} Error for url: {self.url}"
            if self.details:
                remote_error += ', '
        if self.details:
            remote_error += str(self.details)

        return f"{super().__str__()}: {remote_error}"


class BadRequestException(HttpException):
    """HTTP 400 Bad Request."""


class NotFoundException(HttpException):
    """HTTP 404 Not Found."""


class ForbiddenException(HttpException):
    """HTTP 403 Forbidden Request."""


class ConflictException(HttpException):
    """HTTP 409 Conflict."""


class PreconditionFailedException(HttpException):
    """HTTP 412 Precondition Failed."""


class MethodNotSupported(SDKException):
    """The resource does not support this operation type."""

    def __init__(
        self,
        resource: Union['resource.Resource', type['resource.Resource']],
        method: str,
    ):
        # This needs to work with both classes and instances.
        try:
            name = resource.__name__
        except AttributeError:
            name = resource.__class__.__name__

        message = (
            f'The {method} method is not supported for '
            f'{resource.__module__}.{name}'
        )
        super().__init__(message=message)


class DuplicateResource(SDKException):
    """More than one resource exists with that name."""


class ResourceTimeout(SDKException):
    """Timeout waiting for resource."""


class ResourceFailure(SDKException):
    """General resource failure."""


class InvalidResourceQuery(SDKException):
    """Invalid query params for resource."""


def _extract_message(obj: Any) -> str | None:
    if isinstance(obj, dict):
        # Most of services: compute, network
        if obj.get('message'):
            return str(obj['message'])
        # Ironic starting with Stein
        elif obj.get('faultstring'):
            return str(obj['faultstring'])
    elif isinstance(obj, str):
        # Ironic before Stein has double JSON encoding, nobody remembers why.
        try:
            obj = json.loads(obj)
        except Exception:  # noqa: S110
            # This is best effort. Ignore any errors.
            pass
        else:
            return _extract_message(obj)
    return None


def raise_from_response(
    response: requests.Response,
    error_message: str | None = None,
) -> None:
    """Raise an instance of an HTTPException based on keystoneauth response."""
    if response.status_code < 400:
        return

    cls: type[SDKException]
    if response.status_code == 400:
        cls = BadRequestException
    elif response.status_code == 403:
        cls = ForbiddenException
    elif response.status_code == 404:
        cls = NotFoundException
    elif response.status_code == 409:
        cls = ConflictException
    elif response.status_code == 412:
        cls = PreconditionFailedException
    else:
        cls = HttpException

    details = None
    content_type = response.headers.get('content-type', '')
    if response.content and 'application/json' in content_type:
        # Iterate over the nested objects to retrieve "message" attribute.
        # TODO(shade) Add exception handling for times when the content type
        # is lying.

        try:
            content = response.json()
            messages = [_extract_message(obj) for obj in content.values()]
            if not any(messages):
                # Exception dict may be the root dict in projects that use WSME
                messages = [_extract_message(content)]
            # Join all of the messages together nicely and filter out any
            # objects that don't have a "message" attr.
            details = '\n'.join(msg for msg in messages if msg)
        except Exception:
            details = response.text
    elif response.content and 'text/html' in content_type:
        messages = []
        for line in response.text.splitlines():
            message = re.sub(r'<.+?>', '', line.strip())
            if message not in messages:
                messages.append(message)

        # Return joined string separated by colons.
        details = ': '.join(msg for msg in messages if msg)

    if not details:
        details = response.reason if response.reason else response.text

    raise cls(
        message=error_message,
        response=response,
        details=details,
    )


class ConfigException(SDKException):
    """Something went wrong with parsing your OpenStack Config."""


class NotSupported(SDKException):
    """Request cannot be performed by any supported API version."""


class ValidationException(SDKException):
    """Validation failed for resource."""


class ServiceDisabledException(ConfigException):
    """This service is disabled for reasons."""


class ServiceDiscoveryException(SDKException):
    """The service cannot be discovered."""


# Backwards compatibility
OpenStackCloudException = SDKException
ResourceNotFound = NotFoundException
