#!/usr/bin/env python3
import argparse
import inspect
import json
import os
import re
import select
import ssl
import sys
import time
import weakref
import websocket, ssl
import requests

from enum import Enum
from controlfloor.ios_strings import load_ios_strings
from controlfloor.cfasearch import CFASearch
from controlfloor.cfaframe import *
from controlfloor.cachedattribute import CachedAttribute

# Many element search queries supported by ControlFloor
# take only array arguments.  For convenience, provide a shorthand
# for entering single single values

ARRAY_FIELDS = (
    "elementIds",
    "elementTypes",
    "elementSearchPath",
    "fields",
    "rootElementIds",
    "rootElementTypes",
)

SINGLE_ELEMENT_ARRAY_ALIASES = {
    "id"        : "elementIds",
    "elementId" : "elementIds",
    "type"      : "elementTypes",
    "rootId"    : "rootElementIds",
    "rootElementId" : "rootElementIds",
}

# Some easy-to-type aliases
ALIASES = {
    "ids"       : "elementIds",
    "types"     : "elementTypes",
    "rootIds"   : "rootElementIds",
    "rootTypes" : "rootElementTypes",
}


# Commands which take a single string argument can be entered
# by giving only the string name.
SINGLE_STRING_ACTIONS = {
    "tapText"           : "text",
    "typeText"          : "text",
    "typeSpecialKey"    : "key",
    "secureTapText "    : "secureTextKey",
    "secureTypeText"    : "secureTextKey",
    "performGesture"    : "gesture",
    "launchApplication" : "bundleId",
    "killApplication"   : "bundleId",
    "tapElement"        : "elementId",
}

SINGLE_FLOAT_ACTIONS = {
    "hang"        : "duration",
    "sleep"       : "duration",
    "pushDelay"   : "duration",
    "popDelay"    : "duration",
    "setDelay"    : "duration",
    "pushTimeout" : "duration",
    "popTimeout"  : "duration",
    "setTimeout"  : "duration",
}

_log_traffic = False

class ControlFloorException( Exception ):
    pass

class ErrorResponseException( ControlFloorException ):
    def __init__(self, msg, response):
        super().__init__(msg)
        self.response = response
    def __str__(self):
        x = super().__str__()
        x += "\n" + repr(self.response)
        return x

class AmbiguousElementException( ControlFloorException ):
    def __init__(self, msg, el1, el2):
        super().__init__(msg)
        self.element1 = el1
        self.element2 = el2
    def __str__(self):
        x = super().__str__()
        x += "\n" + self.element1.id +":"+repr(self.element1) + "\n" + self.element2.id+":"+repr(self.element2)
        return x

def dictobjify(x):
    if isinstance(x, list):
        return [dictobjify(x) for x in x]
    if isinstance(x, dict):
        return DictObj( { k:dictobjify(v) for k,v in x.items() } )
    return x

class DictObj(dict):
    def __getattr__( self, attr ):
        return self[attr]
    #def __setattr__( self, attr, value ):
    #    self[attr] = value


# Response components:
# tag     json
# status  ["ok", "error", "pong"]
# error   string
# value   json
# events  ...

# Request components:
# action         string  // Required: tap, doubleTap, text, etc...
# tag            json    // Any valid json, will be echoed back in a reply
# ack            int     // If 1, ack required, else reply *may* be omitted if not data is requested
# args           json    // Arguments to the requested action
# Events                 // TODO: (we have a responsibility to echo some things for perfomance purposes)
# networkTimeout float   // No effect for websocket communication. Set timeout for request on ControlFloor Server
# BinaryData     []byte  // No commands currently use this.
# BinaryLength   int     // Defined to be len(BinaryData). Indicates that binary data will follow in the message stream

def recv():
    r, w, e = select.select([_websocket], [], [] ,_timeout)
    if r and _websocket in r:
        return _websocket.recv()
    return None

_next_message_id = 1
def next_message_id():
    global _next_message_id
    x = _next_message_id
    _next_message_id += 1
    return x

def current_message_id():
    return _next_message_id

_next_object_id = 1
def next_object_id():
    global _next_object_id
    x = _next_object_id
    _next_object_id += 1
    return x


_delay = 0.2
_delay_stack = []
def pushDelay( d ):
    global _delay
    _delay_stack.append( _delay )
    _delay = d

def popDelay():
    global _delay_stack
    if _delay_stack:
        _delay = _delay_stack[-1]
        _delay_stack = _delay_stack[0:-1]

def setDelay(d):
    global _delay
    _delay = d
    _delay_stack.clear()

_synchronous = 0
_synchronous_stack = []
def pushSynchronous( d ):
    global _synchronous
    _synchronous_stack.append( _synchronous )
    _synchronous = d

def popSynchronous():
    if len(_synchronous_stack) > 0:
        _synchronous = _synchronous_stack[-1]
        _synchronous_stack = _synchronous_stack[0:-1]

def setSynchronous(d):
    global _synchronous
    _synchronous = d
    _synchronous_stack.clear()

_timeout = 10
_timeout_stack = []
def pushTimeout(d):
    global _timeout
    _timeout_stack.append( _timeout )
    _timeout = d

def popTimeout():
    if len(_timeout_stack) > 0:
        _timeout = _timeout_stack[-1]
        _timeout_stack = _timeout_stack[0:-1]

def setTimeout(d):
    global _timeout
    _timeout = d
    _timeout_stack.clear()

