# Original work Copyright 2016 Google Inc. All Rights Reserved.
#
# 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.
#
# Modified work Copyright 2020 Yubico AB. All Rights Reserved.
# This file, with modifications, is licensed under the above Apache License.

from __future__ import annotations

import ctypes
import ctypes.util
import logging
import threading
from queue import Empty, Queue

from .base import FIDO_USAGE, FIDO_USAGE_PAGE, CtapHidConnection, HidDescriptor

logger = logging.getLogger(__name__)

# Constants

HID_DEVICE_PROPERTY_VENDOR_ID = b"VendorID"
HID_DEVICE_PROPERTY_PRODUCT_ID = b"ProductID"
HID_DEVICE_PROPERTY_PRODUCT = b"Product"
HID_DEVICE_PROPERTY_SERIAL_NUMBER = b"SerialNumber"
HID_DEVICE_PROPERTY_PRIMARY_USAGE = b"PrimaryUsage"
HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE = b"PrimaryUsagePage"
HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE = b"MaxInputReportSize"
HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE = b"MaxOutputReportSize"
HID_DEVICE_PROPERTY_REPORT_ID = b"ReportID"


# Declare C types
class _CFType(ctypes.Structure):
    pass


class _CFString(_CFType):
    pass


class _CFSet(_CFType):
    pass


class _IOHIDManager(_CFType):
    pass


class _IOHIDDevice(_CFType):
    pass


class _CFRunLoop(_CFType):
    pass


class _CFAllocator(_CFType):
    pass


CF_SET_REF = ctypes.POINTER(_CFSet)
CF_STRING_REF = ctypes.POINTER(_CFString)
CF_TYPE_REF = ctypes.POINTER(_CFType)
CF_RUN_LOOP_REF = ctypes.POINTER(_CFRunLoop)
CF_RUN_LOOP_RUN_RESULT = ctypes.c_int32
CF_ALLOCATOR_REF = ctypes.POINTER(_CFAllocator)
CF_DICTIONARY_REF = ctypes.c_void_p
CF_MUTABLE_DICTIONARY_REF = ctypes.c_void_p
CF_TYPE_ID = ctypes.c_ulong
CF_INDEX = ctypes.c_long
CF_TIME_INTERVAL = ctypes.c_double
CF_STRING_ENCODING = ctypes.c_uint32
CF_STRING_BUILTIN_ENCODINGS_UTF8 = 134217984
IO_RETURN = ctypes.c_uint
IO_HID_REPORT_TYPE = ctypes.c_uint
IO_OPTION_BITS = ctypes.c_uint
IO_OBJECT_T = ctypes.c_uint
MACH_PORT_T = ctypes.c_uint
IO_SERVICE_T = IO_OBJECT_T
IO_REGISTRY_ENTRY_T = IO_OBJECT_T

IO_HID_MANAGER_REF = ctypes.POINTER(_IOHIDManager)
IO_HID_DEVICE_REF = ctypes.POINTER(_IOHIDDevice)

IO_HID_REPORT_CALLBACK = ctypes.CFUNCTYPE(
    None,
    ctypes.py_object,
    IO_RETURN,
    ctypes.c_void_p,
    IO_HID_REPORT_TYPE,
    ctypes.c_uint32,
    ctypes.POINTER(ctypes.c_uint8),
    CF_INDEX,
)
IO_HID_CALLBACK = ctypes.CFUNCTYPE(None, ctypes.py_object, IO_RETURN, ctypes.c_void_p)

# Define C constants
K_CF_NUMBER_SINT32_TYPE = 3
K_CF_ALLOCATOR_DEFAULT = None

K_IO_MASTER_PORT_DEFAULT = 0
K_IO_HID_REPORT_TYPE_OUTPUT = 1
K_IO_RETURN_SUCCESS = 0

K_CF_RUN_LOOP_RUN_STOPPED = 2
K_CF_RUN_LOOP_RUN_TIMED_OUT = 3
K_CF_RUN_LOOP_RUN_HANDLED_SOURCE = 4

# Load relevant libraries
# NOTE: find_library doesn't currently work on Big Sur, requiring the hardcoded paths
iokit = ctypes.cdll.LoadLibrary(
    ctypes.util.find_library("IOKit")
    or "/System/Library/Frameworks/IOKit.framework/IOKit"
)
cf = ctypes.cdll.LoadLibrary(
    ctypes.util.find_library("CoreFoundation")
    or "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
)

# Exported constants
K_CF_RUNLOOP_DEFAULT_MODE = CF_STRING_REF.in_dll(cf, "kCFRunLoopDefaultMode")

