import platform
import threading
import time
import traceback
from typing import Any, Dict, List, Optional, Tuple, Union

from ._version import __author__, __version__  # noqa: F401
from .helpers import (MONITOR_MANUFACTURER_CODES,  # noqa: F401
                      ScreenBrightnessError, flatten_list)


def get_brightness(
    display: Optional[Union[int, str]] = None,
    method: Optional[str] = None,
    verbose_error: bool = False
) -> List[int]:
    '''
    Returns the current display brightness

    Args:
        display (str or int): the specific display to query
        method (str): the method to use to get the brightness. See `get_methods` for
            more info on available methods
        verbose_error (bool): controls the level of detail in the error messages

    Returns:
        list: a list of integers (from 0 to 100), each integer being the
            percentage brightness of a display (invalid displays may return None)

    Example:
        ```python
        import screen_brightness_control as sbc

        # get the current screen brightness (for all detected displays)
        current_brightness = sbc.get_brightness()

        # get the brightness of the primary display
        primary_brightness = sbc.get_brightness(display=0)

        # get the brightness of the secondary display (if connected)
        secondary_brightness = sbc.get_brightness(display=1)
        ```
    '''
    return __brightness(display=display, method=method, meta_method='get', verbose_error=verbose_error)


def set_brightness(
    value: Union[int, float, str],
    display: Optional[Union[str, int]] = None,
    method: Optional[str] = None,
    force: bool = False,
    verbose_error: bool = False,
    no_return: bool = True
) -> Union[None, List[int]]:
    '''
    Sets the screen brightness

    Args:
        value (int or float or str): a value 0 to 100. This is a percentage or a string as '+5' or '-5'
        display (int or str): the specific display to adjust
        method (str): the method to use to set the brightness. See `get_methods` for
            more info on available methods
        force (bool): [*Linux Only*] if False the brightness will never be set lower than 1.
            This is because on most displays a brightness of 0 will turn off the backlight.
            If True, this check is bypassed
        verbose_error (bool): boolean value controls the amount of detail error messages will contain
        no_return (bool): if False, this function returns new brightness (by calling `get_brightness`).
            If True, this function returns None (default behaviour).

    Returns:
        None: if the `no_return` kwarg is `True`
        list: a list of integers (from 0 to 100), each integer being the
            percentage brightness of a display (invalid displays may return None)

    Example:
        ```python
        import screen_brightness_control as sbc

        # set brightness to 50%
        sbc.set_brightness(50)

        # set brightness to 0%
        sbc.set_brightness(0, force=True)

        # increase brightness by 25%
        sbc.set_brightness('+25')

        # decrease brightness by 30%
        sbc.set_brightness('-30')

        # set the brightness of display 0 to 50%
        sbc.set_brightness(50, display=0)
        ```
    '''
    if isinstance(value, str) and ('+' in value or '-' in value):
        value = int(float(value))
        monitors = filter_monitors(display=display, method=method)
        if len(monitors) > 1:
            output = []
            for monitor in monitors:
                identifier = Monitor.get_identifier(monitor)[1]
                output.append(
                    set_brightness(
                        get_brightness(display=identifier)[0] + value,
                        display=identifier,
                        force=force,
                        verbose_error=verbose_error,
                        no_return=no_return
                    )
                )
            return flatten_list(output)

        value += get_brightness(display=Monitor.get_identifier(monitors[0])[1])[0]
    else:
        value = int(float(str(value)))

    # make sure value is within bounds
    value = max(min(100, value), 0)

    if platform.system() == 'Linux' and not force:
        value = max(1, value)

    return __brightness(
        value, display=display, method=method,
        meta_method='set', no_return=no_return,
        verbose_error=verbose_error
    )


