import asyncio
import logging
import traceback
from asyncio import Queue, sleep, QueueEmpty
from time import monotonic
from typing import Any, Optional, Final, Tuple, Callable, Union

import aiohttp
from aiohttp import ContentTypeError
from aiohttp.client import ClientResponse, _RequestContextManager
from aiohttp.hdrs import METH_GET, METH_POST, METH_PUT, METH_DELETE
from aiohttp_sse_client import client as sse_client

import HABApp
import HABApp.core
import HABApp.openhab.events
from HABApp.core.asyncio import async_context
from HABApp.core.const.json import dump_json, load_json
from HABApp.core.logger import log_info, log_warning
from HABApp.core.wrapper import process_exception, ignore_exception
from HABApp.openhab.errors import OpenhabDisconnectedError, ExpectedSuccessFromOpenhab
from .http_connection_waiter import WaitBetweenConnects
from ...core.const.log import TOPIC_EVENTS
from ...core.lib import SingleTask

log = logging.getLogger('HABApp.openhab.connection')
log_events = logging.getLogger(f'{TOPIC_EVENTS}.openhab')


IS_ONLINE: bool = False
IS_READ_ONLY: bool = False


# HTTP options
HTTP_ALLOW_REDIRECTS: bool = True
HTTP_VERIFY_SSL: Optional[bool] = None
HTTP_SESSION: aiohttp.ClientSession = None

CONNECT_WAIT: WaitBetweenConnects = WaitBetweenConnects()

ON_CONNECTED: Callable = None
ON_DISCONNECTED: Callable = None


OH_3: bool = False


async def get(url: str, log_404=True, **kwargs: Any) -> ClientResponse:

    mgr = _RequestContextManager(
        HTTP_SESSION._request(METH_GET, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL, **kwargs)
    )
    return await check_response(mgr, log_404=log_404)


async def post(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]:

    if IS_READ_ONLY or not IS_ONLINE:
        return None

    mgr = _RequestContextManager(
        HTTP_SESSION._request(
            METH_POST, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL,
            data=data, json=json, **kwargs
        )
    )

    if data is None:
        data = json
    return await check_response(mgr, log_404=log_404, sent_data=data)


async def put(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]:

    if IS_READ_ONLY or not IS_ONLINE:
        return None

    mgr = _RequestContextManager(
        HTTP_SESSION._request(
            METH_PUT, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL,
            data=data, json=json, **kwargs
        )
    )

    if data is None:
        data = json
    return await check_response(mgr, log_404=log_404, sent_data=data)


async def delete(url: str, log_404=True, json=None, data=None, **kwargs: Any) -> Optional[ClientResponse]:

    if IS_READ_ONLY or not IS_ONLINE:
        return None

    mgr = _RequestContextManager(
        HTTP_SESSION._request(METH_DELETE, url, allow_redirects=HTTP_ALLOW_REDIRECTS, ssl=HTTP_VERIFY_SSL,
                              data=data, json=json, **kwargs)
    )

    if data is None:
        data = json
    return await check_response(mgr, log_404=log_404, sent_data=data)


def set_offline(log_msg=''):
    global IS_ONLINE

    if not IS_ONLINE:
        return None
    IS_ONLINE = False

    log.warning(f'Disconnected! {log_msg}')

    # cancel SSE listener
    TASK_SSE_LISTENER.cancel()
    TASK_TRY_CONNECT.cancel()

    ON_DISCONNECTED()

    TASK_TRY_CONNECT.start()


def is_disconnect_exception(e) -> bool:
    if not isinstance(e, (
            # aiohttp Exceptions
            aiohttp.ClientPayloadError, aiohttp.ClientConnectorError, aiohttp.ClientOSError,

            # aiohttp_sse_client Exceptions
            ConnectionRefusedError, ConnectionError, ConnectionAbortedError)):
        return False

    set_offline(str(e))
    return True


async def check_response(future: aiohttp.client._RequestContextManager, sent_data=None,
                         log_404=True, disconnect_on_error=False) -> ClientResponse:
    try:
        resp = await future
    except Exception as e:
        is_disconnect = is_disconnect_exception(e)
        log.log(logging.WARNING if is_disconnect else logging.ERROR, f'"{e}" ({type(e)})')
        if is_disconnect:
            raise OpenhabDisconnectedError()
        raise

    status = resp.status

    # Sometimes openHAB issues 404 instead of 500 during startup
    if disconnect_on_error and status >= 400:
        set_offline(f'Expected success but got status {status} for '
                    f'{str(resp.request_info.url).replace(HABApp.CONFIG.openhab.connection.url, "")}')
        raise ExpectedSuccessFromOpenhab()

    # Something went wrong - log error message
    log_msg = False
    if status >= 300:
        log_msg = True

        # possibility skip logging of 404
        if status == 404 and not log_404:
            log_msg = False

    if log_msg:
        # Log Error Message
        sent = '' if sent_data is None else f' {sent_data}'
        log.warning(f'Status {status} for {resp.request_info.method} {resp.request_info.url}{sent}')
        for line in str(resp).splitlines():
            log.debug(line)

    return resp