# This system is designed to be asynchronous.  You can send 20 messages with
# no problem before receiving a single response.  However, if _delay is non-zero,
# wait() will be called between each send_message(), which will effectively
# make the system synchronous.  If you want to stream commands at full speed
# for whatever reason, call setTimeout(0) or pushTimeout(0)
def wait(extra_time = 0):
    recv_until( current_message_id() -1 )
    if extra_time:
        elapsed_time =  time.monotonic() - last_message_received_time
        if extra_time > elapsed_time:
            wait_time = extra_time - elapsed_time
            time.sleep( wait_time )

def waitForTransfer(transfer_message):
    recv_until( 0, transfer_message=transfer_message )

# Written initially so we can pass CFAElements and CFAFrame into methods expecting
# a frame/range (x,y,width,height)
def json_backup_serializer(x):
    if hasattr(x,'dict'):
        return x.dict
    # Will generate the usual exception for unserializable objects
    return x

# Not yet detecting disconnectes, we'll call that a TODO.
# Right now this is for exception-handling on connection, so we know that messages
# cannot be sent to ControlFloor
def connected():
    return _websocket is not None

def send_message( action, args={}, ack=1, allow_errors = False, response_obj = None ):
    if args:
        args2 = dict(args)
        if "self" in args2:
            del(args2["self"])
        for k,v in args.items():
            # We've written CFAgent in a way that it doesn't much appreciate "true" and "false" as bool values
            # Send 1 instead.  ControlFloor is constructed so that "False" never has an effect.  No point in sending.
            if isinstance(v, bool):
                if v:
                    args2[k] = 1
                else:
                    del(args2[k])
            elif k in ARRAY_FIELDS:
                args2[k] = force_list(v)

        args = args2

    # This particular check allows tap() and various other methods
    # To take a string, or a list of strings, as an unnamed default
    # argument. (We have pushed "id" to be the first arg in most methods)
    if "id" in args and args["id"] and ( ( "elementIds" not in args ) or ( not args["elementIds"] ) ):
        if isinstance( args["id"], list ):
            args["elementIds"] = args["id"]
            del( args["id"] )
    # As above, type is the default second argument.
    if "type" in args and args["type"] and ( ( "elementTypes" not in args ) or ( not args["elementTypes"] ) ):
        if isinstance( args["type"], list ):
            args["elementTypes"] = args["type"]
            del( args["type"] )

    for key in list(args.keys()):
        val = args[key]
        if not val:
            del(args[key])
            continue
        if key in SINGLE_ELEMENT_ARRAY_ALIASES:
            actualKey = SINGLE_ELEMENT_ARRAY_ALIASES[key]
            del args[key]
            args[actualKey] = [val]
        elif key in ALIASES:
            actualKey = ALIASES[key]
            del args[key]
            args[actualKey] = val

    message = { "action" : action }

    if ack:
        message["ack"] = 1

    message_id = next_message_id()
    message["tag"] = {"m":message_id}
    if args:
        message["args"] = args

    if _synchronous and message_id > 1:
        recv_until( message_id -1 )

    json_message = json.dumps(message, default = json_backup_serializer)
    if _log_traffic:
        print( "<- " + json_message )
    _websocket.send( json_message )

    if response_obj is not None:
        response_obj.message_id = message_id
    # These constructors have side effects. In particular, populating messages[].
    elif action.startswith( "getElement" ) and not action.startswith( "getElements" ):
        response_obj = CFAElementControlFloorResponse( message_id, action, args, allow_errors = allow_errors )
    else:
        response_obj = ControlFloorResponse( message_id, action, args, allow_errors = allow_errors )

    messages[message_id] = response_obj

    if _delay:
        wait( _delay )

    return response_obj

# Complicated circular references... CFAFrame and CFAPoint can do quite a lot
# without importing core, as long as you never send a message.  This injects
# our send_message call without an import.
CFAFrame.send_message = staticmethod(send_message)
CFAPoint.send_message = staticmethod(send_message)

def cfasearch_send_message(self, action, args={}, ack=1, response_obj = None, allow_errors = False ):
    all_args = dict(self.args)
    if args:
        all_args.update(args)
    if "self" in all_args:
        del(all_args["self"])
    send_message(action, all_args, ack=ack, response_obj = response_obj, allow_errors = allow_errors)
CFASearch.send_message = cfasearch_send_message

class Ops(object):
    @staticmethod
    def gt(a,b):
        return a > b
    @staticmethod
    def lt(a,b):
        return a < b
    @staticmethod
    def ge(a,b):
        return a >= b
    @staticmethod
    def le(a,b):
        return a <= b
    @staticmethod
    def eq(a,b):
        return a == b

    # This function is specifically for testing an element's id (or ids = identifier/label)
    # against either a string (exact match) or regex.
    @staticmethod
    def eqm(a,b):
        # Check both identifiers one-at-a-time...
        if isinstance(a, list) or isinstance(a, tuple):
            result = None
            for a_x in a:
                # Written this way because regex checks return a match object,
                # not True/False
                result = Ops.eqm(a_x, b)
                if result:
                    return result
            return result

        if isinstance(b, re.Pattern):
            return b.search(a)
        return a == b
    @staticmethod
    def ne(a,b):
        return a != b
    @staticmethod
    def matches(a,b):
        return b.search(a)
    @staticmethod
    def __in(a,b):
        return a in b
    @staticmethod
    def contains(a,b):
        return b in a
    @staticmethod
    def startswith(a,b):
        return a.startswith(b)
    @staticmethod
    def endswith(a,b):
        return a.endswith(b)

