###############################################################################
#
# The MIT License (MIT)
#
# Copyright (c) typedef int GmbH
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
###############################################################################

# this module is available as the 'wamp' command-line tool or as
# 'python -m autobahn'

import os
import sys
import argparse
import json
from copy import copy

try:
    from autobahn.twisted.component import Component
except ImportError:
    print("The 'wamp' command-line tool requires Twisted.")
    print("  pip install autobahn[twisted]")
    sys.exit(1)

from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.internet.task import react
from twisted.internet.protocol import ProcessProtocol

from autobahn.wamp.exception import ApplicationError
from autobahn.wamp.types import PublishOptions
from autobahn.wamp.types import SubscribeOptions

import txaio
txaio.use_twisted()


# XXX other ideas to get 'connection config':
# - if there .crossbar/ here, load that config and accept a --name or
#   so to indicate which transport to use

# wamp [options] {call,publish,subscribe,register} wamp-uri [args] [kwargs]
#
# kwargs are spec'd with a 2-value-consuming --keyword option:
# --keyword name value


top = argparse.ArgumentParser(prog="wamp")
top.add_argument(
    '--url',
    action='store',
    help='A WAMP URL to connect to, like ws://127.0.0.1:8080/ws or rs://localhost:1234',
    required=True,
)
top.add_argument(
    '--realm', '-r',
    action='store',
    help='The realm to join',
    default='default',
)
top.add_argument(
    '--private-key', '-k',
    action='store',
    help='Hex-encoded private key (via WAMP_PRIVATE_KEY if not provided here)',
    default=os.environ.get('WAMP_PRIVATE_KEY', None),
)
top.add_argument(
    '--authid',
    action='store',
    help='The authid to use, if authenticating',
    default=None,
)
top.add_argument(
    '--authrole',
    action='store',
    help='The role to use, if authenticating',
    default=None,
)
top.add_argument(
    '--max-failures', '-m',
    action='store',
    type=int,
    help='Failures before giving up (0 forever)',
    default=0,
)
sub = top.add_subparsers(
    title="subcommands",
    dest="subcommand_name",
)


call = sub.add_parser(
    'call',
    help='Do a WAMP call() and print any results',
)
call.add_argument(
    'uri',
    type=str,
    help="A WAMP URI to call"
)
call.add_argument(
    'call_args',
    nargs='*',
    help="All additional arguments are positional args",
)
call.add_argument(
    '--keyword',
    nargs=2,
    action='append',
    help="Specify a keyword argument to send: name value",
)


publish = sub.add_parser(
    'publish',
    help='Do a WAMP publish() with the given args, kwargs',
)
publish.add_argument(
    'uri',
    type=str,
    help="A WAMP URI to publish"
)
publish.add_argument(
    'publish_args',
    nargs='*',
    help="All additional arguments are positional args",
)
publish.add_argument(
    '--keyword',
    nargs=2,
    action='append',
    help="Specify a keyword argument to send: name value",
)


register = sub.add_parser(
    'register',
    help='Do a WAMP register() and run a command when called',
)
register.add_argument(
    'uri',
    type=str,
    help="A WAMP URI to call"
)
register.add_argument(
    '--times',
    type=int,
    default=0,
    help="Listen for this number of events, then exit. Default: forever",
)
register.add_argument(
    'command',
    type=str,
    nargs='*',
    help=(
        "Takes one or more args: the executable to call, and any positional "
        "arguments. As well, the following environment variables are set: "
        "WAMP_ARGS, WAMP_KWARGS and _JSON variants."
    )
)


subscribe = sub.add_parser(
    'subscribe',
    help='Do a WAMP subscribe() and print one line of JSON per event',
)
subscribe.add_argument(
    'uri',
    type=str,
    help="A WAMP URI to call"
)
subscribe.add_argument(
    '--times',
    type=int,
    default=0,
    help="Listen for this number of events, then exit. Default: forever",
)
subscribe.add_argument(
    '--match',
    type=str,
    default='exact',
    choices=['exact', 'prefix'],
    help="Massed in the SubscribeOptions, how to match the URI",
)


def _create_component(options):
    """
    Configure and return a Component instance according to the given
    `options`
    """
    if options.url.startswith('ws://'):
        kind = 'websocket'
    elif options.url.startswith('rs://'):
        kind = 'rawsocket'
    else:
        raise ValueError(
            "URL should start with ws:// or rs://"
        )

    authentication = dict()
    if options.private_key:
        if not options.authid:
            raise ValueError(
                "Require --authid and --authrole if --private-key (or WAMP_PRIVATE_KEY) is provided"
            )
        authentication["cryptosign"] = {
            "authid": options.authid,
            "authrole": options.authrole,
            "privkey": options.private_key,
        }

    return Component(
        transports=[{
            "type": kind,
            "url": options.url,
        }],
        authentication=authentication if authentication else None,
        realm=options.realm,
    )


