#!/usr/bin/env python3

import argparse
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

from pyyaml.lib import yaml

special_key_chars: List[str] = ['~']

template: str = (
    '''#
# Generated by generate_plugin.py.
#
# init.tmux: called by plugin.sh.tmux to initialize the plugin.
#

display -p '[tmux-which-key] Loading plugin ...'

#
# User options
#

{user_options}

#
# Custom variables
#

{custom_variables}

#
# Menus
#

{menus}
'''

    # We define custom actions as command aliases because they run directly in
    # tmux, which is much faster than using shell commands. They're also
    # expanded when parsed--unlike options, which need to be expanded at
    # execution--so they can be used in key bindings.
    #
    # Define the root menu entrypoint as an alias:
    #  - It must be defined after the root menu so it can be expanded
    #  - Aliases can't reference other aliases, so we expand the @wk_cmd_show
    #  option as a format instead
    #  - We don't define it as an option because bind-key doesn't support
    #  formats and `run-shell "bind-key ${...}"` doesn't preserve quotes
    #  correctly
    '''
#
# Macros
#

set -gF @wk_cmd_show \\
"display-menu \\
-x '#{{@wk_cfg_pos_x}}' \\
-y '#{{@wk_cfg_pos_y}}' \\
-T '#[#{{@wk_cfg_title_style}}]#[#{{@wk_cfg_title_prefix_style}}]#{{@wk_cfg_title_prefix}}#[#{{@wk_cfg_title_style}}]'"

{macros}

#
# Keybindings
#

{keybindings}

display -p '[tmux-which-key] Done'
''')


def add_quotes(s: str) -> str:
    '''Wraps s in quotes if it has any whitespace or special chars, escaping
    existing double-quotes.'''
    if all(c.isalnum() or c in ['-', '_'] for c in s):
        return s

    return '"{}"'.format(s.replace('"', '\\"'))


@dataclass
class Keybindings(object):
    prefix_table: str
    root_table: str = ''

    def __post_init__(self):
        assert self.prefix_table

    def __str__(self) -> str:
        # `bind-key` doesn't support formats, so we use `run-shell` to inject
        # the config values.
        return '\n\n'.join(['\n'.join([
            'display -p "[tmux-which-key] Binding {table} table key to #{{@wk_cfg_key_{table}_table}} ..."'.format(
                table=table),
            'run-shell "tmux bind-key -T{table} #{{@wk_cfg_key_{table}_table}} show-wk-menu-root"'.format(
                table=table)
        ]) for table in [
            'root',
            'prefix'
        ] if self.__getattribute__('{}_table'.format(table))])


@dataclass
class Position(object):
    x: str
    y: str

    def __post_init__(self):
        assert self.x in ['C', 'R', 'P', 'M', 'W']
        assert self.y in ['C', 'P', 'M', 'W', 'S']


@dataclass
class UserOption(object):
    name: str
    value: str

    def __str__(self) -> str:
        return 'set -g @{} "{}"'.format(self.name, self.value)


@dataclass
class CustomVariable(object):
    name: str
    value: str

    def __str__(self) -> str:
        # Sets the env var as hidden (-h) so it's only available to tmux
        # processes.
        return 'setenv -h {} "{}"'.format(self.name, self.value)


@dataclass
class Macro(object):
    index: int
    name: str
    commands: List[str]

    def __post_init__(self):
        assert all(c for c in self.name if c.isalnum() or c in [
                   '-', '_']), 'macro has invalid name: {}'.format(self.name)
        assert isinstance(self.commands, list), 'macro {} has no commands'.format(
            self.name)

    def __str__(self) -> str:
        return "set -gF command-alias[{}] {}=\\\n'{}'".format(
            self.index, self.name, ' ; \\\n'.join(self.commands),
        )


@dataclass
class MenuItem(object):
    parent_menu_id: Optional[str] = None
    name: Optional[str] = None
    key: Optional[str] = None
    menu: Optional[List[dict]] = None
    macro: Optional[str] = None
    command: Optional[str] = None
    separator: Optional[bool] = False
    transient: bool = False

    def __post_init__(
            self,
    ):
        if not self.separator:
            assert self.name, 'item must have a name'
            self.name = add_quotes(self.name)

            assert self.key, 'item {} is missing a keybinding'.format(
                self.name)
            # Escape special key characters.
            has_special_key_char = True
            for k in special_key_chars:
                if k in self.key:
                    has_special_key_char = True
                    self.key = self.key.replace(k, '\\{}'.format(k))

            if has_special_key_char:
                self.key = '"{}"'.format(self.key)

        if self.menu:
            assert self.name and not (self.macro or self.command)
            assert len(self.menu) > 0, 'menu {} has no commands'.format(
                self.name)
            # Assign an alphanumeric menu ID for use in tmux user options.
            self.menu_id = ''.join(
                [c for c in self.name if c.isalnum() or c in ['-', '_']]).lower()
            self.command = 'show-wk-menu #{{@wk_menu_{}}}'.format(
                self.menu_id)
        elif self.macro:
            assert not (self.menu or self.command)
            self.command = self.macro
        elif self.command:
            assert not (self.menu or self.macro)
        elif self.separator:
            assert not (self.key or self.menu or self.macro or self.command)
        else:
            raise TypeError(self)

        if self.transient:
            assert self.parent_menu_id
            self.command = '{} ; show-wk-menu #{{@wk_menu_{}}}'.format(
                self.command, self.parent_menu_id)

        if self.command:
            self.command = add_quotes(self.command)

    def __str__(self) -> str:
        if self.separator:
            return '""'

        return '{} {} {}'.format(self.name, self.key, self.command)