# Declare C function prototypes
cf.CFSetGetValues.restype = None
cf.CFSetGetValues.argtypes = [CF_SET_REF, ctypes.POINTER(ctypes.c_void_p)]
cf.CFStringCreateWithCString.restype = CF_STRING_REF
cf.CFStringCreateWithCString.argtypes = [
    ctypes.c_void_p,
    ctypes.c_char_p,
    ctypes.c_uint32,
]
cf.CFStringGetCString.restype = ctypes.c_bool
cf.CFStringGetCString.argtypes = [
    CF_TYPE_REF,
    ctypes.c_char_p,
    CF_INDEX,
    CF_STRING_ENCODING,
]
cf.CFGetTypeID.restype = CF_TYPE_ID
cf.CFGetTypeID.argtypes = [CF_TYPE_REF]
cf.CFNumberGetTypeID.restype = CF_TYPE_ID
cf.CFStringGetTypeID.restype = CF_TYPE_ID
cf.CFNumberGetValue.restype = ctypes.c_int
cf.CFRunLoopGetCurrent.restype = CF_RUN_LOOP_REF
cf.CFRunLoopGetCurrent.argtypes = []
cf.CFRunLoopRunInMode.restype = CF_RUN_LOOP_RUN_RESULT
cf.CFRunLoopRunInMode.argtypes = [CF_STRING_REF, CF_TIME_INTERVAL, ctypes.c_bool]
cf.CFRelease.restype = IO_RETURN
cf.CFRelease.argtypes = [CF_TYPE_REF]

iokit.IOObjectRelease.argtypes = [IO_OBJECT_T]

iokit.IOHIDManagerCreate.restype = IO_HID_MANAGER_REF
iokit.IOHIDManagerCreate.argtypes = [CF_ALLOCATOR_REF, IO_OPTION_BITS]
iokit.IOHIDManagerCopyDevices.restype = CF_SET_REF
iokit.IOHIDManagerCopyDevices.argtypes = [IO_HID_MANAGER_REF]
iokit.IOHIDManagerSetDeviceMatching.restype = None
iokit.IOHIDManagerSetDeviceMatching.argtypes = [IO_HID_MANAGER_REF, CF_TYPE_REF]

iokit.IORegistryEntryIDMatching.restype = CF_MUTABLE_DICTIONARY_REF
iokit.IORegistryEntryIDMatching.argtypes = [ctypes.c_uint64]
iokit.IORegistryEntryGetRegistryEntryID.restype = IO_RETURN
iokit.IORegistryEntryGetRegistryEntryID.argtypes = [
    IO_REGISTRY_ENTRY_T,
    ctypes.POINTER(ctypes.c_uint64),
]

iokit.IOHIDDeviceCreate.restype = IO_HID_DEVICE_REF
iokit.IOHIDDeviceCreate.argtypes = [CF_ALLOCATOR_REF, IO_SERVICE_T]
iokit.IOHIDDeviceClose.restype = IO_RETURN
iokit.IOHIDDeviceClose.argtypes = [IO_HID_DEVICE_REF, ctypes.c_uint32]
iokit.IOHIDDeviceScheduleWithRunLoop.restype = None
iokit.IOHIDDeviceScheduleWithRunLoop.argtypes = [
    IO_HID_DEVICE_REF,
    CF_RUN_LOOP_REF,
    CF_STRING_REF,
]
iokit.IOHIDDeviceUnscheduleFromRunLoop.restype = None
iokit.IOHIDDeviceUnscheduleFromRunLoop.argtypes = [
    IO_HID_DEVICE_REF,
    CF_RUN_LOOP_REF,
    CF_STRING_REF,
]
iokit.IOHIDDeviceGetProperty.restype = CF_TYPE_REF
iokit.IOHIDDeviceGetProperty.argtypes = [IO_HID_DEVICE_REF, CF_STRING_REF]
iokit.IOHIDDeviceSetReport.restype = IO_RETURN
iokit.IOHIDDeviceSetReport.argtypes = [
    IO_HID_DEVICE_REF,
    IO_HID_REPORT_TYPE,
    CF_INDEX,
    ctypes.c_void_p,
    CF_INDEX,
]
iokit.IOServiceGetMatchingService.restype = IO_SERVICE_T
iokit.IOServiceGetMatchingService.argtypes = [MACH_PORT_T, CF_DICTIONARY_REF]


def _hid_read_callback(
    read_queue, result, sender, report_type, report_id, report, report_length
):
    """Handles incoming IN report from HID device."""
    del result, sender, report_type, report_id  # Unused by the callback function

    read_queue.put(ctypes.string_at(report, report_length))