async def shutdown_connection():
    global HTTP_SESSION

    TASK_TRY_CONNECT.cancel()
    TASK_SSE_LISTENER.cancel()

    TASK_QUEUE_WORKER.cancel()
    TASK_QUEUE_WATCHER.cancel()

    await asyncio.sleep(0)

    # If we are already connected properly disconnect
    if HTTP_SESSION is not None:
        await HTTP_SESSION.close()
        HTTP_SESSION = None


async def setup_connection():
    global HTTP_SESSION, HTTP_VERIFY_SSL

    await shutdown_connection()

    config = HABApp.CONFIG.openhab
    url: str = config.connection.url
    user: str = config.connection.user
    password: str = config.connection.password

    # do not run without an url
    if not url:
        log_info(log, 'openHAB connection disabled (url missing)!')
        return None

    # do not run without user/pw - since OH3 mandatory
    is_token = user.startswith('oh.') or password.startswith('oh.')
    if not is_token and (not user or not password):
        log_info(log, 'openHAB connection disabled (user/password missing)!')
        return None

    if not config.connection.verify_ssl:
        HTTP_VERIFY_SSL = False
        log.info('Verify ssl set to False!')
    else:
        HTTP_VERIFY_SSL = None

    HTTP_SESSION = aiohttp.ClientSession(
        base_url=url,
        timeout=aiohttp.ClientTimeout(total=None),
        json_serialize=dump_json,
        auth=aiohttp.BasicAuth(user, password),
        read_bufsize=int(config.connection.buffer),
    )

    TASK_TRY_CONNECT.start()


async def start_sse_event_listener():

    async_context.set('SSE')

    try:
        # cache so we don't have to look up every event
        _load_json = load_json
        _see_handler = on_sse_event
        oh3 = OH_3

        async with sse_client.EventSource(url=f'/rest/events?topics={HABApp.CONFIG.openhab.connection.topic_filter}',
                                          session=HTTP_SESSION, ssl=HTTP_VERIFY_SSL) as event_source:
            async for event in event_source:

                e_str = event.data

                try:
                    e_json = _load_json(e_str)
                except (ValueError, TypeError):
                    log_events.warning(f'Invalid json: {e_str}')
                    continue

                # Alive event from openhab to detect dropped connections
                # -> Can be ignored on the HABApp side
                e_type = e_json.get('type')
                if e_type == 'ALIVE':
                    continue

                # Log raw sse event
                if log_events.isEnabledFor(logging.DEBUG):
                    log_events._log(logging.DEBUG, e_str, [])

                # With OH4 we have the ItemStateUpdatedEvent, so we can ignore the ItemStateEvent
                if not oh3 and e_type == 'ItemStateEvent':
                    continue

                # process
                _see_handler(e_json, oh3)
    except Exception as e:
        disconnect = is_disconnect_exception(e)
        lvl = logging.WARNING if disconnect else logging.ERROR
        log.log(lvl, f'SSE request Error: {e}')
        for line in traceback.format_exc().splitlines():
            log.log(lvl, line)

        # reconnect even if we have an unexpected error
        if not disconnect:
            set_offline(f'Uncaught error in process_sse_events: {e}')


QUEUE = Queue()


async def output_queue_listener():
    # clear Queue
    try:
        while True:
            await QUEUE.get_nowait()
    except QueueEmpty:
        pass

    while True:
        try:
            while True:
                item, state, is_cmd = await QUEUE.get()

                if not isinstance(state, str):
                    state = convert_to_oh_type(state)

                if is_cmd:
                    await post(f'/rest/items/{item:s}', data=state)
                else:
                    await put(f'/rest/items/{item:s}/state', data=state)
        except Exception as e:
            process_exception(output_queue_listener, e, logger=log)


@ignore_exception
async def output_queue_check_size():

    first_msg_at = 150

    upper = first_msg_at
    lower = -1
    last_info_at = first_msg_at // 2

    while True:
        await sleep(5)
        size = QUEUE.qsize()

        # small log msg
        if size > upper:
            upper = size * 2
            lower = size // 2
            log_warning(log, f'{size} messages in queue')
        elif size < lower:
            upper = max(size / 2, first_msg_at)
            lower = size // 2
            if lower <= last_info_at:
                lower = -1
                log_info(log, 'queue OK')
            else:
                log_info(log, f'{size} messages in queue')


def async_post_update(item, state: Any):
    QUEUE.put_nowait((item, state, False))


