"""
Control screen layout.

This modules allows you to handle your screens outputs directly from your bar!
    - Detect and propose every possible screen combinations
    - Switch between combinations using click events and mouse scroll
    - Activate the screen or screen combination on a single click
    - It will detect any newly connected or removed screen automatically

For convenience, this module also proposes some added features:
    - Dynamic parameters for POSITION and WORKSPACES assignment (see below)
    - Automatic fallback to a given screen or screen combination when no more
        screen is available (handy for laptops)
    - Automatically apply this screen combination on start: no need for xorg!
    - Automatically move workspaces to screens when they are available
    - Define your own subset of output combinations to use

Configuration parameters:
    cache_timeout: how often to (re)detect the outputs (default 10)
    command: a custom command to be run after display configuration changes
        (default None)
    fallback: when the current output layout is not available anymore,
        fallback to this layout if available. This is very handy if you
        have a laptop and switched to an external screen for presentation
        and want to automatically fallback to your laptop screen when you
        disconnect the external screen. (default True)
    fixed_width: show output as fixed width (default True)
    force_on_change: switch display layout to the leftmost combination mode
        of the given list whenever it is available. The combination modes are
        checked from left (high priority) to right (less priority) until
        one matches.
        Example:
            We have a laptop with internal screen and we are often moving from
            our desk where another screen is available. We want the layout to
            follow our changes so that we do not have to switch manually.
            So whenever we plug at our desk, we want the second monitor to be
            used, and whenever we go away we want everything back on the laptop
            screen automatically:
            ```
            force_on_change = ["eDP1+DP1", "eDP1"]
            ```
        NOTES:
            Click controls will override `force_on_change` until the layout
            changes in the background so you can still manually control your
            layout changes on the bar.
            Use the `force_on_start` to handle initial layout setup on module
            startup along with this feature to benefit from fully dynamic and
            automated changes of screen layouts.
        (default [])
    force_on_start: switch to the given combination mode if available
        when the module starts (saves you from having to configure xorg)
        (default None)
    format: display format for xrandr
        (default '{output}')
    hide_if_single_combination: hide if only one combination is available
        (default False)
    icon_clone: icon used to display a 'clone' combination
        (default '=')
    icon_extend: icon used to display a 'extend' combination
        (default '+')
    on_udev_drm: dynamic variable to watch for `drm` udev subsystem events to
        trigger specified action.
        (default 'refresh_and_freeze')
    output_combinations: string used to define your own subset of output
        combinations to use, instead of generating every possible combination
        automatically. Provide the values in the format that this module uses,
        splitting the combinations using '|' character.
        The combinations will be rotated in the exact order as you listed them.
        When an output layout is not available any more, the configurations
        are automatically filtered out.
        Example:
            Assuming the default values for `icon_clone` and `icon_extend`
            are used, and assuming you have two screens 'eDP1' and 'DP1', the
            following setup will reduce the number of output combinations
            from four (every possible one) down to two.
            ```
            output_combinations = "eDP1|eDP1+DP1"
            ```
        (default None)

Dynamic configuration parameters:
    <OUTPUT>_icon: use this icon instead of OUTPUT name as text
        Example: DP1_icon = "🖵"
    <OUTPUT>_pos: apply the given position to the OUTPUT
        Example: DP1_pos = "-2560x0"
        Example: DP1_pos = "above eDP1"
        Example: DP1_pos = "below eDP1"
        Example: DP1_pos = "left-of LVDS1"
        Example: DP1_pos = "right-of eDP1"
    <OUTPUT>_workspaces: comma separated list of workspaces to move to
        the given OUTPUT when it is activated
        Example: DP1_workspaces = "1,2,3"
    <OUTPUT>_rotate: rotate the output as told
        Example: DP1_rotate = "left"
    <OUTPUT>_mode: define the mode (resolution) for the output
                   if not specified use --auto : preferred mode
        Example: eDP1_mode = "2560x1440"
    <OUTPUT>_primary: apply the primary to the OUTPUT
        Example: DP1_primary = True

Format placeholders:
    {output} xrandr output

Color options:
    color_bad: Displayed layout unavailable
    color_degraded: Using a fallback layout
    color_good: Displayed layout active

Notes:
    Some days are just bad days. Running `xrandr --query` command can
    cause unexplainable brief screen freezes due to an overall combination
    of computer hardware, installed software, your choice of linux distribution,
    and/or some other unknown factors such as recent system updates.

    Configuring `cache_timeout` with a different number, eg `3600` (an hour)
    or `-1` (runs once) can be used to remedy this issue. See issue #580.

Examples:
```
# start with a preferable setup
xrandr {
    force_on_start = "eDP1+DP1"
    DP1_pos = "left-of eDP1"
    VGA_workspaces = "7"
}
```

@author ultrabug

SAMPLE OUTPUT
{'color': '#00FF00', 'full_text': 'LVDS1+DP1'}

mirror
{'full_text': 'LVDS1=DP1'}

single_1
{'full_text': u'LVDS1'}

single_2
{'full_text': 'DP1'}
"""