@dataclass
class Menu(object):
    name: str
    id: str
    items: List[MenuItem]

    def __str__(self) -> str:
        return "set -g @wk_menu_{} \\\n'{}'".format(self.id, ' \\\n'.join(map(str, self.items)))


class Config(object):
    def __init__(
        self,
        command_alias_start_index: int,
        keybindings: dict,
        title: dict,
        position: dict,
        custom_variables: List[dict],
        macros: List[dict],
        items: List[dict]
    ) -> None:
        # Aliases must start at 200 or greater because the tmux manpage examples
        # start at 100, so we assume 100-199 may already be in use.
        assert command_alias_start_index >= 200, 'command_alias_start_index must be at least 200'
        self.command_alias_start_index = command_alias_start_index

        self.keybindings = Keybindings(**keybindings)
        self.title = title
        self.position = Position(**position)

        opts = {
            'wk_cfg_key_root_table': self.keybindings.root_table,
            'wk_cfg_key_prefix_table': self.keybindings.prefix_table,
            'wk_cfg_title_style': title['style'],
            'wk_cfg_title_prefix': title['prefix'],
            'wk_cfg_title_prefix_style': title['prefix_style'],
            'wk_cfg_pos_x': self.position.x,
            'wk_cfg_pos_y': self.position.y,
        }
        self.user_options = [UserOption(name=k, value=opts[k])
                             for k in opts if opts[k]]

        self.custom_variables = [CustomVariable(
            v['name'], v['value']) for v in custom_variables]

        macros = [{
            'name': 'show-wk-menu',
            'commands': ['#{@wk_cmd_show}']
        }, {
            'name': 'show-wk-menu-root',
            'commands': ['#{@wk_cmd_show} #{@wk_menu_root}']
        }] + macros
        self.macros = [Macro(self.command_alias_start_index+i, name=m['name'], commands=m['commands'])
                       for i, m in enumerate(macros)]

        self.macro_names = set([m.name for m in self.macros])
        self.menu_ids = set()
        self.menus = []
        self.register_menu('root', 'root', items)

    def register_menu(self, name: str, id: str, items: List[dict]):
        menu = Menu(name, id, items=[])
        assert menu.id not in self.menu_ids, 'a menu was already registered with ID: {}'.format(
            menu.id)
        self.menu_ids.add(menu.id)

        mapped_keys = set()
        for item in items:
            item = MenuItem(parent_menu_id=id, **item)
            if item.menu:
                assert item.name
                self.register_menu(item.name, item.menu_id, item.menu)
            elif item.macro:
                assert item.macro in self.macro_names, 'item {} has unknown macro: {}'.format(
                    item.name, item.macro)

            if item.key:
                assert item.key not in mapped_keys, 'keybinding {} was already registered in menu {}'.format(
                    item.key, name)
            mapped_keys.add(item.key)

            menu.items.append(item)

        self.menus.append(menu)
        return menu

    def __str__(self) -> str:
        return template.format(
            user_options='\n'.join(map(str, self.user_options)),
            custom_variables='\n'.join(map(str, self.custom_variables)),
            menus='\n\n'.join(map(str, self.menus)),
            macros='\n\n'.join(map(str, self.macros)),
            keybindings=self.keybindings,
        )


def main() -> int:
    logging.basicConfig(level=logging.INFO)

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "config_file", help="The plugin config file", type=Path)
    parser.add_argument(
        "output_file", help="The file where plugin init script will be written", type=Path)
    args = parser.parse_args()

    logging.info('Reading configuration ...')
    with open(args.config_file) as config_file:
        data = yaml.safe_load(config_file)
    assert isinstance(data, dict), 'the contents of config_file must be a map'

    logging.info('Building menu ...')
    config = Config(**data)
    out = str(config)

    with open(args.output_file, 'w+') as output_file:
        output_file.write(out)

    logging.info('Done')

    return 0


if __name__ == '__main__':
    sys.exit(main())