def fade_brightness(
    finish: Union[int, str],
    start: Optional[Union[int, str]] = None,
    interval: float = 0.01,
    increment: int = 1,
    blocking: bool = True,
    **kwargs
) -> Union[List[threading.Thread], List[int]]:
    '''
    A function to somewhat gently fade the screen brightness from `start` to `finish`

    Args:
        finish (int or str): the brightness level to end up on
        start (int or str): where the brightness should fade from.
            If not specified the function starts from the current screen brightness
        interval (float or int): the time delay between each step in brightness
        increment (int): the amount to change the brightness by per step
        blocking (bool): whether this should occur in the main thread (`True`) or a new daemonic thread (`False`)
        kwargs (dict): passed directly to `set_brightness`.
            Any compatible kwargs are passed to `filter_monitors` as well. (eg: display, method...)

    Returns:
        list: list of `threading.Thread` objects if `blocking == False`,
            otherwise it returns the result of `get_brightness()`

    Example:
        ```python
        import screen_brightness_control as sbc

        # fade brightness from the current brightness to 50%
        sbc.fade_brightness(50)

        # fade the brightness from 25% to 75%
        sbc.fade_brightness(75, start=25)

        # fade the brightness from the current value to 100% in steps of 10%
        sbc.fade_brightness(100, increment=10)

        # fade the brightness from 100% to 90% with time intervals of 0.1 seconds
        sbc.fade_brightness(90, start=100, interval=0.1)

        # fade the brightness to 100% in a new thread
        sbc.fade_brightness(100, blocking=False)
        ```
    '''
    def fade(start, finish, increment, monitor):
        for i in range(min(start, finish), max(start, finish), increment):
            val = i
            if start > finish:
                val = start - (val - finish)
            monitor.set_brightness(val, no_return=True)
            time.sleep(interval)

        if monitor.get_brightness() != finish:
            monitor.set_brightness(finish, no_return=True)

    def err_msg(e):
        return f'\n\tfilter_monitors -> {type(e).__name__}: {e}'

    try:
        # make sure only compatible kwargs are passed to filter_monitors
        available_monitors = filter_monitors(
            **{k: v for k, v in kwargs.items() if k in (
                'display', 'haystack', 'method', 'include'
            )}
        )
    except (LookupError, ValueError) as e:
        if not platform.system() == 'Linux':
            raise ScreenBrightnessError(err_msg(e)) from e

        method = kwargs.get('method', None)
        if isinstance(e, LookupError) and method is None:
            # if a user ONLY has XBacklight installed and no method was specified
            # then filter_monitors will raise a LookupError. In this case, default to using
            # linux.XBacklight method
            available_monitors = [_OS_MODULE.XBacklight]
        elif isinstance(e, ValueError) and method.lower() == 'xbacklight':
            # low level `list_monitors_info` will raise a ValueError if 'xbacklight' is passed as the method
            # because xbacklight cannot query individual monitor information
            available_monitors = [_OS_MODULE.XBacklight]
        else:
            raise ScreenBrightnessError(err_msg(e)) from e

    except Exception as e:
        raise ScreenBrightnessError(err_msg(e)) from e

    threads = []
    for i in available_monitors:
        try:
            if platform.system() == 'Linux' and i == _OS_MODULE.XBacklight:
                monitor = i
            else:
                monitor = Monitor(i)

            # same effect as monitor.is_active()
            current = monitor.get_brightness()
            st, fi = start, finish
            # convert strings like '+5' to an actual brightness value
            if isinstance(fi, str):
                if "+" in fi or "-" in fi:
                    fi = current + int(float(fi))
            if isinstance(st, str):
                if "+" in st or "-" in st:
                    st = current + int(float(st))

            st = current if st is None else st
            # make sure both values are within the correct range
            fi = min(max(int(float(fi)), 0), 100)
            st = min(max(int(float(st)), 0), 100)

            t1 = threading.Thread(target=fade, args=(st, fi, increment, monitor))
            t1.start()
            threads.append(t1)
        except Exception:
            pass

    if not blocking:
        return threads

    for t in threads:
        t.join()
    return get_brightness(**kwargs)


