#!/usr/bin/env python3
"""
mcsj-swing-bridge-client.py

CLI client for the in-process Swing control bridge agent (SwingBridgeAgent.java).
Talks to 127.0.0.1:9797 (or a custom host:port) using a trivial line-based
text protocol: send one line, read one line (or full response) back.

Usage:
    python mcsj-swing-bridge-client.py tree [--pretty]
    python mcsj-swing-bridge-client.py click <selector>
    python mcsj-swing-bridge-client.py settext <selector> <value...>
    python mcsj-swing-bridge-client.py gettext <selector>
    python mcsj-swing-bridge-client.py ping
    python mcsj-swing-bridge-client.py raw "<any raw command line>"

Options:
    --host HOST     default 127.0.0.1
    --port PORT     default 9797
    --timeout SEC   socket timeout, default 15

Examples:
    python mcsj-swing-bridge-client.py tree --pretty
    python mcsj-swing-bridge-client.py click MyClickButton
    python mcsj-swing-bridge-client.py settext MyTextField hello world
    python mcsj-swing-bridge-client.py gettext MyTextField
"""
import argparse
import base64
import json
import socket
import sys


def send_command(host, port, timeout, command_line):
    """Open a fresh TCP connection, send one line, read the full response
    (server closes the connection after replying), return raw text."""
    with socket.create_connection((host, port), timeout=timeout) as s:
        s.settimeout(timeout)
        if not command_line.endswith("\n"):
            command_line += "\n"
        s.sendall(command_line.encode("utf-8"))
        s.shutdown(socket.SHUT_WR)
        chunks = []
        while True:
            try:
                data = s.recv(65536)
            except socket.timeout:
                break
            if not data:
                break
            chunks.append(data)
        return b"".join(chunks).decode("utf-8", errors="replace")


def non_wildcard(value):
    if value == "*":
        raise argparse.ArgumentTypeError("wildcard '*' is forbidden for guarded mutations")
    return value


def fully_qualified_class(value):
    if value == "*" or "." not in value:
        raise argparse.ArgumentTypeError("a fully-qualified non-wildcard class is required")
    return value


def protocol_token(value):
    if value == "*":
        return "*"
    if value == "":
        return "."
    return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")


def print_json_response(resp, pretty=False):
    try:
        obj = json.loads(resp)
    except json.JSONDecodeError as exc:
        print(json.dumps({"status": "ERROR", "message": "non-JSON bridge response: %s" % exc}))
        raise SystemExit(2)
    print(json.dumps(obj, indent=2 if pretty else None, separators=None if pretty else (",", ":")))
    return obj


def print_mutation_response(resp, pretty=False):
    obj = print_json_response(resp, pretty)
    if obj.get("status") != "OK":
        raise SystemExit(2)


def cmd_fxedittext(args):
    command = "FXEDITTEXT {} {} {} {} {} {} {} {}".format(
        protocol_token(args.panel_class), args.panel_nth,
        protocol_token(args.node_class), protocol_token(args.id), protocol_token(args.text),
        args.node_nth, protocol_token(args.expected), protocol_token(args.new_text))
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxtablecell(args):
    command = "FXTABLECELL {} {} {} {} {} {} {} {}".format(
        protocol_token(args.panel_class), args.panel_nth, protocol_token(args.header),
        args.table_nth, args.row, args.column, args.action, protocol_token(args.expected))
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxselectradio(args):
    command = "FXSELECTRADIO {} {} {} true".format(
        protocol_token(args.panel_class), protocol_token(args.text),
        str(args.expected_selected).lower())
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxfirebutton(args):
    command = "FXFIREBUTTON {} {} {}".format(
        protocol_token(args.panel_class), protocol_token(args.text),
        str(args.expected_disabled).lower())
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxcombooptions(args):
    command = "FXCOMBOOPTIONS {} {} {} {}".format(
        protocol_token(args.panel_class), protocol_token(args.node_class),
        protocol_token(args.id), args.node_nth)
    print_json_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxselectcombo(args):
    command = "FXSELECTCOMBO {} {} {} {} {} {}".format(
        protocol_token(args.panel_class), protocol_token(args.node_class),
        protocol_token(args.id), args.node_nth,
        protocol_token(args.expected), protocol_token(args.new_text))
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxselectdate(args):
    command = "FXSELECTDATE {} {} {} {} {} {}".format(
        protocol_token(args.panel_class), protocol_token(args.node_class),
        protocol_token(args.id), args.node_nth,
        protocol_token(args.expected), protocol_token(args.new_text))
    print_mutation_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxfind(args):
    command = "FXFIND {} {} {} {} {} {}".format(
        protocol_token(args.panel_class), args.panel_nth,
        protocol_token(args.node_class), protocol_token(args.id),
        protocol_token(args.text), args.limit)
    print_json_response(send_command(args.host, args.port, args.timeout, command), args.pretty)


def cmd_fxstate(args):
    print_json_response(send_command(args.host, args.port, args.timeout, "FXSTATE"), args.pretty)


def cmd_tree(args):
    resp = send_command(args.host, args.port, args.timeout, "TREE")
    if args.pretty:
        try:
            obj = json.loads(resp)
            print(json.dumps(obj, indent=2))
            return
        except json.JSONDecodeError as e:
            print("[warn] response was not valid JSON (%s); printing raw" % e, file=sys.stderr)
    print(resp)