# C wrapper around ReadCallback()
# Declared in this scope so it doesn't get GC-ed
REGISTERED_READ_CALLBACK = IO_HID_REPORT_CALLBACK(_hid_read_callback)


def _hid_removal_callback(hid_device, result, sender):
    del result, sender
    cf.CFRunLoopStop(hid_device.run_loop_ref)


REMOVAL_CALLBACK = IO_HID_CALLBACK(_hid_removal_callback)


def _dev_read_thread(hid_device):
    """Binds a device to the thread's run loop, then starts the run loop.

    Args:
    hid_device: The MacOsHidDevice object

    The HID manager requires a run loop to handle Report reads. This thread
    function serves that purpose.
    """

    # Schedule device events with run loop
    hid_device.run_loop_ref = cf.CFRunLoopGetCurrent()
    if not hid_device.run_loop_ref:
        logger.error("Failed to get current run loop")
        return

    iokit.IOHIDDeviceScheduleWithRunLoop(
        hid_device.handle, hid_device.run_loop_ref, K_CF_RUNLOOP_DEFAULT_MODE
    )

    iokit.IOHIDDeviceRegisterRemovalCallback(
        hid_device.handle, REMOVAL_CALLBACK, ctypes.py_object(hid_device)
    )

    max_retries = 2  # Maximum number of run loop retries
    retries = 0

    while retries < max_retries:
        # Run the run loop
        run_loop_run_result = cf.CFRunLoopRunInMode(
            K_CF_RUNLOOP_DEFAULT_MODE,
            4,
            True,  # Timeout in seconds
        )  # Return after source handled

        received_data = not hid_device.read_queue.empty()
        if run_loop_run_result == K_CF_RUN_LOOP_RUN_HANDLED_SOURCE:
            if received_data:
                # Return when data has been received
                break
            else:
                # Retry running the run loop if data has not been received yet
                logger.debug("Read queue empty after HANDLE_SOURCE, attempting retry")
                retries += 1
        else:
            # log any unexpected run loop exit
            logger.error("Unexpected run loop exit code: %d", run_loop_run_result)
            break

    # Unschedule from run loop
    iokit.IOHIDDeviceUnscheduleFromRunLoop(
        hid_device.handle, hid_device.run_loop_ref, K_CF_RUNLOOP_DEFAULT_MODE
    )


class MacCtapHidConnection(CtapHidConnection):
    def __init__(self, descriptor):
        self.descriptor = descriptor
        self.handle = _handle_from_path(descriptor.path)

        # Open device
        result = iokit.IOHIDDeviceOpen(self.handle, 0)
        if result != K_IO_RETURN_SUCCESS:
            raise OSError(f"Failed to open device for communication: {result}")

        # Create read queue
        self.read_queue: Queue = Queue()

        # Create and start read thread
        self.run_loop_ref = None

        # Register read callback
        self.in_report_buffer = (ctypes.c_uint8 * descriptor.report_size_in)()
        iokit.IOHIDDeviceRegisterInputReportCallback(
            self.handle,
            self.in_report_buffer,
            self.descriptor.report_size_in,
            REGISTERED_READ_CALLBACK,
            ctypes.py_object(self.read_queue),
        )

    def close(self):
        iokit.IOHIDDeviceRegisterInputReportCallback(
            self.handle,
            self.in_report_buffer,
            self.descriptor.report_size_in,
            ctypes.cast(0, IO_HID_REPORT_CALLBACK),
            None,
        )

    def write_packet(self, data):
        result = iokit.IOHIDDeviceSetReport(
            self.handle,
            K_IO_HID_REPORT_TYPE_OUTPUT,
            0,
            data,
            len(data),
        )

        # Non-zero status indicates failure
        if result != K_IO_RETURN_SUCCESS:
            raise OSError(f"Failed to write report to device: {result}")

    def read_packet(self):
        try:
            return self.read_queue.get(False)
        except Empty:
            read_thread = threading.Thread(target=_dev_read_thread, args=(self,))
            read_thread.start()
            read_thread.join()
            try:
                return self.read_queue.get(False)
            except Empty:
                raise OSError("Failed reading a response")


def get_int_property(dev, key):
    """Reads int property from the HID device."""
    cf_key = cf.CFStringCreateWithCString(None, key, 0)
    type_ref = iokit.IOHIDDeviceGetProperty(dev, cf_key)
    cf.CFRelease(cf_key)
    if not type_ref:
        raise ValueError(f"Property '{key}' not found")

    if cf.CFGetTypeID(type_ref) != cf.CFNumberGetTypeID():
        raise OSError(f"Expected number type, got {cf.CFGetTypeID(type_ref)}")

    out = ctypes.c_int32()
    if not cf.CFNumberGetValue(type_ref, K_CF_NUMBER_SINT32_TYPE, ctypes.byref(out)):
        raise OSError(f"Failed to read property '{key}'")

    return out.value