@inlineCallbacks
def do_call(reactor, session, options):
    call_args = list(options.call_args)
    call_kwargs = dict()
    if options.keyword is not None:
        call_kwargs = {
            k: v
            for k, v in options.keyword
        }

    results = yield session.call(options.uri, *call_args, **call_kwargs)
    print("result: {}".format(results))


@inlineCallbacks
def do_publish(reactor, session, options):
    publish_args = list(options.publish_args)
    publish_kwargs = {} if options.keyword is None else {
        k: v
        for k, v in options.keyword
    }

    yield session.publish(
        options.uri,
        *publish_args,
        options=PublishOptions(acknowledge=True),
        **publish_kwargs
    )


@inlineCallbacks
def do_register(reactor, session, options):
    """
    run a command-line upon an RPC call
    """

    all_done = Deferred()
    countdown = [options.times]

    @inlineCallbacks
    def called(*args, **kw):
        print("called: args={}, kwargs={}".format(args, kw), file=sys.stderr)
        env = copy(os.environ)
        env['WAMP_ARGS'] = ' '.join(args)
        env['WAMP_ARGS_JSON'] = json.dumps(args)
        env['WAMP_KWARGS'] = ' '.join('{}={}'.format(k, v) for k, v in kw.items())
        env['WAMP_KWARGS_JSON'] = json.dumps(kw)

        exe = os.path.abspath(options.command[0])
        args = options.command
        done = Deferred()

        class DumpOutput(ProcessProtocol):
            def outReceived(self, data):
                sys.stdout.write(data.decode('utf8'))

            def errReceived(self, data):
                sys.stderr.write(data.decode('utf8'))

            def processExited(self, reason):
                done.callback(reason.value.exitCode)

        proto = DumpOutput()
        reactor.spawnProcess(
            proto, exe, args, env=env, path="."
        )
        code = yield done

        if code != 0:
            print("Failed with exit-code {}".format(code))
        if countdown[0]:
            countdown[0] -= 1
            if countdown[0] <= 0:
                reactor.callLater(0, all_done.callback, None)

    yield session.register(called, options.uri)
    yield all_done


@inlineCallbacks
def do_subscribe(reactor, session, options):
    """
    print events (one line of JSON per event)
    """

    all_done = Deferred()
    countdown = [options.times]

    @inlineCallbacks
    def published(*args, **kw):
        print(
            json.dumps({
                "args": args,
                "kwargs": kw,
            })
        )
        if countdown[0]:
            countdown[0] -= 1
            if countdown[0] <= 0:
                reactor.callLater(0, all_done.callback, None)

    yield session.subscribe(published, options.uri, options=SubscribeOptions(match=options.match))
    yield all_done


def _main():
    """
    This is a magic name for `python -m autobahn`, and specified as
    our entry_point in setup.py
    """
    react(_real_main)


@inlineCallbacks
def _real_main(reactor):
    """
    Sanity check options, create a connection and run our subcommand
    """
    options = top.parse_args()
    component = _create_component(options)

    if options.subcommand_name is None:
        print("Must select a subcommand")
        sys.exit(1)

    if options.subcommand_name == "register":
        exe = options.command[0]
        if not os.path.isabs(exe):
            print("Full path to the executable required. Found: {}".format(exe), file=sys.stderr)
            sys.exit(1)
        if not os.path.exists(exe):
            print("Executable not found: {}".format(exe), file=sys.stderr)
            sys.exit(1)

    subcommands = {
        "call": do_call,
        "register": do_register,
        "subscribe": do_subscribe,
        "publish": do_publish,
    }
    command_fn = subcommands[options.subcommand_name]

    exit_code = [0]

    @component.on_join
    @inlineCallbacks
    def _(session, details):
        print("connected: authrole={} authmethod={}".format(details.authrole, details.authmethod), file=sys.stderr)
        try:
            yield command_fn(reactor, session, options)
        except ApplicationError as e:
            print("\n{}: {}\n".format(e.error, ''.join(e.args)))
            exit_code[0] = 5
        yield session.leave()

    failures = []

    @component.on_connectfailure
    def _(comp, fail):
        print("connect failure: {}".format(fail))
        failures.append(fail)
        if options.max_failures > 0 and len(failures) > options.max_failures:
            print("Too many failures ({}). Exiting".format(len(failures)))
            reactor.stop()

    yield component.start(reactor)
    # sys.exit(exit_code[0])


if __name__ == "__main__":
    _main()