def list_monitors_info(method: Optional[str] = None, allow_duplicates: bool = False) -> List[dict]:
    '''
    list detailed information about all monitors that are controllable by this library

    Args:
        method (str): the method to use to list the available monitors. See `get_methods` for
            more info on available methods
        allow_duplicates (bool): whether to filter out duplicate displays or not

    Returns:
        list: list of dictionaries

    Example:
        ```python
        import screen_brightness_control as sbc
        monitors = sbc.list_monitors_info()
        for monitor in monitors:
            print('=======================')
            # the manufacturer name plus the model
            print('Name:', monitor['name'])
            # the general model of the display
            print('Model:', monitor['model'])
            # the serial of the display
            print('Serial:', monitor['serial'])
            # the name of the brand of the monitor
            print('Manufacturer:', monitor['manufacturer'])
            # the 3 letter code corresponding to the brand name, EG: BNQ -> BenQ
            print('Manufacturer ID:', monitor['manufacturer_id'])
            # the index of that display FOR THE SPECIFIC METHOD THE DISPLAY USES
            print('Index:', monitor['index'])
            # the method this monitor can be addressed by
            print('Method:', monitor['method'])
            # the EDID string associated with that monitor
            print('EDID:', monitor['edid'])
        ```
    '''
    return _OS_MODULE.list_monitors_info(method=method, allow_duplicates=allow_duplicates)


def list_monitors(method: Optional[str] = None) -> List[str]:
    '''
    List the names of all detected monitors

    Args:
        method (str): the method to use to list the available monitors. See `get_methods` for
            more info on available methods

    Returns:
        list: list of strings

    Example:
        ```python
        import screen_brightness_control as sbc
        monitor_names = sbc.list_monitors()
        # eg: ['BenQ GL2450H', 'Dell U2211H']
        ```
    '''
    return [i['name'] for i in list_monitors_info(method=method)]


def get_methods() -> Dict[str, object]:
    '''
    Returns all available brightness method names and their associated classes.

    Returns:
        dict: keys are the method names. This is what you would use
            if a function has a `method` kwarg.
            Values are the classes themselves

    Example:
        ```python
        import screen_brightness_control as sbc

        all_methods = sbc.get_methods()

        for method_name, method_class in all_methods.items():
            print('Method:', method_name)
            print('Class:', method_class)
            print('Associated monitors:', sbc.list_monitors(method=method_name))
        ```
    '''
    if platform.system() == 'Windows':
        methods = (_OS_MODULE.WMI, _OS_MODULE.VCP)
    else:  # linux
        methods = (
            _OS_MODULE.SysFiles, _OS_MODULE.I2C,
            _OS_MODULE.XRandr, _OS_MODULE.DDCUtil,
            _OS_MODULE.Light
        )

    return {i.__name__.lower(): i for i in methods}