def get_string_property(dev, key):
    """Reads string property from the HID device."""
    cf_key = cf.CFStringCreateWithCString(None, key, 0)
    type_ref = iokit.IOHIDDeviceGetProperty(dev, cf_key)
    cf.CFRelease(cf_key)
    if not type_ref:
        return None

    if cf.CFGetTypeID(type_ref) != cf.CFStringGetTypeID():
        raise OSError(f"Expected string type, got {cf.CFGetTypeID(type_ref)}")

    out = ctypes.create_string_buffer(128)
    ret = cf.CFStringGetCString(
        type_ref, out, ctypes.sizeof(out), CF_STRING_BUILTIN_ENCODINGS_UTF8
    )
    if not ret:
        return None

    try:
        return out.value.decode("utf-8") or None
    except UnicodeDecodeError:
        return None


def get_device_id(handle):
    """Obtains the unique IORegistry entry ID for the device.

    Args:
    handle: reference to the device

    Returns:
    A unique ID for the device, obtained from the IO Registry
    """
    # Obtain device entry ID from IO Registry
    io_service_obj = iokit.IOHIDDeviceGetService(handle)
    entry_id = ctypes.c_uint64()
    result = iokit.IORegistryEntryGetRegistryEntryID(
        io_service_obj, ctypes.byref(entry_id)
    )
    if result != K_IO_RETURN_SUCCESS:
        raise OSError(f"Failed to obtain IORegistry entry ID: {result}")

    return entry_id.value


def _handle_from_path(path):
    # Resolve the path to device handle
    entry_id = ctypes.c_uint64(int(path))
    matching_dict = iokit.IORegistryEntryIDMatching(entry_id)
    device_entry = iokit.IOServiceGetMatchingService(
        K_IO_MASTER_PORT_DEFAULT, matching_dict
    )
    if not device_entry:
        raise OSError(f"Device ID {path} does not match any HID device on the system")

    return iokit.IOHIDDeviceCreate(K_CF_ALLOCATOR_DEFAULT, device_entry)


def open_connection(descriptor):
    return MacCtapHidConnection(descriptor)


def _get_descriptor_from_handle(handle):
    usage_page = get_int_property(handle, HID_DEVICE_PROPERTY_PRIMARY_USAGE_PAGE)
    usage = get_int_property(handle, HID_DEVICE_PROPERTY_PRIMARY_USAGE)
    if usage_page == FIDO_USAGE_PAGE and usage == FIDO_USAGE:
        device_id = get_device_id(handle)
        vid = get_int_property(handle, HID_DEVICE_PROPERTY_VENDOR_ID)
        pid = get_int_property(handle, HID_DEVICE_PROPERTY_PRODUCT_ID)
        product = get_string_property(handle, HID_DEVICE_PROPERTY_PRODUCT)
        serial = get_string_property(handle, HID_DEVICE_PROPERTY_SERIAL_NUMBER)
        size_in = get_int_property(handle, HID_DEVICE_PROPERTY_MAX_INPUT_REPORT_SIZE)
        size_out = get_int_property(handle, HID_DEVICE_PROPERTY_MAX_OUTPUT_REPORT_SIZE)
        return HidDescriptor(
            str(device_id), vid, pid, size_in, size_out, product, serial
        )
    raise ValueError("Not a CTAP device")


def get_descriptor(path):
    return _get_descriptor_from_handle(_handle_from_path(path))


def list_descriptors():
    # Init a HID manager
    hid_mgr = iokit.IOHIDManagerCreate(None, 0)
    if not hid_mgr:
        raise OSError("Unable to obtain HID manager reference")
    try:
        iokit.IOHIDManagerSetDeviceMatching(hid_mgr, None)

        # Get devices from HID manager
        device_set_ref = iokit.IOHIDManagerCopyDevices(hid_mgr)
        if not device_set_ref:
            raise OSError("Failed to obtain devices from HID manager")
        try:
            num = iokit.CFSetGetCount(device_set_ref)
            devices = (IO_HID_DEVICE_REF * num)()
            iokit.CFSetGetValues(device_set_ref, devices)

            # Retrieve and build descriptor dictionaries for each device
            descriptors = []
            for handle in devices:
                try:
                    descriptor = _get_descriptor_from_handle(handle)
                    descriptors.append(descriptor)
                except ValueError:
                    continue  # Not a CTAP device, ignore it
            return descriptors
        finally:
            cf.CFRelease(device_set_ref)
    finally:
        cf.CFRelease(hid_mgr)
