#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>

import json
import re
import socket
import sys
import types
from functools import partial

from .cli import emph, parse_args
from .config import parse_send_text_bytes
from .constants import appname, version
from .tabs import SpecialWindow
from .utils import non_blocking_read, parse_address_spec, read_with_timeout


def cmd(short_desc, desc=None, options_spec=None, no_response=False):

    def w(func):
        func.short_desc = short_desc
        func.desc = desc or short_desc
        func.name = func.__name__[4:].replace('_', '-')
        func.options_spec = options_spec
        func.is_cmd = True
        func.impl = lambda: globals()[func.__name__[4:]]
        func.no_response = no_response
        return func
    return w


def parse_subcommand_cli(func, args):
    opts, items = parse_args(args[1:], (func.options_spec or '\n').format, '...', func.desc, '{} @ {}'.format(appname, func.name))
    return opts, items


@cmd(
    'List all tabs/windows',
    'List all windows. The list is returned as JSON tree. The top-level is a list of'
    ' operating system {appname} windows. Each OS window has an |_ id| and a list'
    ' of |_ tabs|. Each tab has its own |_ id|, a |_ title| and a list of |_ windows|.'
    ' Each window has an |_ id|, |_ title|, |_ current working directory|, |_ process id (PID)| and'
    ' |_ command-line| of the process running in the window.\n\n'
    'You can use these criteria to select windows/tabs for the other commands.'.format(appname=appname)
)
def cmd_ls(global_opts, opts, args):
    pass


def ls(boss, window):
    data = list(boss.list_os_windows())
    data = json.dumps(data, indent=2, sort_keys=True)
    return data


MATCH_WINDOW_OPTION = '''\
--match -m
The window to match. Match specifications are of the form:
|_ field:regexp|. Where field can be one of: id, title, pid, cwd, cmdline.
You can use the |_ ls| command to get a list of windows. Note that for
numeric fields such as id and pid the expression is interpreted as a number,
not a regular expression.
'''
MATCH_TAB_OPTION = '''\
--match -m
The tab to match. Match specifications are of the form:
|_ field:regexp|. Where field can be one of: id, title, pid, cwd, cmdline.
You can use the |_ ls| command to get a list of tabs. Note that for
numeric fields such as id and pid the expression is interpreted as a number,
not a regular expression. When using title or id, first a matching tab is
looked for and if not found a matching window is looked for, and the tab
for that window is used.
'''


@cmd(
    'Send arbitrary text to specified windows',
    'Send arbitrary text to specified windows. The text follows Python'
    ' escaping rules. So you can use escapes like |_ \\x1b| to send control codes'
    ' and |_ \\u21fa| to send unicode characters. If you use the |_ --match| option'
    ' the text will be sent to all matched windows. By default, text is sent to'
    ' only the currently active window.',
    options_spec=MATCH_WINDOW_OPTION + '''\n
--stdin
type=bool-set
Read the text to be sent from |_ stdin|. Note that in this case the text is sent as is,
not interpreted for escapes. If stdin is a terminal, you can press Ctrl-D to end reading.


--from-file
Path to a file whose contents you wish to send. Note that in this case the file contents
are sent as is, not interpreted for escapes.
''',
    no_response=True
)
def cmd_send_text(global_opts, opts, args):
    limit = 1024
    ret = {'match': opts.match, 'is_binary': False}

    def pipe(src=sys.stdin):
        ret['is_binary'] = True
        import select
        with non_blocking_read() as fd:
            keep_going = True
            while keep_going:
                rd = select.select([fd], [], [])
                if rd:
                    data = sys.stdin.buffer.read()
                    if not data:
                        break
                    data = data.decode('utf-8')
                    if '\x04' in data:
                        data = data[:data.index('\x04')]
                        keep_going = False
                    while data:
                        ret['text'] = data[:limit]
                        yield ret
                        data = data[limit:]
                else:
                    break

    def chunks(text):
        ret['is_binary'] = False
        while text:
            ret['text'] = text[:limit]
            yield ret
            text = text[limit:]

    def file_pipe(path):
        ret['is_binary'] = True
        with open(path, encoding='utf-8') as f:
            while True:
                data = f.read(limit)
                if not data:
                    break
                ret['text'] = data
                yield ret

    sources = []
    if opts.stdin:
        sources.append(pipe())

    if opts.from_file:
        sources.append(file_pipe(opts.from_file))

    text = ' '.join(args)
    sources.append(chunks(text))

    def chain():
        for src in sources:
            yield from src
    return chain()


def send_text(boss, window, payload):
    windows = [boss.active_window]
    match = payload['match']
    if match:
        windows = tuple(boss.match_windows(match))
    data = payload['text'].encode('utf-8') if payload['is_binary'] else parse_send_text_bytes(payload['text'])
    for window in windows:
        if window is not None:
            window.write_to_child(data)