class Monitor():
    '''A class to manage a single monitor and its relevant information'''
    def __init__(self, display: Union[int, str, dict]):
        '''
        Args:
            display (int or str or dict): the index/name/model name/serial/edid
                of the display you wish to control. Is passed to `filter_monitors`
                to decide which display to use.

        Raises:
            LookupError: if a matching display could not be found
            TypeError: if the given display type is not int or str

        Example:
            ```python
            import screen_brightness_control as sbc

            # create a class for the primary monitor and then a specifically named monitor
            primary = sbc.Monitor(0)
            benq_monitor = sbc.Monitor('BenQ GL2450H')

            # check if the benq monitor is the primary one
            if primary.serial == benq_monitor.serial:
                print('BenQ GL2450H is the primary display')
            else:
                print('The primary display is', primary.name)

            # this class can also be accessed like a dictionary
            print(primary['name'])
            print(benq_monitor['name'])
            ```
        '''
        monitors_info = list_monitors_info(allow_duplicates=True)
        if isinstance(display, dict):
            if display in monitors_info:
                info = display
            else:
                info = filter_monitors(
                    display=self.get_identifier(display),
                    haystack=monitors_info
                )[0]
        else:
            info = filter_monitors(display=display, haystack=monitors_info)[0]

        # make a copy so that we don't alter the dict in-place
        info = info.copy()

        self.serial: str = info.pop('serial')
        '''the serial number of the display or (if serial is not available) an ID assigned by the OS'''
        self.name: str = info.pop('name')
        '''the monitors manufacturer name plus its model'''
        self.method = info.pop('method')
        '''the method by which this monitor can be addressed.
        This will be a class from either the windows or linux sub-module'''
        self.manufacturer: str = info.pop('manufacturer')
        '''the name of the brand of the monitor'''
        self.manufacturer_id: str = info.pop('manufacturer_id')
        '''the 3 letter manufacturing code corresponding to the manufacturer name'''
        self.model: str = info.pop('model')
        '''the general model of the display'''
        self.index: int = info.pop('index')
        '''the index of the monitor FOR THE SPECIFIC METHOD THIS MONITOR USES.'''
        self.edid: str = info.pop('edid')
        '''a unique string returned by the monitor that contains its DDC capabilities, serial and name'''

        # this assigns any extra info that is returned to this class
        # eg: the 'interface' key in XRandr monitors on Linux
        for key, value in info.items():
            if value is not None:
                setattr(self, key, value)

    def __getitem__(self, item: Any) -> Any:
        return getattr(self, item)

    def get_identifier(self, monitor: dict = None) -> Tuple[str, Union[int, str]]:
        '''
        Returns the piece of information used to identify this monitor.
        Will iterate through the EDID, serial, name and index and return the first
        value that is not equal to None

        Args:
            monitor (dict): extract an identifier from this dict instead of the monitor class

        Returns:
            tuple: the name of the property returned and the value of said property.
                EG: `('serial', '123abc...')` or `('name', 'BenQ GL2450H')`

        Example:
            ```python
            import screen_brightness_control as sbc
            primary = sbc.Monitor(0)
            print(primary.get_identifier())  # eg: ('serial', '123abc...')

            secondary = sbc.list_monitors_info()[1]
            print(primary.get_identifier(monitor=secondary))  # eg: ('serial', '456def...')

            # you can also use the class uninitialized
            print(sbc.Monitor.get_identifier(secondary))  # eg: ('serial', '456def...')
            ```
        '''
        if monitor is None:
            monitor = self

        for key in ('edid', 'serial', 'name', 'index'):
            value = monitor[key]
            if value is not None:
                return key, value

    def set_brightness(self, value: int, no_return: bool = True) -> Union[None, int]:
        '''
        Sets the brightness for this display. See `set_brightness` for the full docs

        Args:
            value (int): the brightness value to set the display to (from 0 to 100)
            no_return (bool): if true, this function returns `None`
                Otherwise it returns the result of `Monitor.get_brightness`

        Returns:
            None: if `no_return==True`
            int: from 0 to 100

        Example:
            ```python
            import screen_brightness_control as sbc

            # set the brightness of the primary monitor to 50%
            primary = sbc.Monitor(0)
            primary.set_brightness(50)
            ```
        '''
        # refresh display info, in case another display has been unplugged or something
        # which would change the index of this display
        self.get_info()
        value = max(0, min(value, 100))
        self.method.set_brightness(value, display=self.index)
        if no_return:
            return None
        return self.get_brightness()

    def get_brightness(self) -> int:
        '''
        Returns the brightness of this display.

        Returns:
            int: from 0 to 100

        Example:
            ```python
            import screen_brightness_control as sbc

            # get the brightness of the primary monitor
            primary = sbc.Monitor(0)
            primary_brightness = primary.get_brightness()
            ```
        '''
        # refresh display info, in case another display has been unplugged or something
        # which would change the index of this display
        self.get_info()
        return self.method.get_brightness(display=self.index)[0]

    def fade_brightness(self, *args, **kwargs) -> Union[threading.Thread, int]:
        '''
        Fades the brightness for this display. See `fade_brightness` for the full docs

        Args:
            args (tuple): passed directly to `fade_brightness`
            kwargs (dict): passed directly to `fade_brightness`.
                The `display` and `method` kwargs are always
                overwritten.

        Returns:
            threading.Thread: if the the blocking kwarg is False
            int: if the blocking kwarg is True

        Example:
            ```python
            import screen_brightness_control as sbc

            # fade the brightness of the primary monitor to 50%
            primary = sbc.Monitor(0)
            primary.fade_brightness(50)
            ```
        '''
        # refresh display info, in case another display has been unplugged or something
        # which would change the index of this display
        self.get_info(refresh=False)
        kwargs['display'] = self.index
        # the reason we override the method kwarg here is that
        # the 'index' is method specific and `fade_brightness`
        # is a top-level function. `self.set_brightness` and `self.get_brightness`
        # call directly to the method so they don't need this step
        kwargs['method'] = self.method.__name__.lower()

        brightness = fade_brightness(*args, **kwargs)
        # fade_brightness will call the top-level get_brightness
        # function, which will return list OR int
        if isinstance(brightness, list):
            return brightness[0]
        return brightness

    def get_info(self, refresh: bool = True) -> dict:
        '''
        Returns all known information about this monitor instance

        Args:
            refresh (bool): whether to refresh the information
                or to return the cached version

        Returns:
            dict

        Example:
            ```python
            import screen_brightness_control as sbc

            # initialize class for primary monitor
            primary = sbc.Monitor(0)
            # get the info
            info = primary.get_info()
            ```
        '''
        if not refresh:
            return vars(self)

        identifier = self.get_identifier()

        if identifier is not None:
            # refresh the info we have on this monitor
            info = filter_monitors(display=identifier[1], method=self.method.__name__)[0]
            for key, value in info.items():
                if value is not None:
                    setattr(self, key, value)

        return vars(self)

    def is_active(self) -> bool:
        '''
        Attempts to retrieve the brightness for this display. If it works the display is deemed active

        Returns:
            bool: True means active, False means inactive

        Example:
            ```python
            import screen_brightness_control as sbc

            primary = sbc.Monitor(0)
            if primary.is_active():
                primary.set_brightness(50)
            ```
        '''
        try:
            self.get_brightness()
            return True
        except Exception:
            return False