def async_send_command(item, state: Any):
    QUEUE.put_nowait((item, state, True))


async def async_get_uuid() -> str:
    resp = await get('/rest/uuid', log_404=False)
    return await resp.text(encoding='utf-8')


async def async_get_root() -> Optional[dict]:
    resp = await get('/rest/', log_404=False)
    if resp.status == 404:
        return None

    try:
        return await resp.json(loads=load_json, encoding='utf-8')
    except ContentTypeError:
        # during start up openHAB sends an empty response with a wrong
        # content type which causes the above error
        return None


async def async_get_system_info() -> dict:
    resp = await get('/rest/systeminfo', log_404=False)
    if resp.status == 404:
        return {}
    return await resp.json(loads=load_json, encoding='utf-8')


async def _start_level_reached() -> Tuple[bool, Union[None, int]]:
    start_level_min = HABApp.CONFIG.openhab.general.min_start_level

    system_info = await async_get_system_info()
    start_level_is = system_info.get('systemInfo', {}).get('startLevel')    # type: Optional[int]

    if start_level_is is None:
        return False, None

    return start_level_is >= start_level_min, start_level_is


WAITED_FOR_OPENHAB: bool = False


async def wait_for_min_start_level():
    global WAITED_FOR_OPENHAB

    level_reached, level = await _start_level_reached()
    if level_reached:
        return None

    WAITED_FOR_OPENHAB = True
    log.info('Waiting for openHAB startup to be complete')

    last_level: int = -100

    timeout_duration = 10 * 60
    timeout_start_at_level = 70
    timeout_timestamp = 0

    while not level_reached:
        await asyncio.sleep(1)

        level_reached, level = await _start_level_reached()

        # show start level change
        if last_level != level:
            if level is None:
                log.debug('Start level: not received!')
                level = -10
            else:
                log.debug(f'Start level: {level}')

            # Wait but start eventually because sometimes we have a thing that prevents the start level from advancing
            # This is a safety net, so we properly start e.g. after a power outage
            # When starting manually one should fix the blocking thing
            if level >= timeout_start_at_level:
                timeout_timestamp = monotonic()
                log.debug('Starting start level timeout')

        # timeout is running
        if timeout_timestamp and monotonic() - timeout_timestamp > timeout_duration:
            log.warning(f'Starting even though openHAB is not ready yet (start level: {level})')
            break

        # update last level!
        last_level = level

    log.info('openHAB startup complete')


async def try_connect():
    global IS_ONLINE, OH_3

    while True:
        try:
            # sleep before reconnect
            await CONNECT_WAIT.wait()

            log.debug('Trying to connect to OpenHAB ...')
            if (root := await async_get_root()) is None:
                root = {}

            # It's possible that we get status 4XX during startup and then the response is empty
            runtime_info = root.get('runtimeInfo')
            if not runtime_info:
                log.info('... offline!')
                continue

            log.info(f'Connected {"read only " if IS_READ_ONLY else ""}to OpenHAB '
                     f'version {runtime_info["version"]} ({runtime_info["buildString"]})')

            # todo: remove this 2023
            # Show warning (convenience)
            vers = tuple(map(int, runtime_info["version"].split('.')[:2]))
            if vers < (3, 3):
                log.warning('HABApp requires at least openHAB version 3.3!')

            if vers < (4, 0):
                OH_3 = True

            # wait for openhab startup to be complete
            await wait_for_min_start_level()
            break
        except Exception as e:
            if isinstance(e, (OpenhabDisconnectedError, ExpectedSuccessFromOpenhab)):
                log.info('... offline!')
            else:
                for line in traceback.format_exc().splitlines():
                    log.error(line)

    IS_ONLINE = True

    # start sse processing
    TASK_SSE_LISTENER.start()

    # output messages
    TASK_QUEUE_WORKER.start()
    TASK_QUEUE_WATCHER.start()

    ON_CONNECTED()
    return None


TASK_SSE_LISTENER: Final = SingleTask(start_sse_event_listener, 'SSE event listener')
TASK_TRY_CONNECT: Final = SingleTask(try_connect, 'Try OH connect')

TASK_QUEUE_WORKER: Final = SingleTask(output_queue_listener, 'OhQueue')
TASK_QUEUE_WATCHER: Final = SingleTask(output_queue_check_size, 'OhQueueSize')


def __load_cfg():
    global IS_READ_ONLY
    IS_READ_ONLY = HABApp.config.CONFIG.openhab.general.listen_only


# setup config
__load_cfg()
HABApp.config.CONFIG.subscribe_for_changes(__load_cfg)


# import it here otherwise we get cyclic imports
from HABApp.openhab.connection_handler.sse_handler import on_sse_event  # noqa: E402
from HABApp.openhab.connection_handler.func_async import convert_to_oh_type  # noqa: E402