def force_list(x):
    if x is None:
        return list()
    if isinstance(x, tuple):
        return list(x)
    if not isinstance(x, list):
        return [x,]
    return x

def force_tuple(x):
    if x is None:
        return ()
    if not isinstance(x, list) and not isinstance(x, tuple):
        return (x,)
    return x

class CFAElementList(list):
    def __init__(self, parent, get_args, elements):
        elements = sorted(elements,  key=lambda e:((e.y,e.x,e.height,e.width,e.elementType)) )
        dup_check = {}
        elements2 = list()
        for e in elements:
            if e in dup_check:
                if hasattr(e,"value") and not hasattr(dup_check[e], "value"):
                    self.elements2.remove(e)
                else:
                    continue
            dup_check[e] = e
            elements2.append(e)
        super().__init__(elements2)
        self.get_args = get_args

    def __contains__( self, id ):
        if isinstance(id, CFAElement):
            return super().__contains__(id)
        return self.contains(id)

    def __getattr__( self, name ):
        return self.get(name)

    def contains(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None):
        elementIds = force_tuple(elementIds)
        elementTypes = force_tuple(elementTypes)

        for e in self:
            if elementIds:
                for id in elementIds:
                    if Ops.eqm(e.ids, id):
                        break
                else:
                    continue
            if (not elementTypes) or (e.elementType in elementTypes):
                return True
        return False

    # For exception strings.
    def _checks_to_error_message(self, base_message, custom_checks):
        pieces = [base_message,]
        for op, attr_name, values, original_values in custom_checks:
            original_values = original_values or values
            if attr_name:
                prefix = attr_name + "__" + op.__name__ + " = "
            else:
                prefix = op.__name__ + " ="
            pieces.append( prefix + repr(values) )
        return "\n".join(pieces)

    def __getitem__(self, id):
        if isinstance(id, int):
            return super().__getitem__(id)
        if isinstance(id, slice):
            return super().__getitem__(id)
        return self.get(id)

    def all(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, **kwargs):
        kwargs["all"] = True
        kwargs["optional"] = True
        kwargs["exceptions"] = False
        kwargs["first"] = False
        kwargs["index"] = None
        return self.get(elementIds, elementTypes, **kwargs)

    def identify( self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, optional: bool = False, first: bool = False, first_above = None, first_below = None, first_left_of = None, first_right_of = None, index=None, **kwargs ) -> 'CFAElement':
        kwargs["exceptions"] = False
        kwargs["unidentified"] = True
        kwargs["all"] = False
        return self.get( elementIds, elementTypes, optional = optional, first = first, first_above = first_above, first_below = first_below, first_left_of = first_left_of, first_right_of = first_right_of, index = index, identify = True, **kwargs)

    def get( self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, optional: bool = False, exceptions: bool = True, first: bool = False, unidentified = False, first_above = None, first_below = None, first_left_of = None, first_right_of = None, index=None, all: bool = False, identify=False, **kwargs ) -> 'CFAElement':
        original_args = locals()
        del(original_args["self"])

        custom_checks = []
        ids = force_tuple(elementIds)
        types = force_tuple(elementTypes)

        # If you passed in a missing element as a search parameter, then this element will also be invalid
        all_kwargs = dict(kwargs)
        all_kwargs["first_above"]    = first_above
        all_kwargs["first_below"]    = first_below
        all_kwargs["first_right_of"] = first_right_of
        all_kwargs["first_left_of"]  = first_left_of
        for k,v in all_kwargs.items():
            if isinstance(v, CFAMissingElement) or isinstance(v, CFAAmbiguousElement):
                if exceptions and not all:
                    raise Exception("Searching for an element using a previously unidentifiable element...")
                return CFAMissingElement(self, original_args)

        if ids:
            custom_checks.append((Ops.eqm,"ids", ids, None))
        if types:
            custom_checks.append((Ops.eq, "elementType", types, None))

        if kwargs and len(self):
            for attr_name,values in kwargs.items():
                values = force_tuple(values)
                el = self[0] #Example element to check attributes
                if hasattr(CFAFrame, attr_name):
                    frame_attr = getattr(CFAFrame, attr_name)
                    #ie. left_of=(frames...)
                    if callable(frame_attr):
                        custom_checks.append( (frame_attr, None, values, None) )
                    else:
                        raise Exception("Reference to non-callable CFAFrame attribute?")
                else:
                    op = Ops.eq
                    pieces = attr_name.split("__")
                    if len(pieces) > 2:
                        raise Exception("Chaining attribute calls not supported")
                    if len(pieces) == 2:
                        if not hasattr(Ops, pieces[1]):
                            raise Exception("Unknown comparison operation: " + pieces[1])
                        op = getattr(Ops, pieces[1])
                        attr_name = pieces[0]
                    if not hasattr(el, attr_name):
                        raise Exception("Unrecognized attribute name: \"%s\"" % (attr_name,))
                    el_attr = getattr(el, attr_name)
                    if callable(el_attr):
                        raise Exception("Attribute " + attr_name + " is a function")

                    original_values = values
                    values = []
                    for v in original_values:
                        if hasattr(v,attr_name):
                            values.append( getattr(v,attr_name) )
                        else:
                            values.append( v )

                    custom_checks.append( (op, attr_name, values, original_values) )

        matches = []
        for e in self:
            #print(str(len(self.elements))+" "+repr(e.frame))
            all_checks_passed = True

            if unidentified and e.identified:
                continue

            for op, attr_name, values, _ in custom_checks:
                for v in values:
                    e_val = e
                    if attr_name:
                        e_val = getattr(e, attr_name)
                    c = op(e_val, v)
                    #print("%s %s %s %s %s %s" %(attr_name, e.id, repr(op), c, v, type(v)))
                    if c:
                        if op == Ops.eqm:
                            e.matches = c
                        break
                else:
                    #if not ids:
                    #    print( repr((op, attr_name)))
                    #    print(repr(values))
                    #print("%s %s %s" %(attr_name, e.python_variable_name, repr(op)))
                    all_checks_passed = False
                    break

            if not all_checks_passed:
                continue

            if first:
                return e

            matches.append(e)

        if not matches and not optional and exceptions:
            id_str = ",".join([repr(id) for id in ids])
            type_str = ""
            if types:
                type_str = "(" + ( ",".join(types) ) + ")"
            raise KeyError( self._checks_to_error_message("Element search did not return an item", custom_checks) )

        # From within these matches, check left_of, right_of, above, below for potential closes match....
        if len(matches):
            if first_below:
                closest = None
                closest_distance = -1
                for e in matches:
                    if e.below(first_below):
                        distance = e.y - first_below.y
                        if (not closest) or distance < closest_distance or (distance == closest_distance and (e.x < closest.x)):
                            #print("%s %s is below %s %s" % (e.python_variable_name, repr(e.tuple), first_below.python_variable_name, repr(first_below.tuple)))
                            closest = e
                            closest_distance = distance
                if closest:
                    matches = [closest]
                else:
                    matches = []
            if first_right_of:
                closest = None
                closest_distance = -1
                for e in matches:
                    if e.right_of(first_right_of):
                        distance = e.x - first_right_of.x
                        if (not closest) or distance < closest_distance or (distance == closest_distance and (e.y < closest.y)):
                            #print("%s %s is right_of %s %s" % (e.python_variable_name, repr(e.tuple), first_right_of.python_variable_name, repr(first_right_of.tuple)))
                            closest = e
                            closest_distance = distance
                if closest:
                    matches = [closest]
                else:
                    matches = []
            if first_left_of:
                closest = None
                closest_distance = -1
                for e in matches:
                    if e.left_of(first_left_of):
                        distance = first_left_of.x - e.x
                        if (not closest) or distance < closest_distance or (distance == closest_distance and (e.y < closest.y)):
                            #print("%s %s is left_of %s %s" % (e.python_variable_name, repr(e.tuple), first_left_of.python_variable_name, repr(first_left_of.tuple)))
                            closest = e
                            closest_distance = distance
                if closest:
                    matches = [closest]
                else:
                    matches = []
            if first_above:
                closest = None
                closest_distance = -1
                for e in matches:
                    if e.above(first_above):
                        distance = first_above.y - e.y
                        #print("%s %s is above %s %s" % (e.python_variable_name, repr(e.tuple), first_above.python_variable_name, repr(first_above.tuple)))
                        if (not closest) or distance < closest_distance or (distance == closest_distance and (e.x < closest.x)):
                            closest = e
                            closest_distance = distance
                if closest:
                    matches = [closest]
                else:
                    matches = []

        if len(matches) > 1 or all:
            if index is not None:
                if index >= len(matches):
                    return CFAMissingElement(self, original_args)
                return matches[index]
            if exceptions and not all:
                raise AmbiguousElementException( self._checks_to_error_message("Element search did not return a unique item.", custom_checks),  matches[0], matches[1] )
            if all:
                return CFAElementList(self, original_args, matches)
            return CFAAmbiguousElement(self, original_args, matches)

        if matches:
            if identify:
                matches[0].identified = True
            return matches[0]

        return CFAMissingElement(self, original_args)
        #return None
    #def remove_frames( self, frames ):
    #    frames_dict = {}
    #    for f in frames:
    #        frames_dict[(f.x,f.y,f.width,f.height)] = 1
    #
    #    for e in list(self.elements):
    #        if (e.x, e.y, e.width, e.height) in frames_dict:
    #            del(self.element_dup_check_dict[e])
    #            self.elements.remove(e)


    #@staticmethod
    #def merge( self, *responses ):
    #    for r in responses:
    #        if not r.loaded:
    #            recv_until( r.messageId )
    #        if r.messageType != "Elements":
    #            raise Exception("Only element-set responses can be merged")
    #        if not r.ok:
    #            raise Exception("One of the element sets to be merged was returned from CFAgent with an error")
    #
    #    result = ControlFloorResponse(-1)
    #    result.loaded = True
    #    result.status = "ok"
    #    result.error = None
    #    result.tag = None
    #    result.ok = True
    #    result.messageType = "Elements"
    #
    #    result.elements = result._ = []
    #    result.element_dup_check_dict = {}

    #    for r in responses:
    #        for e in r.elements:
    #            if e in result.element_dup_check_dict:
    #                continue
    #            result.element_dup_check_dict[e] = 1
    #            result.elements.append(e)
    #    return result