@cmd(
    'Set the window title',
    'Set the title for the specified window(s). If you use the |_ --match| option'
    ' the title will be set for all matched windows. By default, only the window'
    ' in which the command is run is affected. If you do not specify a title, the'
    ' last title set by the child process running in the window will be used.',
    options_spec=MATCH_WINDOW_OPTION
)
def cmd_set_window_title(global_opts, opts, args):
    return {'title': ' '.join(args), 'match': opts.match}


def set_window_title(boss, window, payload):
    windows = [window or boss.active_window]
    match = payload['match']
    if match:
        windows = tuple(boss.match_windows(match))
        if not windows:
            raise ValueError('No matching windows for expression: {}'.format(match))
    for window in windows:
        if window:
            window.set_title(payload['title'])


@cmd(
    'Set the tab title',
    'Set the title for the specified tab(s). If you use the |_ --match| option'
    ' the title will be set for all matched tabs. By default, only the tab'
    ' in which the command is run is affected. If you do not specify a title, the'
    ' title of the currently active window in the tab is used.',
    options_spec=MATCH_TAB_OPTION
)
def cmd_set_tab_title(global_opts, opts, args):
    return {'title': ' '.join(args), 'match': opts.match}


def set_tab_title(boss, window, payload):
    match = payload['match']
    if match:
        tabs = tuple(boss.match_tabs(match))
        if not tabs:
            raise ValueError('No matching windows for expression: {}'.format(match))
    else:
        tabs = [boss.tab_for_window(window) if window else boss.active_tab]
    for tab in tabs:
        if tab:
            tab.set_title(payload['title'])


@cmd(
    'Close the specified window(s)',
    options_spec=MATCH_WINDOW_OPTION + '''\n
--self
type=bool-set
If specified close the window this command is run in, rather than the active window.
'''
)
def cmd_close_window(global_opts, opts, args):
    return {'match': opts.match, 'self': opts.self}


def close_window(boss, window, payload):
    match = payload['match']
    if match:
        windows = tuple(boss.match_windows(match))
        if not windows:
            raise ValueError('No matching windows for expression: {}'.format(match))
    else:
        windows = [window if window and payload['self'] else boss.active_window]
    for window in windows:
        if window:
            boss.close_window(window)


@cmd(
    'Close the specified tab(s)',
    options_spec=MATCH_TAB_OPTION + '''\n
--self
type=bool-set
If specified close the tab this command is run in, rather than the active tab.
'''
)
def cmd_close_tab(global_opts, opts, args):
    return {'match': opts.match, 'self': opts.self}


def close_tab(boss, window, payload):
    match = payload['match']
    if match:
        tabs = tuple(boss.match_tabs(match))
        if not tabs:
            raise ValueError('No matching windows for expression: {}'.format(match))
    else:
        tabs = [boss.tab_for_window(window) if window and payload['self'] else boss.active_tab]
    for tab in tabs:
        if window:
            if tab:
                boss.close_tab(tab)


@cmd(
    'Open new window',
    'Open a new window in the specified tab. If you use the |_ --match| option'
    ' the first matching tab is used. Otherwise the currently active tab is used.'
    ' Prints out the id of the newly opened window. Any command line arguments'
    ' are assumed to be the command line used to run in the new window, if none'
    ' are provided, the default shell is run. For example:\n'
    '|_ kitty @ new-window --title Email mutt|',
    options_spec=MATCH_TAB_OPTION + '''\n
--title
The title for the new window. By default it will use the title set by the
program running in it.


--cwd
The initial working directory for the new window.


--keep-focus
type=bool-set
Keep the current window focused instead of switching to the newly opened window


--new-tab
type=bool-set
Open a new tab


--tab-title
When using --new-tab set the title of the tab.
'''
)
def cmd_new_window(global_opts, opts, args):
    return {'match': opts.match, 'title': opts.title, 'cwd': opts.cwd,
            'new_tab': opts.new_tab, 'tab_title': opts.tab_title,
            'keep_focus': opts.keep_focus, 'args': args or []}


def new_window(boss, window, payload):
    w = SpecialWindow(cmd=payload['args'] or None, override_title=payload['title'], cwd=payload['cwd'])
    old_window = boss.active_window
    if payload['new_tab']:
        boss._new_tab(w)
        tab = boss.active_tab
        if payload['tab_title']:
            tab.set_title(payload['tab_title'])
        wid = boss.active_window.id
        if payload['keep_focus'] and old_window:
            boss.set_active_window(old_window)
        return str(wid)

    match = payload['match']
    if match:
        tabs = tuple(boss.match_tabs(match))
        if not tabs:
            raise ValueError('No matching windows for expression: {}'.format(match))
    else:
        tabs = [boss.active_tab]
    tab = tabs[0]
    w = tab.new_special_window(w)
    if payload['keep_focus'] and old_window:
        boss.set_active_window(old_window)
    return str(w.id)


@cmd(
    'Focus the specified window',
    options_spec=MATCH_WINDOW_OPTION
)
def cmd_focus_window(global_opts, opts, args):
    return {'match': opts.match}


