from ast import literal_eval
from http import HTTPStatus
import logging
from lclazy import LazyLoader
from lcrequests.exceptions import LCHTTPError
from typing import Any, Optional, TYPE_CHECKING
from urllib.parse import urlencode

from django.conf import settings
from rest_framework.test import APIClient

from console_base.typehints import CanonicalIDOrString
from console_base.websockets import LogToWS

if TYPE_CHECKING:
    from lcrequests import Response
    from lcrequests.tokens import PasetoToken
    import pylogcabin
    from pylogcabin import DrawBridgeAPI
else:
    pylogcabin = LazyLoader('pylogcabin', globals(), 'pylogcabin')

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
class ConsoleConnection(LogToWS):
    """
    Wrapper around Console API. If we ever want to add support
    for other services, such as Odoo, we'd create an OdooConnection
    with the same API as this one.
    """

    def __init__(
        self,
        url: str,
        port: int,
        signed_token_class: Optional['PasetoToken'] = None,
        token: str = '',
        **kwargs: Any,
    ) -> None:
        """

        :param url: Console URL
        :param port: Console http port, usually 443 or 1525
        :param signed_token_class: Preferred auth method - signed JWT is created for each request
        :param token: Fallback auth method when private key is not available
        :param kwargs: Any of optional data
        """
        kwargs.update({
            'publish_ws': True,
            'channel': 'ksync',
        })
        super().__init__(**kwargs)
        self.signed_token_class = signed_token_class
        self.token = token
        self.url = url if not url.endswith('/') else url[:-1]
        self.port = port
        self.server_connection = None

        if self.port and self.port != 443:
            self.hostname = f'{self.url}:{self.port}'
        else:
            self.hostname = self.url

    def name(self) -> str:
        if self.is_local():
            return 'Local Console'
        return 'Remote Console'

    def css_class(self) -> str:
        """
        Return CSS class for logline
        """
        if self.is_local():
            return 'download'
        return 'upload'

    def is_local(self) -> bool:
        """
        Is this the Local console or the Remote machine?

        Some operations should only be performed when the
        Remote Console is the Publisher.
        """
        return 'localhost' in self.url or '127.0.0.1' in self.url

    def connection(self, timeout: int = 30) -> 'DrawBridgeAPI':
        """
        Establish / return connection to Console
        """
        if not self.server_connection:
            self.server_connection = pylogcabin.DrawBridgeAPI(
                server=self.hostname,
                timeout=timeout,
                signed_token_class=self.signed_token_class,
                token=self.token,
            )
        return self.server_connection

    def canonical_ids(
        self, app: str, table: str, params: dict | None = None, **kwargs: Any
    ) -> dict:
        """
        Get list of Canonical IDs.

        :param app: Console app to load
        :param table: Console table / model to load
        :param params: Query parameters as dictionary
        """
        connection = self.connection()
        connection.load(app, table)

        try:
            return connection.canonical_ids(params=params, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            logger.error(
                'Canonical IDs failed for App: %s / Table: %s / Params: %s', app, table, params
            )
            logger.exception(e)
            return {}

    def count(self, app: str, table: str, params: dict | None = None, **kwargs: Any) -> dict:
        """
        Get count of records.

        :param app: Console app to load
        :param table: Console table / model to load
        :param params: Query parameters as dictionary
        """
        connection = self.connection()
        connection.load(app, table)

        try:
            return connection.count(params=params, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            logger.error(
                'Count view failed for App: %s / Table: %s / Params: %s', app, table, params
            )
            logger.exception(e)
            return {}

    def get(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        params: dict | None = None,
        **kwargs: Any,
    ) -> 'Response':
        """
        Get record data from console server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param cid: Optional Canonical ID of record to load
        :param params: Query parameters as dictionary
        """
        connection = self.connection()
        connection.load(app, table)

        try:
            return connection.get(cid=cid, params=params, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            if e.response.status_code != HTTPStatus.NOT_FOUND:
                logger.exception('%s - Unable to get %s in table %s', e, cid, table)
            return e.response

    def head(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        params: dict | None = None,
        **kwargs: Any,
    ) -> 'Response':
        """
        Verify record exists on console server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param cid: Optional Canonical ID of record to load
        :param params: Query parameters as dictionary
        """
        connection = self.connection()
        connection.load(app, table)

        try:
            return connection.head(cid=cid, params=params, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            logger.error(
                'HEAD method failed for App: %s / Table: %s / Params: %s', app, table, params
            )
            return e.response

    def list(self, app: str, table: str, params: dict | None = None, **kwargs: Any) -> dict:
        """
        Get list record data from console server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param params: Query parameters as dictionary
        """
        connection = self.connection()
        connection.load(app, table)
        params = params or {}
        if 'limit' not in params:
            params['limit'] = settings.API_PAGINATION_LIMIT

        try:
            return connection.list_page(params=params, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            logger.error(
                'List view failed for App: %s / Table: %s / Params: %s', app, table, params
            )
            logger.exception(e)
            return {}

    def get_field_errors(self, exception: LCHTTPError) -> dict:
        """
        Get dictionary of field errors like so:
        {"contact":["Object with cid=97fc1ed5-73d2-5021-9d51-07899f986270 does not exist."]}
        """
        try:
            return literal_eval(exception.response.text)
        except Exception as e:
            logger.exception('Unable to parse field errors: %s', e)
            return {'non_field_errors': exception.response.text}

    def bulk(
        self,
        app: str,
        table: str,
        params: Any = None,
        post_data: Any = None,
        **kwargs: Any,
    ) -> 'Response':
        """
        Write multiple rows of data to server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param params: Query parameters as dictionary
        :param post_data: Data dictionary to POST
        """
        connection = self.connection()
        connection.load(app, table)
        css_class = self.css_class()
        post_data = [post_data] if isinstance(post_data, dict) else post_data
        operation = 'update' if kwargs.get('method', 'PUT') == 'PUT' else 'delete'

        try:
            # return response and let caller handle publishing the results
            return connection.bulk(params=params, post_data=post_data, **kwargs)
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)

            status = e.response.status_code
            try:
                data = f'Data: {e.response.json()}'
            except Exception:
                data = ''

            if status == HTTPStatus.NOT_FOUND:
                data = 'Record not found'

            count = len(post_data)
            exact_msg = (
                f'Unable to {operation} {app}/{table} with {count} lines. Status: {status}. {data}'
            )

            logger.error(exact_msg)
            self.publish_line(line=exact_msg, extra_css=css_class)

            return e.response

    def update(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        params: dict | None = None,
        post_data: dict | None = None,
        **kwargs: Any,
    ) -> 'Response':
        """
        Write updated data to server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param cid: Optional Canonical ID of record to load
        :param params: Query parameters as dictionary
        :param post_data: Data dictionary to POST
        """
        connection = self.connection()
        connection.load(app, table)
        css_class = self.css_class()
        try:
            response = connection.put(cid=cid, params=params, post_data=post_data, **kwargs)
            self.publish_line(
                line=f'Updated Table: {table} in App: {app} with {post_data}',
                extra_css=css_class,
            )
            return response

        except pylogcabin.ConsoleAPIError as e:
            status = e.response.status_code
            exact_msg = f'Unable to PUT update {app}/{table} with {post_data}. Status: {status} '
            logger.exception(e)
            logger.error(exact_msg)
            self.publish_line(line=exact_msg, extra_css=css_class)
            for field, error in self.get_field_errors(e).items():
                msg = f'{field}: {error}'
                logger.error(msg)
                self.publish_line(line=msg, extra_css=css_class)

            self.raise_if_irrecoverable(e)

            return e.response

    def patch(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        params: dict | None = None,
        post_data: dict | None = None,
        **kwargs: Any,
    ) -> 'Response':
        """
        Write partially updated data to server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param cid: Optional Canonical ID of record to load
        :param params: Query parameters as dictionary
        :param post_data: Data dictionary to POST
        """
        connection = self.connection()
        connection.load(app, table)
        css_class = self.css_class()
        msg = f'{app}/{table} Record: {cid} with {post_data}'
        try:
            response = connection.patch(cid=cid, params=params, post_data=post_data, **kwargs)
            self.publish_line(line=f'Partially updated {msg}.', extra_css=css_class)
            return response

        except pylogcabin.ConsoleAPIError as e:
            status = e.response.status_code
            exact_msg = f'Unable to PATCH update {msg}. Status: {status} '
            logger.exception(e)
            logger.error(exact_msg)
            self.publish_line(line=exact_msg, extra_css=css_class)
            for field, error in self.get_field_errors(e).items():
                msg = f'{field}: {error}'
                logger.error(msg)
                self.publish_line(line=msg, extra_css=css_class)

            self.raise_if_irrecoverable(e)

            return e.response

    def delete(self, app: str, table: str, cid: CanonicalIDOrString) -> 'Response':
        """
        Delete record from server.

        :param app: Console app to load
        :param table: Console table / model to load
        :param cid: Canonical ID of record to delete
        """
        connection = self.connection()
        connection.load(app, table)
        try:
            resp = connection.delete(cid=cid)
            self.publish_line(line=f'Deleted {table} {cid}', extra_css=self.css_class())
            return resp
        except pylogcabin.ConsoleAPIError as e:
            self.raise_if_irrecoverable(e)
            logger.exception('%s - Unable to delete %s in table %s', e, cid, table)
            return e.response

    def raise_if_irrecoverable(self, e: 'pylogcabin.ConsoleAPIError') -> None:
        """
        Some errors are irrecoverable and no future requests
        should be made until the issue is resolved.
        """
        try:
            status_code = e.response.status_code
            if status_code != HTTPStatus.UNAUTHORIZED:
                return
        except AttributeError:
            pass

        raise e from None

    def __str__(self) -> str:
        return f'{self.__class__.__name__}: {self.hostname}'

    def __repr__(self) -> str:
        return f'{self.__class__.__name__}(token=<token>, url={self.url}, port={self.port})'


# ---------------------------------------------------------------------------
class ConsoleAPIClient(APIClient):
    """
    Rest Framework APIClient with methods written to be api-compatible
    with ConsoleConnection methods for handier unit testing.
    """

    def name(self) -> str:
        return 'Console Unittest Client'

    def css_class(self) -> str:
        """
        Return CSS class for logline
        """
        return 'download'

    def is_local(self) -> bool:
        return True

    def list(self, app: str, table: str, params: Any = None, **kwargs: Any) -> 'Response':
        return self.get(
            path=self.get_url(app, table, params),
            follow=kwargs.pop('follow', False),
        )

    def patch(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        params: dict | None = None,
        post_data: dict | None = None,
        **kwargs: Any,
    ) -> 'Response':
        subpath = kwargs.pop('subpath', '')
        return super().patch(
            path=self.get_url(app, table, cid, subpath, params),
            data=post_data,
            content_type=kwargs.pop('content_type', None),
            follow=kwargs.pop('follow', False),
        )

    def get_url(
        self,
        app: str,
        table: str,
        cid: CanonicalIDOrString = '',
        subpath: str = '',
        params: dict | None = None,
    ) -> str:
        """
        Generate path params, supporting custom API routes, such as
        /ksync/syncbatch/<cid>/run/
        /latchstring/user/<cid>/set_password/

        Also support dictionary of Query Params
        """
        url = f'/api/{app}/{table}/'
        if cid:
            url = f'{url}{cid}/'

        if subpath:
            url = f'{url}{subpath}/'

        if not params:
            return url

        return f'{url}?{urlencode(params)}'


__all__ = (
    'ConsoleConnection',
    'ConsoleAPIClient',
)