messages = weakref.WeakValueDictionary()
class ControlFloorResponse(object):
    def __init__( self, message_id, action = None, args = None, allow_errors = False ):
        self.messageId = message_id
        self.loaded = False
        self.action = action
        self.args = args
        self.allow_errors = allow_errors

    def resend(self):
        if not self.action:
            raise Exception("This ControlFloorResponse object has no associated action")
        return send_message( self.action, self.args )

    @property
    def elements(self):
        if not self.loaded:
            recv_until( self.messageId, True )
        if not hasattr(self, '_elements'): # self.messageType != "Elements":
            raise Exception("This is not an element-set query")
        return self._elements

    def reload(self):
        if not self.loaded:
            recv_until( self.messageId, True )
        self.loaded = False
        self.messageId = self.status = self.error = self.tag = self.messageType = self.ok = self._ = None
        self.element_dup_check_dict = None
        self._elements = None
        send_message( self.action, self.args, response_obj = self )

    def load( self, response, binaryData ):
        self.loaded = True
        self.status = response["status"]
        self.error = response.get( "error", "" )
        self.tag = response["tag"]
        self.messageType = response["type"]
        self.ok = self.status == "ok"
        self._ = response.get( "value", "" )
        if binaryData:
            self.binaryData = binaryData

        if "transferId" in self._:
            if "finalStatus" not in self.__dict__:
                self.finalStatus = None

        # Note, there is a behavior we take advantage here.  If you call load() twice, new values will clobber
        # old values.  For file transfers, transferProgress messages are all sent with the initial message ID,
        # so progress gets gradually updated/replaced in the original message.
        if isinstance( self._, dict ):
            for key,val in self._.items():
                self.__dict__[key] = val
        # This check got hacky... I want to re-vamp getElements on CFAgent to generally never
        # fail for not-enough-elements... Doesn't return a message type of "Elements" on error...
        elif self.messageType == "Elements" or ('Elements' in self.action and self.error):
            if isinstance( self._, list ):
                self._ = [CFAElement(e) for e in self._]
            else:
                self._ = []
            self._elements = CFAElementList(self, None, self._)
            # TODO: something fancy for non-element list responses?
            # else:
            #    raise Exception("Write some code for unhandled 'list' response type: " + self.messageType)
        else:
            # Requests that return a single primitive type (int, string, etc.) can be accessed with a simple .value
            self.value = self._ = dictobjify(self._)

    def __iter__( self ):
        yield from self.elements

    def __len__(self):
        return len(self.elements)

    def __bool__( self ):
        if not self.loaded:
            recv_until( self.messageId, True )
        if self.error:
            return False
        if self.messageType == "Elements":
            return len(self.elements) > 0
        return self.ok

    def remove(self, obj):
        self.elements.remove(obj)

    def contains(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] = None ):
        return self.elements.contains(elementIds, elementTypes)

    def all(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, **kwargs):
        return self.elements.all(elementIds, elementTypes, **kwargs)

    def identify( self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, optional: bool = False, first: bool = False, first_above = None, first_below = None, first_left_of = None, first_right_of = None, index=None, **kwargs ) -> 'CFAElement':
        return self.elements.identify(elementIds, elementTypes, optional = optional, first = first, first_above = first_above, first_below = first_below, first_left_of = first_left_of, first_right_of = first_right_of, index = index, **kwargs)

    def get( self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, optional: bool = False, exceptions: bool = True, first: bool = False, unidentified = False, first_above = None, first_below = None, first_left_of = None, first_right_of = None, index=None, all: bool = False, **kwargs ) -> 'CFAElement':
        return self.elements.get(elementIds, elementTypes, optional = optional, exceptions = exceptions, first = first, unidentified = unidentified, first_above = first_above, first_below = first_below, first_left_of = first_left_of, first_right_of = first_right_of, index = index, all = all, **kwargs)
    # Enable:
    # if "button_name" in controlFloorResponse:
    def __contains__( self, id ):
        return self.elements.contains(id)

    # This method is primarily meant for working with multiple element queries
    # on the same page with different search params, where the same element might
    # be returned in both sets.
    #
    # It can also be used to remove a list of "known" elements
    #def remove_elements( self, other_elements ):
    #    for other in other_elements:
    #        if other in self.element_dup_check_dict:
    #            self.elements.remove(other)
    #            del(self.element_dup_check_dict[e])

    def __getitem__(self, id):
        if isinstance(id, int):
            return self.elements[id]
        if isinstance(id, slice):
            return self.elements.__getitem__(id) #[id]
        return self.get(id)

    def __getattr__( self, name ):
        if not self.loaded:
            recv_until( self.messageId, True )
        if "_elements" in self.__dict__:
            return self._elements[name]
        return self.__getattribute__( name )