def focus_window(boss, window, payload):
    windows = [window or boss.active_window]
    match = payload['match']
    if match:
        windows = tuple(boss.match_windows(match))
        if not windows:
            raise ValueError('No matching windows for expression: {}'.format(match))
    for window in windows:
        if window:
            boss.set_active_window(window)
            break


@cmd(
    'Get text from the specified window',
    options_spec=MATCH_WINDOW_OPTION + '''\n
--extent
default=screen
choices=screen, all, selection
What text to get. The default of screen means all text currently on the screen. all means
all the screen+scrollback and selection means currently selected text.


--ansi
type=bool-set
By default, only plain text is returned. If you specify this flag, the text will
include the formatting escape codes for colors/bold/italic/etc. Note that when
getting the current selection, the result is always plain text.


--self
type=bool-set
If specified get text from the window this command is run in, rather than the active window.
'''
)
def cmd_get_text(global_opts, opts, args):
    return {'match': opts.match, 'extent': opts.extent, 'ansi': opts.ansi, 'self': opts.self}


def get_text(boss, window, payload):
    match = payload['match']
    if match:
        windows = tuple(boss.match_windows(match))
        if not windows:
            raise ValueError('No matching windows for expression: {}'.format(match))
    else:
        windows = [window if window and payload['self'] else boss.active_window]
    window = windows[0]
    if payload['extent'] == 'selection':
        ans = window.text_for_selection()
    else:
        ans = window.as_text(as_ansi=bool(payload['ansi']), add_history=True)
    return ans


cmap = {v.name: v for v in globals().values() if hasattr(v, 'is_cmd')}


def handle_cmd(boss, window, cmd):
    cmd = json.loads(cmd)
    v = cmd['version']
    if tuple(v)[:2] > version[:2]:
        return {'ok': False, 'error': 'The kitty client you are using to send remote commands is newer than this kitty instance. This is not supported.'}
    c = cmap[cmd['cmd']]
    func = partial(c.impl(), boss, window)
    payload = cmd.get('payload')
    ans = func() if payload is None else func(payload)
    response = {'ok': True}
    if ans is not None:
        response['data'] = ans
    if not c.no_response:
        return response


global_options_spec = partial('''\
--to
An address for the kitty instance to control. Corresponds to the address
given to the kitty instance via the --listen-on option. If not specified,
messages are sent to the controlling terminal for this process, i.e. they
will only work if this process is run within an existing kitty window.
'''.format, appname=appname)


def do_io(to, send, no_response):
    send = ('@kitty-cmd' + json.dumps(send)).encode('ascii')
    send = b'\x1bP' + send + b'\x1b\\'
    if to:
        family, address = parse_address_spec(to)[:2]
        s = socket.socket(family)
        s.connect(address)
        out = s.makefile('wb')
    else:
        out = open('/dev/tty', 'wb')
    with out:
        out.write(send)
    if to:
        s.shutdown(socket.SHUT_WR)
    if no_response:
        return {'ok': True}

    received = b''
    dcs = re.compile(br'\x1bP@kitty-cmd([^\x1b]+)\x1b\\')
    match = None

    def more_needed(data):
        nonlocal received, match
        received += data
        match = dcs.search(received)
        return match is None

    if to:
        src = s.makefile('rb')
    else:
        src = open('/dev/tty', 'rb')
    with src:
        read_with_timeout(more_needed, src=src)
    if match is None:
        raise SystemExit('Failed to receive response from ' + appname)
    response = json.loads(match.group(1).decode('ascii'))
    return response


def main(args):
    all_commands = tuple(sorted(cmap))
    cmds = ('  |G {}|\n    {}'.format(cmap[c].name, cmap[c].short_desc) for c in all_commands)
    msg = (
        'Control {appname} by sending it commands. Add'
        ' |_ allow_remote_control yes| to kitty.conf for this'
        ' to work.\n\n|T Commands|:\n{cmds}\n\n'
        'You can get help for each individual command by using:\n'
        '{appname} @ |_ command| -h'
    ).format(appname=appname, cmds='\n'.join(cmds))

    global_opts, items = parse_args(args[1:], global_options_spec, 'command ...', msg, '{} @'.format(appname))

    if not items:
        raise SystemExit('You must specify a command')
    cmd = items[0]
    try:
        func = cmap[cmd]
    except KeyError:
        raise SystemExit('{} is not a known command. Known commands are: {}'.format(
            emph(cmd), ', '.join(all_commands)))
    opts, items = parse_subcommand_cli(func, items)
    payload = func(global_opts, opts, items)
    send = {
        'cmd': cmd,
        'version': version,
    }
    if func.no_response and isinstance(payload, types.GeneratorType):
        for item in payload:
            send['payload'] = item
            do_io(global_opts.to, send, func.no_response)
        return
    if payload is not None:
        send['payload'] = payload
    response = do_io(global_opts.to, send, func.no_response)
    if not response.get('ok'):
        if response.get('tb'):
            print(response['tb'], file=sys.stderr)
        raise SystemExit(response['error'])
    if 'data' in response:
        print(response['data'])
