# coding=utf-8
#
# The Qubes OS Project, http://www.qubes-os.org
#
# Copyright (C) 2023  Piotr Bartman <prbartman@invisiblethingslab.com>
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
# USA.
"""Direct communication with ctap device."""

import asyncio
import logging
import logging.handlers
import sys

from fido2.client import _ctap2client_err
from fido2.ctap import CtapError
from fido2.ctap1 import APDU, ApduError
from fido2.hid import CtapHidDevice

from qubesctap import const
from qubesctap.protocol import RequestWrapper, CborResponseWrapper


async def mux(untrusted_request, stream=None, devices=None,
              timeout=const.DEVICE_TIMEOUT):
    """Send request (APDU/CBOR) to all discovered devices and return one response.

    If a valid response came, return it.
    Else, if at least one APDU.USE_NOT_SATISFIED came, return that.
    Else, return some other response.

    If no devices, return None.
    """
    log = logging.getLogger('mux')

    if stream is None:
        stream = sys.stdout.buffer

    fuse = const.USER_TIMEOUT
    response = None

    for _ in range(fuse):
        if devices is None:
            _devices = list(CtapHidDevice.list_devices())
        else:
            _devices = devices

        response = await _mux(
            untrusted_request=untrusted_request,
            devices=_devices,
            timeout=timeout,
        )
        if response is not None:
            break

        # Don't block the event loop.
        await asyncio.sleep(1)
    else:
        log.warning("timeout: no device detected")
        response = CborResponseWrapper(CtapError(CtapError.ERR.TIMEOUT))

    stream.write(bytes(response))
    stream.close()

    return response


async def _mux(*, untrusted_request, devices, timeout):
    log = logging.getLogger('mux')

    pending = {
        asyncio.create_task(asyncio.to_thread(call_device, device, untrusted_request))
        for device in devices
    }
    log.debug('pending=%r', pending)

    if not pending:
        # no device plugged -- send a response as if the device wasn't touched,
        # but log a fat message, so there is a chance to debug it...
        log.debug('no device detected')
        return None

    response = None
    while pending:
        done, pending = await asyncio.wait(
            pending,
            timeout=timeout,
            return_when=asyncio.FIRST_COMPLETED,
        )
        log.debug('pending=%r done=%r', pending, done)

        for fut in done:
            try:
                result = fut.result()
            except asyncio.CancelledError:
                continue

            if result.is_ok:
                while pending:
                    pending.pop().cancel()
                return result

            if response is None \
                    or isinstance(result.data, ApduError) \
                    and result.data.code == APDU.USE_NOT_SATISFIED:
                response = result

    return response


def call_device(device, untrusted_request):
    """Send bytes to device and get wrapped response.

    The request is validated before being sent to the device.
    """
    log = logging.getLogger('mux.device')

    try:
        request = RequestWrapper.from_bytes(untrusted_request)
        log.debug("request: %s", bytes(request))

        try:
            response = request.execute(device)
        except CtapError as err:
            # pylint: disable=raise-missing-from
            raise _ctap2client_err(err)
    finally:
        device.close()

    log.debug('response %s', bytes(response))
    return response