class CFAMissingElement(object):
    def __init__(self, parent, get_args):
        self.parent   = parent
        self.get_args = get_args
        self.optional = get_args["optional"]
    def __bool__( self ):
        return False

class CFAAmbiguousElement(CFAElementList):
    def __bool__(self):
        return False

class CFAElement(CFAFrameMixin):
    @property
    def id(self):
        if self.identifier:
            return self.identifier
        return self.label

    @property
    def ids(self):
        if self.identifier:
            if (not self.label) or self.identifier == self.label:
                return (self.identifier, )
            return (self.identifier, self.label)
        return (self.label,)

    def __hash__(self):
        if not hasattr(self, '_hash2'):
            self._tuple = (self.x, self.y, self.width, self.height, self.label, self.identifier, self.elementType)
            self._hash = hash(self._tuple)
        return self._hash

    def __eq__(self, other):
        if not isinstance(other, CFAElement):
            return False
        if not hash(self) == hash(other):
            return False
        return self._tuple == other._tuple

    def __repr__(self):
        return (self.id or 'unnamed')+ "-" + self.elementType + ":"+super().__repr__()

    @CachedAttribute
    def by_id(self):
        if not self.id:
            raise Exception("This element has no identifier")
        if not self.elementType:
            raise Exception("This element has no type")
        return CFASearch(self.id, self.elementType)

    def matchingElementAtCenter(self) -> 'CFAElement':
        e = self.getElementAtCenter()
        if not e:
            return None

        a = (self.x, self.y, self.width, self.height, self.elementType)
        b = (e.x, e.y, e.width, e.height, e.elementType)
        print(repr(a))
        print(repr(b))
        if a != b:
            return None

        return e

    def matchesElementAtCenter(self) -> bool:
        return self.matchingElementAtCenter() is not None

    def reloadFromElementAtCenter(self) -> bool:
        e = self.getElementAtCenter()
        if not e: #Not possible, there is *always* an element at any point, even if it's the entire app
            return False

        a = (self.x, self.y, self.width, self.height, self.elementType)
        b = (e.x, e.y, e.width, e.height, e.elementType)
        print(repr(a))
        print(repr(b))
        if a != b:
            return False

        if hasattr(self, '_hash'):
            delattr(self, '_hash')

        self.label = e.label
        self.identifier = e.identifier
        return True

    def __ne__(self, other):
        return not self.__eq__(other)

    def load_data(self, data):
        self.args = {}

        if data != self.__dict__:
            self.__dict__.update(data)
        d = self.__dict__

        for k in ["value","label","identifier"]:
            if k not in d:
                d[k] = ""

        for k in ["selected"]:
            if k not in d:
                d[k] = False

        for k in ["processId"]:
            if k not in d:
                d[k] = 0

        if d.get( "isSystemElement", 0 ):
            self.args["elementSearchPath"] = ( "system", )

        if "elementType" in d and d["elementType"] not in ( "other", "unknown" ):
            self.args["elementTypes"] = ( data["elementType"], )

        if self.id != "":
            self.args["elementIds"] = ( self.id, )

        # This would ordinarily be set by the CFAFrame __init__ method
        if "x" in d and "y" in d and "width" in d and "height" in d:
            self.maxX = self.x + self.width
            self.maxY = self.y + self.height

    def __init__(self, data):
        self.load_data(data)
        self.matches = None
        self.identified = False #Set to try when returned as a unique result to get()