from collections import OrderedDict, deque
from itertools import combinations
from time import sleep


class Py3status:
    """"""

    # available configuration parameters
    cache_timeout = 10
    command = None
    fallback = True
    fixed_width = True
    force_on_change = []
    force_on_start = None
    format = "{output}"
    hide_if_single_combination = False
    icon_clone = "="
    icon_extend = "+"
    on_udev_drm = "refresh_and_freeze"
    output_combinations = None

    class Meta:
        deprecated = {
            "rename": [
                {
                    "param": "format_clone",
                    "new": "icon_clone",
                    "msg": "obsolete parameter use `icon_clone`",
                },
                {
                    "param": "format_extend",
                    "new": "icon_extend",
                    "msg": "obsolete parameter use `icon_extend`",
                },
            ]
        }

    def post_config_hook(self):
        """
        Initialization
        """
        self._no_force_on_change = True
        self.active_comb = None
        self.active_layout = None
        self.active_mode = "extend"
        self.displayed = None
        self.max_width = 0
        self.layout = None

    def _get_layout(self):
        """
        Get the outputs layout from xrandr and try to detect the
        currently active layout as best as we can on start.
        """
        connected = list()
        active_layout = list()
        disconnected = list()
        layout = OrderedDict({"connected": OrderedDict(), "disconnected": OrderedDict()})
        self.old_layout = self.layout or layout

        current = self.py3.command_output("xrandr")
        for line in current.splitlines():
            try:
                s = line.split(" ")
                infos = line[line.find("(") :]
                if s[1] == "connected":
                    output, state, mode = s[0], s[1], None
                    for index, x in enumerate(s[2:], 2):
                        if "x" in x and "+" in x:
                            mode = x
                            active_layout.append(output)
                            infos = line[line.find(s[index + 1]) :]
                            break
                        elif "(" in x:
                            break
                    connected.append(output)
                elif s[1] == "disconnected":
                    output, state, mode = s[0], s[1], None
                    disconnected.append(output)
                else:
                    continue
            except Exception as err:
                self.py3.log(f'xrandr error="{err}"')
            else:
                layout[state][output] = {"infos": infos, "mode": mode, "state": state}

        # initialize the active layout
        if self.active_layout is None:
            self.active_comb = tuple(active_layout)
            self.active_layout = self._get_string_and_set_width(
                tuple(active_layout), self.active_mode
            )

        return layout

    def _layout_changed(self):
        """
        check if the known monitor setup match current setup
        """
        return (
            self.layout["connected"] != self.old_layout["connected"]
            or self.layout["disconnected"] != self.old_layout["disconnected"]
        )

    def _set_available_combinations(self):
        """
        Generate all connected outputs combinations and
        set the max display width while iterating.
        """
        available = set()
        combinations_map = {}

        whitelist = None
        if self.output_combinations:
            whitelist = self.output_combinations.split("|")

        self.max_width = 0
        for output in range(len(self.layout["connected"])):
            for comb in combinations(self.layout["connected"], output + 1):
                for mode in ["clone", "extend"]:
                    string = self._get_string_and_set_width(comb, mode)
                    if whitelist and string not in whitelist:
                        continue
                    if len(comb) == 1:
                        combinations_map[string] = (comb, None)
                    else:
                        combinations_map[string] = (comb, mode)
                    available.add(string)

        # Preserve the order in which user defined the output combinations
        if whitelist:
            available = reversed([comb for comb in whitelist if comb in available])

        self.available_combinations = deque(available)
        self.combinations_map = combinations_map

    def _get_string_and_set_width(self, combination, mode):
        """
        Construct the string to be displayed and record the max width.
        """
        show = getattr(self, f"icon_{mode}").join(
            tuple(getattr(self, f"{x}_icon", x) for x in combination)
        )
        self.max_width = max(self.max_width, len(show))
        return show

    def _choose_what_to_display(self, force_refresh=False):
        """
        Choose what combination to display on the bar.

        By default we try to display the active layout on the first run, else
        we display the last selected combination.
        """
        for _ in range(len(self.available_combinations)):
            if self.displayed is None and self.available_combinations[0] == self.active_layout:
                self.displayed = self.available_combinations[0]
                break
            else:
                if self.displayed == self.available_combinations[0]:
                    break
                else:
                    self.available_combinations.rotate(1)
        else:
            if force_refresh:
                self.displayed = self.available_combinations[0]
            else:
                self.py3.log('xrandr error="displayed combination is not available"')

    def _center(self, s):
        """
        Center the given string on the detected max width.
        """
        return f"{s:^{self.max_width}}"

    def _apply(self, force=False):
        """
        Call xrandr and apply the selected (displayed) combination mode.
        """
        if self.displayed == self.active_layout and not force:
            # no change, do nothing
            return

        combination, mode = self.combinations_map.get(self.displayed, (None, None))
        if combination is None and mode is None:
            # displayed combination cannot be activated, ignore
            return

        cmd = "xrandr"
        outputs = list(self.layout["connected"])
        outputs += list(self.layout["disconnected"])
        previous_output = None
        primary_added = False
        for output in outputs:
            cmd += f" --output {output}"
            #
            if output in combination:
                pos = getattr(self, f"{output}_pos", "0x0")
                primary = getattr(self, f"{output}_primary", None)
                resolution = getattr(self, f"{output}_mode", None)
                resolution = f"--mode {resolution}" if resolution else "--auto"
                rotation = getattr(self, f"{output}_rotate", "normal")
                if rotation not in ["inverted", "left", "normal", "right"]:
                    self.py3.log(f"configured rotation {rotation} is not valid")
                    rotation = "normal"
                #
                if primary is True and not primary_added:
                    primary_added = True
                    cmd += " --primary"
                if mode == "clone" and previous_output is not None:
                    cmd += f" {resolution} --same-as {previous_output}"
                else:
                    if "above" in pos or "below" in pos or "left-of" in pos or "right-of" in pos:
                        cmd += f" {resolution} --{pos} --rotate {rotation}"
                    else:
                        cmd += " {} --pos {} --rotate {}".format(resolution, pos, rotation)
                previous_output = output
            else:
                cmd += " --off"
        #
        code = self.py3.command_run(cmd)
        if code == 0:
            self.active_comb = combination
            self.active_layout = self.displayed
            self.active_mode = mode
        self.py3.log(f'command "{cmd}" exit code {code}')

        if self.command:
            self.py3.command_run(self.command)

        # move workspaces to outputs as configured
        self._apply_workspaces(combination, mode)

    def _apply_workspaces(self, combination, mode):
        """
        Allows user to force move a comma separated list of workspaces to the
        given output when it's activated.

        Example:
            - DP1_workspaces = "1,2,3"
        """
        if len(combination) > 1 and mode == "extend":
            sleep(3)
            for output in combination:
                workspaces = getattr(self, f"{output}_workspaces", "").split(",")
                for workspace in workspaces:
                    if not workspace:
                        continue
                    # switch to workspace
                    cmd = f'{self.py3.get_wm_msg()} workspace "{workspace}"'
                    self.py3.command_run(cmd)
                    # move it to output
                    cmd = '{} move workspace to output "{}"'.format(self.py3.get_wm_msg(), output)
                    self.py3.command_run(cmd)
                    # log this
                    self.py3.log(f"moved workspace {workspace} to output {output}")

    def _fallback_to_available_output(self):
        """
        Fallback to the first available output when the active layout
        was composed of only one output.

        This allows us to avoid cases where you get stuck with a black sreen
        on your laptop by switching back to the integrated screen
        automatically !
        """
        if len(self.active_comb) == 1:
            self._choose_what_to_display(force_refresh=True)
            self._apply()
            self.py3.update()

    def _force_force_on_start(self):
        """
        Force the user configured mode on start.
        """
        if self.force_on_start in self.available_combinations:
            self.displayed = self.force_on_start
            self._choose_what_to_display(force_refresh=True)
            self._apply(force=True)
            self.py3.update()
        self.force_on_start = None

    def _switch_selection(self, direction):
        self.available_combinations.rotate(direction)
        self.displayed = self.available_combinations[0]

    def _force_on_change(self):
        """
        Handle force_on_change feature.
        """
        for layout in self.force_on_change:
            if layout in self.available_combinations:
                if self.active_layout != layout:
                    self.displayed = layout
                    self._apply(force=True)
                    self.py3.update()
                    break
                else:
                    break

    def on_click(self, event):
        """
        Click events
            - left click & scroll up/down: switch between modes
            - right click: apply selected mode
            - middle click: force refresh of available modes
        """
        self._no_force_on_change = True
        button = event["button"]
        if button == 4:
            self._switch_selection(-1)
        if button in [1, 5]:
            self._switch_selection(1)
        if button == 2:
            self._choose_what_to_display(force_refresh=True)
        if button == 3:
            self._apply()

    def xrandr(self):
        """
        This is the main py3status method, it will orchestrate what's being
        displayed on the bar.
        """
        self.layout = self._get_layout()
        self._set_available_combinations()
        self._choose_what_to_display()

        if len(self.available_combinations) < 2 and self.hide_if_single_combination:
            full_text = self.py3.safe_format(self.format, {"output": ""})
        else:
            if self.fixed_width is True:
                output = self._center(self.displayed)
            else:
                output = self.displayed
            full_text = self.py3.safe_format(self.format, {"output": output})

        response = {
            "cached_until": self.py3.time_in(self.cache_timeout),
            "full_text": full_text,
        }

        # coloration
        if self.displayed == self.active_layout:
            response["color"] = self.py3.COLOR_GOOD
        elif self.displayed not in self.available_combinations:
            response["color"] = self.py3.COLOR_BAD

        # force default layout setup at module startup
        if self.force_on_start is not None:
            sleep(1)
            self._force_force_on_start()

        # follow on change
        if not self._no_force_on_change and self.force_on_change and self._layout_changed():
            self.py3.log("detected change of monitor setup")
            self._force_on_change()

        # this was a click event triggered update
        if self._no_force_on_change:
            self._no_force_on_change = False

        # fallback detection
        if self.active_layout not in self.available_combinations:
            response["color"] = self.py3.COLOR_DEGRADED
            if self.fallback is True:
                self._fallback_to_available_output()

        return response


if __name__ == "__main__":
    """
    Run module in test mode.
    """
    from py3status.module_test import module_test

    module_test(Py3status)
