import os
import sys
import logging
import traceback
import serializer
from serializer import Boolean, Integer, RiskscapePyException, Text

from inspect import signature, Parameter
from serializer import Number, RiskscapePyException

# comment out this next line for debugging to file
#logging.basicConfig(filename='debug.txt', level=logging.DEBUG)
logger = logging.getLogger('RiskScape')

#
# Code shared between the exec and function call adapters
#

def extract_callable(filepath, expected_args, args_mismatch_problem):
    """Dynamically import the user's function"""
    # First, we need to add the dir that the file is in to our path
    module_dir = os.path.abspath(os.path.dirname(filepath))
    # we prepend so we try to load the user's file first. This prioritizes loading
    # their filename over existing python modules. Otherwise, calling the file
    # 'test.py' causes problems because there's already a built-in 'test' python
    # module
    sys.path.insert(0, module_dir)
    # import the python file itself
    filename_no_ext = os.path.splitext(os.path.basename(filepath))[0]
    module = __import__(filename_no_ext)

    # sanity-check there's a callable function in this file we can use
    if hasattr(module, 'FUNCTION'):
        check_callable(module.FUNCTION, 'FUNCTION', expected_args, args_mismatch_problem)
        return module.FUNCTION
    elif hasattr(module, 'function'):
        check_callable(module.function, 'function', expected_args, args_mismatch_problem)
        return module.function
    else:
        raise RiskscapePyException("No 'def function' present in " +
            filepath + ". Please name the method you want to execute "
            "'function'.")

def check_callable(x, name, expected_args, args_mismatch_problem):
    """Check that x is a callable function and has the expected number of arguments
    @param x the callable object
    @param name then name of the function to use in an error message
    @param expected_args number of args the function should have
    """
    if not callable(x):
        raise RiskscapePyException("'" + str(name) +
            "' is not a callable function")

    # sanity-check the number of args is sensible
    params = signature(x).parameters
    positional_args = 0
    total_args = 0
    for p in params.values():
        # *args case: user is advanced enough we can just leave them alone
        if p.kind == Parameter.VAR_POSITIONAL:
            return
        if p.kind == Parameter.POSITIONAL_OR_KEYWORD:
            total_args += 1
        if p.default == Parameter.empty:
            positional_args += 1

    # give the user a nudge if their python args don't line up with the INI file
    if expected_args < positional_args:
        actual_str = '{0} positional'.format(positional_args)
        raise RiskscapePyException(args_mismatch_problem(positional_args, actual_str, expected_args))
    if total_args < expected_args:
        actual_str = '{0} positional or keyword'.format(total_args)
        raise RiskscapePyException(args_mismatch_problem(total_args, actual_str, expected_args))

def stop_sdterr_blocking():
    """
    Attempt to use fcntl to make stderr non blocking to avoid deadlocks
    """
    try:
        import fcntl
        # get full buffer to throw an exception instead of blocking on full stderr - this is better than a
        # deadlock.  Note that we won't be able to (easily) give the user an error message, as stderr is
        # full and won't be able to accept a message.  We could swap sys.stderr for a StringIO object and
        # manage the buffer ourselves, but if we take care on the Java side to read the bytes from stderr
        # as they become available, then this becomes very unlikely to happen
        errfd = sys.stderr.fileno()
        curr_flags = fcntl.fcntl(errfd, fcntl.F_GETFL)
        fcntl.fcntl(errfd, fcntl.F_SETFL, curr_flags | os.O_NONBLOCK)
    except:
        pass # ah well, we tried

def handle_function_call_failure(output, e):
    """
    Reports a function call error back to RiskScape
    """
    logger.debug("Failed to call user function : {0}".format(e))
    # get any logging/debugging  bytes to java ASAP
    sys.stderr.flush()

    # tell java to expect an error message
    serializer.write('Integer', output, -1)
    output.flush()

    # Format a stack trace, omitting the current line of execution (which is
    # this wrapper script, which the user won't know about)
    tb_string = '\n'.join(traceback.format_tb(e.__traceback__.tb_next))

    # Now format the exception part - nb exception comes from python internals
    exc_string = traceback.format_exc(limit=0)

    # send 'em to the waiting arms of the JVM
    Text.toBytes(output, exc_string + '\n' + tb_string, None)
    output.flush()

    # NB we flush stderr after getting control back from the user's function so we can get any
    # debugging messages back to the user ASAP.  If we did this after flushing stdout, there's
    # the possibility java will see the response and then dispose of the cpython process before
    # stderr ever gets flushed.
    sys.stderr.flush()

def initialize_io():
    """
    Prepare process streams (in, out & err) for IPC between the adapter and RiskScape
    @return a tuple of (input, output) to use for reading and writing tuples.
    """

    stop_sdterr_blocking()

    # RiskScape will start serializing the args for each function call directly
    # to our stdin
    input = sys.stdin.buffer

    # We serialize the function call return-value/result to our stdout
    output = os.fdopen(sys.stdout.fileno(), "wb", closefd=False)

    # we don't want user print() statements messing with our serialization, so set stdout to be stderr, too.
    # this way the user gets to see *something* (and they're not likely to care about the distinction when
    # running their scripts with RiskScape
    sys.stdout = sys.stderr

    return (input, output)

def ready(output):
    """
    Tell RiskScape we have come up succesfully
    """
    Boolean.toBytes(output, True, None)
    output.flush()

#
# A list of filenames to pass back to RiskScape for it to include in the model's
# outputs.
#
dynamic_outputs = []

def model_output(filename):
    """
    Register a path as a model output.  Needs to exist and be writable when the
    function returns.

    @return the given filename, so that you can register outputs 'inline'
    """
    dynamic_outputs.append(filename)
    return filename

def send_outputs(output):
    """
    Called by the exec adapter to pass the names of the 'dynamic' model outputs
    to RiskScape so they can be slurped in to the model's manifest and list of
    outputs.
    """
    Number.write(output, len(dynamic_outputs), num_bytes=4)
    for path in dynamic_outputs:
        abspath = os.path.abspath(path)

        if not os.path.exists(abspath):
            logger.warn(f"Missing output: {abspath}")

        Text.toBytes(output, abspath, None)