class CFAElementControlFloorResponse(ControlFloorResponse, CFAElement):
    def load( self, response, binaryData ):
        super().load( response, binaryData )
        self.load_data( self.__dict__ )

class CFAForm:
    # This is just to make quick command-line tests easier
    @classmethod
    def run(cls):
        from controlfloor.core import main
        main()
        obj = cls()
        return obj

    def __init__(self, previous_form = None, validate = True ):
        self.previous_form = previous_form
        self.load_elements()
        if validate:
            self.check_elements()

    def load_elements(self):
        pass

    def refresh(self):
        self.load_elements()

    @property
    def ambiguous_elements(self):
        return {k:v for k,v in self.__dict__.items() if isinstance(v, CFAAmbiguousElement ) }

    @property
    def missing_elements(self):
        return {k:v for k,v in self.__dict__.items() if isinstance(v, CFAMissingElement ) and not v.optional }

    def has_element(self, element_name):
        return hasattr(self, element_name) and isinstance(self.element_name, CFAElement)

    #def contains(self,  elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None ):
    #    responses = [v for v in self.__dict__.values() if isinstance(v, ControlFloorResponse)]
    #    for r in responses:
    #        if r.contains(elementIds, elementTypes):
    #            return True

    #def all(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None,  optional: bool = True, exceptions: bool = False, **kwargs):
    #    kwargs["all"] = True
    #    return self.get(elementIds, elementTypes, optional = optional, exceptions = False, **kwargs)

    #def get(self, elementIds: str | list[str] | re.Pattern | list[re.Pattern] = None, elementTypes: str | list[str] | bool = None, optional: bool = False, exceptions: bool = True, all: bool = False, **kwargs):
    #    responses = [v for v in self.__dict__.values() if isinstance(v, ControlFloorResponse)]
    #    for i, r in enumerate(responses):
    #        if i == len(responses) - 1:
    #            return r.get(elementIds, elementTypes, optional, exceptions, **kwargs)
    #        else:
    #            x = r.get( elementIds, elementTypes, True, False, **safe_kwargs)
    #            if isinstance(x, CFAElementList):
    #                # Just ask again to get the proper exception thrown
    #                # Note: if there are multiple sets, and an earlier set contains one matching item, and a later
    #                # contains duplicates, we won't currently notice that...
    #                if exceptions:
    #                    r.get( elementIds, elementTypes, optional, exceptions, **safe_kwargs)
    #                return x
    #            if x:
    #                return x

    def check_elements(self):
        if self.missing_elements:
             raise Exception("Missing Elements: " + repr(list(self.missing_elements.keys())) )
        if self.ambiguous_elements:
             raise Exception("Ambiguous Elements: " + repr(list(self.ambiguous_elements.keys())) )

    # This method is called from our generated init() functions to provide a chance
    # to dismiss occational elements, or do other sanity checking before the main
    # load_elements call.
    def before_load_elements(cls, *element_collections):
        return True