def cmd_click(args):
    resp = send_command(args.host, args.port, args.timeout, "CLICK %s" % args.selector)
    print(resp)


def cmd_settext(args):
    value = " ".join(args.value)
    resp = send_command(args.host, args.port, args.timeout, "SETTEXT %s %s" % (args.selector, value))
    print(resp)


def cmd_gettext(args):
    resp = send_command(args.host, args.port, args.timeout, "GETTEXT %s" % args.selector)
    print(resp)


def cmd_ping(args):
    resp = send_command(args.host, args.port, args.timeout, "PING")
    print(resp)


def cmd_raw(args):
    resp = send_command(args.host, args.port, args.timeout, args.line)
    print(resp)


def main():
    p = argparse.ArgumentParser(description="Swing control bridge client")
    p.add_argument("--host", default="127.0.0.1")
    p.add_argument("--port", type=int, default=9797)
    p.add_argument("--timeout", type=float, default=15.0)
    sub = p.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("fxedittext", help="guarded panel-scoped JavaFX text edit (AgentV7)")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--panel-nth", type=int, choices=[0], default=0)
    sp.add_argument("--class", dest="node_class", type=fully_qualified_class, default="javafx.scene.control.TextField")
    sp.add_argument("--id", required=True, type=non_wildcard)
    sp.add_argument("--text", default="*")
    sp.add_argument("--node-nth", type=int, choices=[0], default=0)
    sp.add_argument("--expected", required=True, type=non_wildcard)
    sp.add_argument("--new", dest="new_text", required=True)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxedittext)

    sp = sub.add_parser("fxtablecell", help="target a specific JavaFX table cell (AgentV7)")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--panel-nth", type=int, choices=[0], default=0)
    sp.add_argument("--header", required=True, type=non_wildcard, help="exact header text identifying the table")
    sp.add_argument("--table-nth", type=int, choices=[0], default=0)
    sp.add_argument("--row", type=int, required=True)
    sp.add_argument("--column", type=int, required=True)
    sp.add_argument("--action", choices=["select", "edit", "doubleclick"], default="select")
    sp.add_argument("--expected", required=True, type=non_wildcard)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxtablecell)

    sp = sub.add_parser("fxselectradio", help="fire an exact panel-scoped JavaFX radio action (AgentV10)")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--text", required=True, type=non_wildcard)
    sp.add_argument("--expected-selected", action="store_true")
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxselectradio)

    sp = sub.add_parser("fxfirebutton", help="fire an exact enabled panel-scoped JavaFX button (AgentV10)")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--text", required=True, type=non_wildcard)
    sp.add_argument("--expected-disabled", action="store_true")
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxfirebutton)

    sp = sub.add_parser("fxcombooptions", help="list exact options for a panel-scoped JavaFX combo")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--class", dest="node_class", required=True, type=fully_qualified_class)
    sp.add_argument("--id", default="*")
    sp.add_argument("--node-nth", type=int, choices=[0, 1], default=0)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxcombooptions)

    sp = sub.add_parser("fxselectcombo", help="guarded exact JavaFX combo selection")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--class", dest="node_class", required=True, type=fully_qualified_class)
    sp.add_argument("--id", default="*")
    sp.add_argument("--node-nth", type=int, choices=[0, 1], default=0)
    sp.add_argument("--expected", required=True, type=non_wildcard)
    sp.add_argument("--new", dest="new_text", required=True, type=non_wildcard)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxselectcombo)

    sp = sub.add_parser("fxselectdate", help="guarded exact JavaFX DatePicker value selection")
    sp.add_argument("--panel-class", required=True, type=fully_qualified_class)
    sp.add_argument("--class", dest="node_class", required=True, type=fully_qualified_class)
    sp.add_argument("--id", default="*")
    sp.add_argument("--node-nth", type=int, choices=[0, 1], default=0)
    sp.add_argument("--expected", required=True, type=non_wildcard)
    sp.add_argument("--new", dest="new_text", required=True, type=non_wildcard)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxselectdate)

    sp = sub.add_parser("fxfind", help="exact panel-scoped JavaFX node lookup (AgentV10)")
    sp.add_argument("--panel-class", required=True)
    sp.add_argument("--panel-nth", type=int, default=0)
    sp.add_argument("--class", dest="node_class", default="*")
    sp.add_argument("--id", default="*")
    sp.add_argument("--text", default="*")
    sp.add_argument("--limit", type=int, default=50)
    sp.add_argument("--pretty", action="store_true")
    sp.set_defaults(func=cmd_fxfind)

    sp = sub.add_parser("fxstate", help="compact JavaFX semantic state (AgentV7)")
    sp.add_argument("--pretty", action="store_true", help="pretty-print JSON")
    sp.set_defaults(func=cmd_fxstate)

    sp = sub.add_parser("tree", help="dump full component tree as JSON")
    sp.add_argument("--pretty", action="store_true", help="pretty-print JSON")
    sp.set_defaults(func=cmd_tree)

    sp = sub.add_parser("gettext", help="get text of a component by name or text")
    sp.add_argument("selector")
    sp.set_defaults(func=cmd_gettext)

    sp = sub.add_parser("ping", help="check the agent is alive")
    sp.set_defaults(func=cmd_ping)


    args = p.parse_args()
    try:
        args.func(args)
    except (ConnectionRefusedError, socket.timeout, OSError) as e:
        print("[error] could not talk to bridge at %s:%d -- %s" % (args.host, args.port, e), file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()