def filter_monitors(
    display: Optional[Union[int, str]] = None,
    haystack: Optional[List[dict]] = None,
    method: Optional[str] = None,
    include: List[str] = []
) -> List[dict]:
    '''
    Searches through the information for all detected displays
    and attempts to return the info matching the value given.
    Will attempt to match against index, name, model, edid, method and serial

    Args:
        display (str or int): the display you are searching for.
            Can be serial, name, model number, edid string or index of the display
        haystack (list): the information to filter from.
            If this isn't set it defaults to the return of `list_monitors_info`
        method (str): the method the monitors use. See `get_methods` for
            more info on available methods
        include (list): extra fields of information to sort by

    Raises:
        TypeError: if the display kwarg is not an int, str or None
        LookupError: if the display, does not have a match

    Returns:
        list: list of dicts

    Example:
        ```python
        import screen_brightness_control as sbc

        search = 'GL2450H'
        match = sbc.filter_displays(search)
        print(match)
        # EG output: [{'name': 'BenQ GL2450H', 'model': 'GL2450H', ... }]
        ```
    '''
    # if we have been provided with a list of monitors to sift through then use that
    # otherwise, get the info ourselves
    if haystack:
        monitors_with_duplicates = haystack
        if method is not None:
            monitors_with_duplicates = [i for i in haystack if method.lower() == i['method'].__name__.lower()]
    else:
        monitors_with_duplicates = list_monitors_info(method=method, allow_duplicates=True)

    if display is not None and type(display) not in (str, int):
        raise TypeError(f'display kwarg must be int or str, not "{type(display).__name__}"')

    # This loop does two things: 1. Filters out duplicate monitors and 2. Matches the display kwarg (if applicable)
    unique_identifiers = []
    monitors = []
    for monitor in monitors_with_duplicates:
        # find a valid identifier for a monitor, excluding any which are equal to None
        added = False
        for identifier in ['edid', 'serial', 'name', 'model'] + include:
            if monitor.get(identifier, None) is not None:
                # check we haven't already added the monitor
                if monitor[identifier] not in unique_identifiers:
                    # check if the display kwarg (if str) matches this monitor
                    if monitor[identifier] == display or not isinstance(display, str):
                        # if valid and monitor[identifier] not in unique_identifiers:
                        if not added:
                            monitors.append(monitor)
                            unique_identifiers.append(monitor[identifier])
                            added = True

                        # if the display kwarg is an integer and we are currently at that index
                        if isinstance(display, int) and len(monitors) - 1 == display:
                            return [monitor]
                        if added:
                            break
                else:
                    # if we have already added a monitor with the same identifier
                    # then any info matching this monitor will match the other one
                    # so exit the checking now
                    break

    # if no monitors matched the query OR if display kwarg was an int
    # if the latter and we made it this far then the int was out of range
    if monitors == [] or isinstance(display, int):
        msg = 'no monitors found'
        if display is not None:
            msg += f' with name/serial/model/edid/index of {repr(display)}'
        if method is not None:
            msg += f' with method of "{method}"'
        raise LookupError(msg)

    return monitors