# Read messages until either:
# 1. A message with the specified message_id is encountered.
# 2. A message with the message_id matching the transfer_message is encountered, with a 'status' value set
#
# Transfers now send transferProgress messages re-using the message ID corresponding to the initial request.
# Messages will be sent until the message succeeds or fails, at which point the "status" field will be populated
last_message_received_time = 0
last_message_received_id = 0
def recv_until(message_id, required=False, transfer_message=None):
    global last_message_received_time
    global last_message_received_id
    # If "required", expecting to load data for the specified message id
    # Otherwise we're just waiting to progress sufficiently in message processing
    if message_id and last_message_received_id >= message_id:
        if required:
            raise Exception("Out-of-order message received....") # TODO.
        else:
            return

    if not message_id and not transfer_message:
        return

    while True:
        response_json = recv()

        if not response_json:
            raise Exception( "Message timed out... (timeout=%0.2fs)" %(_timeout) )

        response = json.loads( response_json )

        last_message_received_time = time.monotonic()
        if _log_traffic:
            print( "-> " + response_json )

        if not response:
            return None
        e = None
        if response.get("status","") == "error":
            error_message = response.get("error","unknown error")
            e = ErrorResponseException( error_message, response )

        binaryData = None
        if response.get("binaryLength"):
            binaryData = recv()
            if not binaryData:
                raise Exception( "Message timed out while waiting for binary attachment of length " + str(response.get("binaryLength")) )

        done = False
        if "tag" in response:
            tag = response["tag"]
            m = tag.get("m",0)
            if m in messages:
                messages[m].load( response, binaryData )
                # Cannot naively use hasattr() here due to our own wonky overloading of responses with element lists
                if "transferId" in messages[m].__dict__:
                    if hasattr( messages[m], "progress" ):
                        messages[m].progress(messages[m])
                if transfer_message and transfer_message.tag["m"] == m:
                    if transfer_message.finalStatus:
                        done = True
                    # Our usual errors don't contain data in their response.  Maybe that would be fine... for the moment
                    # the "status" of "TransferProgress" message is always "ok", and the error is embedded in the data,
                    # which ends up setting the exact same "error" field when .load() is called.
                    if transfer_message.finalStatus and transfer_message.finalStatus != "COMPLETE" and not e and not transfer_message.allow_errors:
                        e = ErrorResponseException( transfer_message.error, response )
                if e and messages[m].allow_errors:
                    e = None
            if message_id and m >= message_id:
                done = True
            if m > last_message_received_id:
                last_message_received_id = m
        if e:
            print( response_json )
            raise e
        if done:
            return

_websocket = None
deviceInfo = DictObj()
def cfconnect( ws_url, cookie_string = None, self_signed = False ):
    global _websocket

    if _websocket:
        raise Exception( "Called cfconnect() multiple times" )
    ssl_opt = {}
    if self_signed:
         ssl_opt ={"cert_reqs": ssl.CERT_NONE}
    _websocket = websocket.WebSocket( sslopt=ssl_opt )

    _websocket.connect( ws_url, cookie = cookie_string )

    info = send_message( "getCFAgentDeviceInformation" )

    if not info.locale:
        raise Exception( "getCFAgentDeviceInformation did not return device locale" )

    # In python you can compare tuples easily, so (15, 1) is more useful than 15.1
    # ie:
    #     if deviceInfo.osVersion >  (14,3): ...
    #     if deviceInfo.osVersion >= (14,0): ...
    #     if deviceInfo.osVersion[0] == 14: ...
    deviceInfo.__dict__.update(info._) # osVersion, timeZone, locale, status, initializing, licensed, name, logicalWidth, logicalHeight
    deviceInfo.osVersion = deviceInfo.osVersion.split(".")
    deviceInfo.frame = CFAFrame(0,0,info.logicalWidth, info.logicalHeight)

    load_ios_strings( info.locale )

def log_traffic(enable: bool = True):
    global _log_traffic
    _log_traffic = enable

def main( run_function = None, add_opts = None, cookie_string = None ):
    parser = argparse.ArgumentParser( prog="run.py", description="ControlFloor Script Runner", allow_abbrev=False )
    parser.add_argument( "-s", "--script" )
    parser.add_argument( "-t", "--test-dir",     help="Run all tests in the specified folder" )
    parser.add_argument( "-u", "--udid",         help="Device UDID (CF_UDID)" )
    parser.add_argument( "-c", "--controlfloor", help="Address of ControlFloor server (CF_HOST)" )
    parser.add_argument(       "--username",     help="ControlFloor username (CF_USERNAME)" )
    parser.add_argument(       "--password",     help="ControlFloor password (CF_PASSWORD)" )
    parser.add_argument(       "--cookie",       help="Cookie with session info when using external authentication(nginx, etc.) (CF_COOKIE)" )
    parser.add_argument(       "--http",         help="Connect to ControlFloor with http instead of https (CF_USE_HTTP=1)" )
    parser.add_argument(       "--self-signed",  action="store_true", default=False, help="Connect to ControlFloor without SSL cert verification (CF_SELF_SIGNED=1)")
    parser.add_argument(       "--synchronous",  action="store_true", default=False, help="Only send request after previous request is acked" )
    parser.add_argument( "-l", "--log-traffic",  action="store_true", default=False, help="Echo raw JSON traffic to/from CFAgent to stdout" )

    cfaform_subclasses = CFAForm.__subclasses__()

    if cfaform_subclasses:
        parser.add_argument( '--run-function', help="Run the specified automation module. Assumes the application is already in the correct starting state" )
        parser.add_argument( '--list-functions', default=False, action="store_true" )
        parser.add_argument( '--string-arg', action="append", type=str, help="Pass a single string argument to the specified function." )
        parser.add_argument( '--int-arg',    action="append", type=int, help="Pass a single int argument to the specified function." )

    if add_opts:
        add_opts( parser )

    opts = parser.parse_args()

    args = []
    if cfaform_subclasses and opts.string_arg:
        args = args + list(opts.string_arg)
    if cfaform_subclasses and opts.int_arg:
        args = args + list(opts.int_arg)

    if not opts.controlfloor:
        if "CF_HOST" in os.environ and os.environ["CF_HOST"]:
            opts.controlfloor = os.environ["CF_HOST"]
        else:
            print("ControlFloor host must be specified with --controlfloor or the CF_HOST environment variable")
            sys.exit(1)

    if not opts.self_signed:
        if "CF_SELF_SIGNED" in os.environ:
            opts.self_signed = os.environ["CF_SELF_SIGNED"].upper() not in ("", "0", "FALSE", "F")

    if not opts.http:
        # This should only exist if you want to force HTTP, so usually I wouldn't care what you set it to.
        # But some people might try to disable an existing entry.
        if "CF_USE_HTTP" in os.environ:
            opts.http = os.environ["CF_USE_HTTP"].upper() not in ("", "0", "FALSE", "F")

    if not opts.udid:
        if "CF_UDID" in os.environ and os.environ["CF_UDID"]:
            opts.udid = os.environ["CF_UDID"]
        else:
            print("Device udid must be specified with --udid or the CF_UDID environment variable")
            sys.exit(1)

    if not opts.username:
        if "CF_USERNAME" in os.environ:
            opts.username = os.environ["CF_USERNAME"]

    if not opts.password:
        if "CF_PASSWORD" in os.environ:
            opts.password = os.environ["CF_PASSWORD"]

    if opts.synchronous:
        setSynchronous(1)

    log_traffic(opts.log_traffic)

    if opts.http:
        ws_url = "ws://"
        status_url = login_url = "http://"
    else:
        ws_url = "wss://"
        status_url = login_url = "https://"

    ws_url     += opts.controlfloor + "/device/webSocket?udid=" + opts.udid
    login_url  += opts.controlfloor + "/login"
    status_url += opts.controlfloor + "/device/info/json?udid=" + opts.udid

    if not cookie_string:
        if opts.cookie:
            cookie_string = opts.cookie
        elif "CF_COOKIE" in os.environ:
            cookie_string = os.environ["CF_COOKIE"]

    # No user/pass implies we can open a websocket without authentication
    if opts.username or opts.password:
        if cookie_string:
            print("username/password cannot be provided when a custom cookie is specified.\n\tCookie: " + cookie_string)
            sys.exit(1)
        if not opts.username:
            print("password specified via --password or CF_PASSWORD, but username not set.")
            sys.exit(1)
        # Allow username + blank password?
        if not opts.password:
            print("username specified via --username or CF_USERNAME, but password not set.")
            sys.exit(1)

        login_info = {
           'username': opts.username,
           'password': opts.password,
        }
        s = requests.Session()
        p = s.post(login_url, data=login_info, verify = not opts.self_signed)
        session_token = s.cookies.get("session")
        cookie_string ="session=" + session_token

        response = s.get(status_url, verify = not opts.self_signed)
        if response.status_code != 200:
            raise Exception("Received status code %d from ControlFloor Server" % (response.status_code,))
        status = response.json()
        if status.get("error"):
            raise Exception("Received error from ControlFloor Server: %s" % (status["error"],))
        if "providerStatus" not in status:
            raise Exception("No 'providerStatus' field found in ControlFloor device info response")
        if status["providerStatus"] != "Connected":
            raise Exception("Provider not connected... Current Status: %s\nDetail: %s" % (status["providerStatus"], status["providerStatusDetail"], ))

    cfconnect( ws_url, cookie_string, opts.self_signed )

    if cfaform_subclasses and opts.list_functions:
        class_stack = list(cfaform_subclasses)
        while class_stack:
            cls = class_stack[0]
            class_stack = class_stack[1:]
            more_subclasses = cls.__subclasses__()
            if more_subclasses: #depth-first, display each class with its children
                class_stack = list(more_subclasses) + class_stack
                continue
            for (f_name,f) in inspect.getmembers(cls, predicate=inspect.isfunction):
                if hasattr(CFAForm, f_name):
                #if f_name in ("init", "__init__", "displayed", "run"):
                    continue
                print(cls.__module__+"."+cls.__name__+"."+f_name)
        sys.exit(0)

    if cfaform_subclasses and opts.run_function:
        pieces = opts.run_function.split(".")

        module_name = ""

        if len(pieces) < 2:
            print("Invalid module name \"%s\". Run --list-functions to see valid modules" %(opts.run_function,))
            sys.exit(1)

        if len(pieces) > 2:
            module_name = ".".join(pieces[0:-2])
        class_name = pieces[-2]
        run_function_name = pieces[-1]

        run_function = None

        class_stack = list(cfaform_subclasses)

        while class_stack:
            cls = class_stack[0]
            class_stack = class_stack[1:]
            more_subclasses = cls.__subclasses__()
            if more_subclasses: #depth-first, display each class with its children
                class_stack = list(more_subclasses) + class_stack

            # We can run a few more functions than we actually display, such as the init() methods...
            if cls.__name__ == class_name and module_name == cls.__module__:
                for (f_name,f) in inspect.getmembers(cls, predicate=inspect.isfunction):
                    if f_name == run_function_name:
                        run_function = f
                break

        if not run_function:
            print("Invalid module name. Run --list-functions to see valid modules")
            sys.exit(1)
        instance = cls()
        if run_function.__name__ != "__init__":
            result = run_function(instance, *args)
            if result is not None:
                print(repr(result))
        sys.exit(0)

    if run_function:
        if add_opts:
            run_function( opts )
        else:
            run_function()

if __name__ == "__main__":
    main()

def cfrun( name, run_function, add_opts = None ):
    if name == "__main__":
        main( run_function, add_opts )