def __brightness(
    *args, display=None, method=None, meta_method='get', no_return=False,
    verbose_error=False, **kwargs
):
    '''Internal function used to get/set brightness'''

    def format_exc(name, e):
        errors.append((
            name, e.__class__.__name__,
            traceback.format_exc() if verbose_error else e
        ))

    output = []
    errors = []
    method = method.lower() if isinstance(method, str) else method

    try:
        monitors = filter_monitors(display=display, method=method)
    except (LookupError, ValueError) as e:
        if platform.system() == 'Linux' and display is None and method in ('xbacklight', None):
            try:
                if meta_method == 'set':
                    _OS_MODULE.XBacklight.set_brightness(*args)
                output.append(_OS_MODULE.XBacklight.get_brightness())
            except Exception as e:
                format_exc('XBacklight', e)
        else:
            format_exc('filter_monitors', e)
    except Exception as e:
        format_exc('filter_monitors', e)
    else:
        for monitor in monitors:
            try:
                if meta_method == 'set':
                    monitor['method'].set_brightness(*args, display=monitor['index'], **kwargs)
                    if no_return:
                        output.append(None)
                        continue

                output.append(monitor['method'].get_brightness(display=monitor['index'], **kwargs))
            except Exception as e:
                output.append(None)
                format_exc(monitor, e)

    output = flatten_list(output)

    if output:
        output_is_none = set(output) == {None}
        if (
            # can't have None output if we are trying to get the brightness
            (meta_method == 'get' and not output_is_none)
            or (
                # if we are setting the brightness then we CAN have a None output
                # but only if no_return is True.
                meta_method == 'set'
                and ((no_return and output_is_none) or not output_is_none)
            )
        ):
            return None if no_return else output

    # if the function hasn't returned then it has failed
    msg = '\n'
    if errors:
        for monitor, exc_name, exc in errors:
            if isinstance(monitor, str):
                msg += f'\t{monitor}'
            else:
                msg += f'\t{monitor["name"]} ({monitor["serial"]})'
            msg += f' -> {exc_name}: '
            msg += str(exc).replace('\n', '\n\t\t') + '\n'
    else:
        msg += '\tno valid output was received from brightness methods'

    raise ScreenBrightnessError(msg)


if platform.system() == 'Windows':
    from . import windows
    _OS_MODULE = windows
elif platform.system() == 'Linux':
    from . import linux
    _OS_MODULE = linux
elif platform.system() == 'Darwin':
    raise NotImplementedError('MAC is not yet supported')
else:
    raise NotImplementedError(f'{platform.system()} is not yet supported')
