"""

Contents
--------

  * `JSON_Overview`
  * `JSON_Examples`
  * `JSON_Model_Specification`

.. _JSON_Overview:


Overview
--------

The developers of PsyNeuLink are collaborating with the scientific community, as part of the `OpenNeuro effort
<https://openneuro.org>`_, to create a standard, JSON-based format for the description and exchange of computational
models of brain and psychological function across different simulation environments. As part of this effort,
PsyNeuLink supports the `ModECI Model Description Format <https://github.com/ModECI/MDF/includes>`_ (MDF) by
including the ability to produce an MDF-compatible model from a PsyNeuLink model and to construct valid Python
scripts that express a PsyNeuLink model from an MDF model.

Any PsyNeuLink `Composition` or `Component` can be exported to MDF format using its `as_mdf_model` method or
to JSON format using its `json_summary` method. `json_summary` generates a string that, passed into the
`generate_script_from_json` function, produces a valid Python script replicating the original PsyNeuLink model.
`write_json_file` can be used to write the json_summary for one or more Compositions into a specified file (though
see `note <JSON_Write_Multiple_Compositions_Note>`).  `generate_script_from_json` can accept either the string returned
by `generate_script_from_json` or the name of a file containing one.
Calling ``exec(generate_script_from_json(<input>))`` will load into the current namespace all of the PsyNeuLink
objects specified in the ``input``; and `get_compositions` can be used to retrieve a list of all of the Compositions
in that namespace, including any generated by execution of `generate_script_from_json`. `generate_script_from_mdf`
may similarly be used to create a PsyNeuLink Python script from a ModECI MDF Model object, such as that created
by `as_mdf_model <Composition.as_mdf_model>`.

.. _JSON_Security_Warning:

.. warning::
   Use of `generate_script_from_json` or `generate_script_from_mdf` to generate a Python script from a file without taking proper precautions can
   introduce a security risk to the system on which the Python interpreter is running.  This is because it calls
   exec, which has the potential to execute non-PsyNeuLink-related code embedded in the file.  Therefore,
   `generate_script_from_json` or `generate_script_from_mdf` should be used to read only files of known and secure origin.

.. _JSON_Examples:

Model Examples
--------------

Below is an example of a script that implements a PsyNeuLink model of the Stroop model with conflict monitoring,
and its output in JSON. Running `generate_script_from_json` on the output will produce another PsyNeuLink script
that will give the same results when run on the same input as the original.

:download:`Download stroop_conflict_monitoring.py
<../../tests/json/stroop_conflict_monitoring.py>`

:download:`Download stroop_conflict_monitoring.json
<../../docs/source/_static/stroop_conflict_monitoring.json>`

.. _JSON_Model_Specification:

JSON/MDF Model Specification
------------------------

.. note::
    The format is in development, and is subject to change.

See https://github.com/ModECI/MDF/blob/main/docs/README.md#model


"""

import ast
import base64
import binascii
import copy
import dill
import enum
import graph_scheduler
import inspect
import json
import math
import numbers
import numpy
import pickle
import pint
import psyneulink
import re
import types
import warnings

from psyneulink.core.globals.keywords import \
    MODEL_SPEC_ID_COMPOSITION, MODEL_SPEC_ID_GENERIC, MODEL_SPEC_ID_NODES, MODEL_SPEC_ID_PARAMETER_SOURCE, \
    MODEL_SPEC_ID_PARAMETER_INITIAL_VALUE, MODEL_SPEC_ID_PARAMETER_VALUE, MODEL_SPEC_ID_PROJECTIONS, MODEL_SPEC_ID_PSYNEULINK, MODEL_SPEC_ID_RECEIVER_MECH, MODEL_SPEC_ID_RECEIVER_PORT, \
    MODEL_SPEC_ID_SENDER_MECH, MODEL_SPEC_ID_SENDER_PORT, MODEL_SPEC_ID_TYPE, MODEL_SPEC_ID_OUTPUT_PORTS, MODEL_SPEC_ID_MDF_VARIABLE, MODEL_SPEC_ID_INPUT_PORTS, MODEL_SPEC_ID_SHAPE, MODEL_SPEC_ID_METADATA, MODEL_SPEC_ID_INPUT_PORT_COMBINATION_FUNCTION
from psyneulink.core.globals.parameters import ParameterAlias
from psyneulink.core.globals.sampleiterator import SampleIterator
from psyneulink.core.globals.utilities import convert_to_list, gen_friendly_comma_str, get_all_explicit_arguments, \
    parse_string_to_psyneulink_object_string, parse_valid_identifier, safe_equals, convert_to_np_array

__all__ = [
    'PNLJSONError', 'JSONDumpable', 'PNLJSONEncoder',
    'generate_json', 'generate_script_from_json', 'generate_script_from_mdf',
    'write_json_file'
]


class PNLJSONError(Exception):
    pass


class JSONDumpable:
    @property
    def json_summary(self):
        return self.as_mdf_model().to_json()


# leaving this due to instructions in test_documentation_models
# (useful for exporting Composition results to JSON)
class PNLJSONEncoder(json.JSONEncoder):
    """
        A `JSONEncoder
        <https://docs.python.org/3/library/json.html#json.JSONEncoder>`_
        that parses `_dict_summary <Component._dict_summary>` output
        into a more JSON-friendly format.
    """
    def default(self, o):
        import modeci_mdf.mdf as mdf
        from psyneulink.core.components.component import Component, ComponentsMeta

        if isinstance(o, ComponentsMeta):
            return o.__name__
        elif isinstance(o, (type, types.BuiltinFunctionType)):
            if o.__module__ == 'builtins':
                # just give standard type, like float or int
                return f'{o.__name__}'
            elif o is numpy.ndarray:
                return f'{o.__module__}.array'
            else:
                # some builtin modules are internally "_module"
                # but are imported with "module"
                return f"{o.__module__.lstrip('_')}.{o.__name__}"
        elif isinstance(o, (enum.Enum, types.FunctionType, types.SimpleNamespace)):
            return str(o)
        elif isinstance(o, types.MethodType):
            return o.__qualname__
        elif o is NotImplemented:
            return None
        elif isinstance(o, Component):
            return o.name
        elif isinstance(o, SampleIterator):
            return f'{o.__class__.__name__}({repr(o.specification)})'
        elif isinstance(o, numpy.ndarray):
            return list(o)
        elif isinstance(o, numpy.random.RandomState):
            return f'numpy.random.RandomState({o.seed})'
        elif isinstance(o, numpy.number):
            return o.item()
        elif isinstance(o, mdf.BaseWithId):
            return json.loads(o.to_json())

        try:
            return super().default(o)
        except TypeError:
            return str(o)


def _get_variable_parameter_name(obj):
    try:
        if obj.parameters.variable.mdf_name is not None:
            return obj.parameters.variable.mdf_name
    except AttributeError:
        pass

    return MODEL_SPEC_ID_MDF_VARIABLE


def _substitute_expression_args(model):
    # currently cannot use args with value expressions
    if model.value is not None:
        for arg, val in model.args.items():
            model.value = model.value.replace(arg, str(val))


def _parse_component_type(component_dict):
    def get_pnl_component_type(s):
        from psyneulink.core.components.component import ComponentsMeta

        try:
            return getattr(psyneulink, s)
        except AttributeError:
            for o in dir(psyneulink):
                if s.lower() == o.lower():
                    o = getattr(psyneulink, o)
                    if isinstance(o, ComponentsMeta):
                        return o
            # if matching component not found, raise original exception
            raise

    type_str = None
    if MODEL_SPEC_ID_TYPE in component_dict:
        type_dict = component_dict[MODEL_SPEC_ID_TYPE]
    else:
        try:
            type_dict = component_dict[MODEL_SPEC_ID_METADATA][MODEL_SPEC_ID_TYPE]
        except KeyError:
            # specifically for functions the keyword is not 'type'
            type_str = component_dict['function']

    if type_str is None:
        try:
            type_str = type_dict[MODEL_SPEC_ID_PSYNEULINK]
        except KeyError:
            # catch error outside of this function if necessary
            type_str = type_dict[MODEL_SPEC_ID_GENERIC]
        except TypeError:
            # actually a str
            type_str = type_dict
    elif isinstance(type_str, dict):
        if len(type_str) != 1:
            raise PNLJSONError
        else:
            elem = list(type_str.keys())[0]
            # not a function_type: args dict
            if MODEL_SPEC_ID_METADATA in type_str[elem]:
                raise PNLJSONError
            else:
                type_str = elem

    try:
        # gets the actual psyneulink type (Component, etc..) from the module
        return get_pnl_component_type(type_str)
    except (AttributeError, TypeError):
        pass

    try:
        from modeci_mdf.functions.standard import mdf_functions
        mdf_functions[type_str]['function']
    # remove import/module errors when modeci_mdf is a package
    except (ImportError, KeyError, ModuleNotFoundError):
        pass
    else:
        return f"modeci_mdf.functions.standard.mdf_functions['{type_str}']['function']"

    try:
        getattr(math, type_str)
    except (AttributeError, TypeError):
        pass
    else:
        return f'math.{type_str}'

    try:
        eval(type_str)
    except (TypeError, SyntaxError):
        pass
    except NameError:
        return type_str
    else:
        return type_str

    raise PNLJSONError(
        'Invalid type specified for JSON object: {0}'.format(
            component_dict
        )
    )


def _parse_parameter_value(value, component_identifiers=None, name=None, parent_parameters=None):
    if component_identifiers is None:
        component_identifiers = {}

    exec('import numpy')
    try:
        pnl_type = _parse_component_type(value)
    except (KeyError, TypeError, PNLJSONError):
        # ignore parameters that aren't components
        pnl_type = None

    if isinstance(value, list):
        new_val = [_parse_parameter_value(x, component_identifiers, name, parent_parameters) for x in value]

        # check for ParameterPort spec
        if (
            len(value) == 2
            and isinstance(value[0], (numbers.Number, numpy.ndarray))
            and isinstance(value[1], dict)
        ):
            # make tuple instead of list
            value = f"({', '.join([str(x) for x in new_val])})"
        else:
            value = f"[{', '.join([str(x) for x in new_val])}]"
    elif isinstance(value, dict):
        if (
            MODEL_SPEC_ID_PARAMETER_SOURCE in value
            and MODEL_SPEC_ID_PARAMETER_VALUE in value
        ):
            # handle ParameterPort spec
            try:
                value_type = eval(value[MODEL_SPEC_ID_TYPE])
            except Exception as e:
                raise PNLJSONError(
                    'Invalid python type specified in JSON object: {0}'.format(
                        value[MODEL_SPEC_ID_TYPE]
                    )
                ) from e

            value = _parse_parameter_value(
                value[MODEL_SPEC_ID_PARAMETER_VALUE],
                component_identifiers,
                name,
                parent_parameters,
            )

            # handle tuples and numpy arrays, which both are dumped
            # as lists in JSON form
            if value_type is tuple:
                # convert list brackets to tuple brackets
                assert value[0] == '[' and value[-1] == ']'
                value = f'({value[1:-1]})'
            elif value_type is numpy.ndarray:
                value = f'{value[MODEL_SPEC_ID_TYPE]}({value})'
        elif MODEL_SPEC_ID_PARAMETER_INITIAL_VALUE in value:
            # is a stateful parameter with initial value
            value = _parse_parameter_value(
                value[MODEL_SPEC_ID_PARAMETER_INITIAL_VALUE],
                component_identifiers,
                name,
                parent_parameters
            )
        elif MODEL_SPEC_ID_PARAMETER_VALUE in value and pnl_type is None:
            # is a standard mdf Parameter class with value
            value = _parse_parameter_value(
                value[MODEL_SPEC_ID_PARAMETER_VALUE],
                component_identifiers,
                name,
                parent_parameters
            )
        else:
            # it is either a Component spec or just a plain dict
            try:
                # try handling as a Component spec
                try:
                    comp_name = value['name']
                except KeyError:
                    comp_name = name

                if comp_name is not None:
                    identifier = parse_valid_identifier(comp_name)
                    if len(value) == 1:
                        try:
                            value = value[comp_name]
                        except KeyError:
                            pass
                else:
                    if len(value) == 1:
                        comp_name = list(value.keys())[0]
                        identifier = parse_valid_identifier(comp_name)
                        if isinstance(value[comp_name], dict):
                            value = value[comp_name]
                    else:
                        raise PNLJSONError(
                            f'Component without name could reference multiple objects: {value}',
                        )

                if (
                    identifier in component_identifiers
                    and component_identifiers[identifier]
                ):
                    # if this spec is already created as a node elsewhere,
                    # then just use a reference
                    value = identifier
                else:
                    value = _generate_component_string(
                        value,
                        component_identifiers,
                        component_name=comp_name,
                        parent_parameters=parent_parameters
                    )
            except (PNLJSONError, KeyError, TypeError):
                # standard dict handling
                value = '{{{0}}}'.format(
                    ', '.join([
                        '{0}: {1}'.format(
                            str(_parse_parameter_value(k, component_identifiers, name)),
                            str(_parse_parameter_value(v, component_identifiers, name))
                        )
                        for k, v in value.items()
                    ])
                )

    elif isinstance(value, str):
        # handle pointer to parent's parameter value
        try:
            return _parse_parameter_value(parent_parameters[value])
        except (KeyError, TypeError):
            pass

        # handle reference to psyneulink object
        obj_string = parse_string_to_psyneulink_object_string(value)
        if obj_string is not None:
            return f'psyneulink.{obj_string}'

        # handle dill string
        try:
            dill_str = base64.decodebytes(bytes(value, 'utf-8'))
            dill.loads(dill_str)
            return f'dill.loads({dill_str})'
        except (binascii.Error, pickle.UnpicklingError, EOFError):
            pass

        # handle IO port specification
        match = re.match(r'(.+)\.(.+)_ports\.(.+)', value)
        if match is not None:
            comp_name, port_type, name = match.groups()
            comp_identifer = parse_valid_identifier(comp_name)

            if comp_identifer in component_identifiers:
                name_as_kw = parse_string_to_psyneulink_object_string(name)
                if name_as_kw is not None:
                    name = f'psyneulink.{name_as_kw}'
                else:
                    name = f"'{name}'"

                return f'{comp_identifer}.{port_type}_ports[{name}]'

        # if value is just a non-fixed component name, use the fixed name
        identifier = parse_valid_identifier(value)
        if identifier in component_identifiers:
            value = identifier

        try:
            psyneulink._unit_registry.Unit(value)
        except (AttributeError, TypeError, ValueError, pint.errors.DefinitionSyntaxError):
            pass
        else:
            value = f"'{value}'"

        try:
            psyneulink._unit_registry.Quantity(value)
        except (AttributeError, TypeError, ValueError, pint.errors.DefinitionSyntaxError):
            pass
        else:
            value = f"'{value}'"

        evaluates = False
        try:
            eval(value)
            evaluates = True
        except (TypeError, NameError, SyntaxError):
            pass

        # handle generic string
        if (
            value not in component_identifiers
            # assume a string that contains a dot is a command, not a raw
            # string, this is definitely imperfect and can't handle the
            # legitimate case, but don't know how to distinguish..
            and '.' not in value
            and not evaluates
        ):
            value = f"'{value}'"

    return value


def _generate_component_string(
    component_dict,
    component_identifiers,
    component_name=None,
    parent_parameters=None,
    assignment=False,
    default_type=None   # used if no PNL or generic types are specified
):
    from psyneulink.core.components.functions.function import Function_Base
    from psyneulink.core.components.functions.userdefinedfunction import UserDefinedFunction
    from psyneulink.core.components.projections.projection import Projection_Base

    try:
        component_type = _parse_component_type(component_dict)
    except KeyError as e:
        # acceptable to exclude type currently
        if default_type is not None:
            component_type = default_type
        else:
            raise type(e)(
                f'{component_dict} has no PNL or generic type and no '
                'default_type is specified'
            ) from e

    if component_name is None:
        name = component_dict['name']
    else:
        name = component_name
        try:
            assert component_name == component_dict['name']
        except KeyError:
            pass

    is_user_defined_function = False
    try:
        parameters = dict(component_dict[component_type._model_spec_id_parameters])
    except AttributeError:
        is_user_defined_function = True
    except KeyError:
        parameters = {}

    if is_user_defined_function or component_type is UserDefinedFunction:
        custom_func = component_type
        component_type = UserDefinedFunction
        try:
            parameters = dict(component_dict[component_type._model_spec_id_parameters])
        except KeyError:
            parameters = {}
        parameters['custom_function'] = f'{custom_func}'
        try:
            del component_dict[MODEL_SPEC_ID_METADATA]['custom_function']
        except KeyError:
            pass

    try:
        parameters.update(component_dict[component_type._model_spec_id_stateful_parameters])
    except KeyError:
        pass

    try:
        # args in function dict
        parameters.update(component_dict['function'][list(component_dict['function'].keys())[0]])
    except (AttributeError, KeyError):
        pass

    parameter_names = {}

    # If there is a parameter that is the psyneulink identifier string
    # (as of this comment, 'pnl'), then expand these parameters as
    # normal ones. We don't check and expand for other
    # special strings here, because we assume that these are specific
    # to other modeling platforms.
    try:
        parameters.update(parameters[MODEL_SPEC_ID_PSYNEULINK])
        del parameters[MODEL_SPEC_ID_PSYNEULINK]
    except KeyError:
        pass

    try:
        metadata = component_dict[MODEL_SPEC_ID_METADATA]
    except KeyError:
        metadata = {}

    if issubclass(component_type, Projection_Base):
        try:
            component_dict['functions'] = metadata['functions']
        except KeyError:
            pass

    # pnl objects only have one function unless specified in another way
    # than just "function"
    if 'functions' in component_dict:
        dup_function_names = set([name for name in component_dict['functions'] if name in component_identifiers])
        if len(dup_function_names) > 0:
            warnings.warn(
                f'Functions ({gen_friendly_comma_str(dup_function_names)}) of'
                f' {name} share names of mechanisms or compositions in this'
                ' model. This is likely to cause incorrect script reproduction.'
            )

        function_determined_by_output_port = False

        try:
            output_ports = component_dict[MODEL_SPEC_ID_OUTPUT_PORTS]
        except KeyError:
            pass
        else:
            if len(output_ports) == 1 or isinstance(output_ports, list):
                try:
                    primary_output_port = output_ports[0]
                except KeyError:
                    primary_output_port = output_ports[list(output_ports)[0]]
                function_determined_by_output_port = True
            else:
                try:
                    # 'out_port' appears to be the general primary output_port term
                    # should ideally have a marker in json to define it as primary
                    primary_output_port = output_ports['out_port']
                except KeyError:
                    pass
                else:
                    function_determined_by_output_port = True

        # neuroml-style mdf has MODEL_SPEC_ID_PARAMETER_VALUE in output port definitions
        if function_determined_by_output_port and MODEL_SPEC_ID_PARAMETER_VALUE in primary_output_port:
            parameter_names['function'] = re.sub(r'(.*)\[\d+\]', '\\1', primary_output_port[MODEL_SPEC_ID_PARAMETER_VALUE])
        else:
            parameter_names['function'] = [
                f for f in component_dict['functions']
                if not f.endswith(MODEL_SPEC_ID_INPUT_PORT_COMBINATION_FUNCTION)
            ][0]

        parameters['function'] = {
            parameter_names['function']: component_dict['functions'][parameter_names['function']]
        }

    assignment_str = f'{parse_valid_identifier(name)} = ' if assignment else ''

    additional_arguments = []
    # get the nonvariable arg and keyword arguments for the component's
    # constructor
    constructor_arguments = get_all_explicit_arguments(
        component_type,
        '__init__'
    )

    # put name as first argument
    if 'name' in constructor_arguments:
        additional_arguments.append(f"name='{name}'")

    if parent_parameters is None:
        parent_parameters = parameters

    parameters = {
        **{k: v for k, v in parent_parameters.items() if isinstance(v, dict) and MODEL_SPEC_ID_PARAMETER_INITIAL_VALUE in v},
        **parameters,
        **metadata
    }

    # MDF input ports do not have functions, so their shape is
    # equivalent to ours after the InputPort function is run (this
    # function may change the shape of the default variable), so ignore
    # the input port shape if input_ports parameter is specified
    if 'variable' not in parameters and 'input_ports' not in parameters:
        try:
            ip = parameters['function'][Function_Base._model_spec_id_parameters][MODEL_SPEC_ID_MDF_VARIABLE]
            var = convert_to_np_array(
                numpy.zeros(
                    ast.literal_eval(
                        component_dict[MODEL_SPEC_ID_INPUT_PORTS][ip][MODEL_SPEC_ID_SHAPE]
                    )
                ),
                dimension=2
            ).tolist()
            parameters['variable'] = var
        except KeyError:
            pass

    def parameter_value_matches_default(component_type, param, value):
        default_val = getattr(component_type.defaults, param)
        evaled_val = NotImplemented

        # see if val is a psyneulink class instantiation
        # if so, do not instantiate it (avoid offsetting rng for
        # testing - see if you can bypass another way?)
        try:
            eval(re.match(r'(psyneulink\.\w+)\(', value).group(1))
            is_pnl_instance = True
        except (AttributeError, TypeError, NameError, ValueError):
            is_pnl_instance = False

        if not is_pnl_instance:
            # val may be a string that evaluates to the default value
            # also skip listing in constructor in this case
            try:
                evaled_val = eval(value)
            except (TypeError, NameError, ValueError):
                pass
            except Exception:
                # Assume this occurred in creation of a Component
                # that probably needs some hidden/automatic modification.
                # Special handling here?
                # still relevant after testing for instance above?
                pass

        # skip specifying parameters that match the class defaults
        if (
            not safe_equals(value, default_val)
            and (
                evaled_val is NotImplemented
                or not safe_equals(evaled_val, default_val)
            )
        ):
            # test for dill use/equivalence
            try:
                is_dill_str = value[:5] == 'dill.'
            except TypeError:
                is_dill_str = False

            if (
                not is_dill_str
                or dill.dumps(eval(value)) != dill.dumps(default_val)
            ):
                return False

        return True

    mdf_names_to_pnl = {
        p.mdf_name: p.name for p in component_type.parameters
        if p.mdf_name is not None and not isinstance(p, ParameterAlias)
    }

    # sort on arg name
    for arg, val in sorted(parameters.items(), key=lambda p: p[0]):
        try:
            arg = mdf_names_to_pnl[arg]
        except KeyError:
            pass

        try:
            constructor_parameter_name = getattr(component_type.parameters, arg).constructor_argument
            # Some Parameters may be stored just to be replicated here, and
            # they may have different names than are used in the
            # constructor of the object.
            # Examples:
            #   Component.variable / default_variable
            #   ControlMechanism.output_ports / control
            if constructor_parameter_name is not None:
                constructor_arg = constructor_parameter_name
            else:
                constructor_arg = arg
        except AttributeError:
            constructor_arg = arg

        if constructor_arg in constructor_arguments:
            try:
                val = _parse_parameter_value(
                    val, component_identifiers,
                    name=parameter_names[arg],
                    parent_parameters=parent_parameters,
                )
            except KeyError:
                val = _parse_parameter_value(val, component_identifiers, parent_parameters=parent_parameters)

            if (
                (arg in component_type.parameters or constructor_arg in component_type.parameters)
                and not parameter_value_matches_default(component_type, arg, val)
            ):
                additional_arguments.append(f'{constructor_arg}={val}')
        elif component_type is UserDefinedFunction:
            try:
                val[MODEL_SPEC_ID_PARAMETER_INITIAL_VALUE]
            except (KeyError, TypeError):
                pass
            else:
                # is a stateful parameter corresponding to this function
                if val[MODEL_SPEC_ID_PARAMETER_VALUE] == name:
                    additional_arguments.append(f"stateful_parameter='{arg}'")

            if arg != MODEL_SPEC_ID_MDF_VARIABLE:
                val = _parse_parameter_value(
                    val, component_identifiers, parent_parameters=parent_parameters
                )

                try:
                    matches = parameter_value_matches_default(component_type, arg, val)
                except AttributeError:
                    matches = False

                if not matches:
                    additional_arguments.append(f'{constructor_arg}={val}')

    output = '{0}psyneulink.{1}{2}{3}{4}'.format(
        assignment_str,
        component_type.__name__,
        '(' if len(additional_arguments) > 0 else '',
        ', '.join(additional_arguments),
        ')' if len(additional_arguments) > 0 else '',
    )

    return output


def _generate_scheduler_string(
    scheduler_id,
    scheduler_dict,
    component_identifiers,
    blacklist=[]
):
    output = []
    try:
        node_specific_conds = scheduler_dict['node_specific']
    except KeyError:
        pass
    else:
        for node, condition in node_specific_conds.items():
            if node not in blacklist:
                output.append(
                    '{0}.add_condition({1}, {2})'.format(
                        scheduler_id,
                        parse_valid_identifier(node),
                        _generate_condition_string(
                            condition,
                            component_identifiers
                        )
                    )
                )

        output.append('')

    termination_str = []
    try:
        termination_conds = scheduler_dict['termination']
    except KeyError:
        pass
    else:
        for scale, cond in termination_conds.items():
            termination_str.insert(
                1,
                'psyneulink.{0}: {1}'.format(
                    f'TimeScale.{str.upper(scale)}',
                    _generate_condition_string(cond, component_identifiers)
                )
            )

        output.append(
            '{0}.termination_conds = {{{1}}}'.format(
                scheduler_id,
                ', '.join(termination_str)
            )
        )

    return '\n'.join(output)


def _generate_condition_string(condition_dict, component_identifiers):
    def _parse_condition_arg_value(value):
        try:
            identifier = parse_valid_identifier(value)
        except TypeError:
            pass
        else:
            if identifier in component_identifiers:
                return str(identifier)

        try:
            getattr(psyneulink.core.scheduling.condition, value['type'])
        except (AttributeError, KeyError, TypeError):
            pass
        else:
            return _generate_condition_string(value, component_identifiers)

        # handle value/outputport fix for threshold
        try:
            if re.match(r'\w+_OutputPort_0', value):
                return '"value"'
        except TypeError:
            pass

        return str(_parse_parameter_value(value, component_identifiers))

    def _parse_graph_scheduler_type(typ):
        for ts, pnl_ts in graph_scheduler.time._time_scale_aliases.items():
            ts_class_name = graph_scheduler.time._time_scale_to_class_str(ts)
            pnl_ts_class_name = graph_scheduler.time._time_scale_to_class_str(pnl_ts)

            if ts_class_name in typ:
                return typ.replace(ts_class_name, pnl_ts_class_name)

        return typ

    args_str = ''
    cond_type = _parse_graph_scheduler_type(condition_dict[MODEL_SPEC_ID_TYPE])
    sig = inspect.signature(getattr(psyneulink, cond_type).__init__)

    var_positional_arg_name = None

    for name, param in sig.parameters.items():
        if param.kind is inspect.Parameter.VAR_POSITIONAL:
            var_positional_arg_name = name
            break

    args_dict = condition_dict['args']

    try:
        pos_args = args_dict[var_positional_arg_name]
    except KeyError:
        pass
    else:
        if len(pos_args) > 0:
            arg_str_list = []
            for arg in pos_args:
                # handle nested Conditions
                try:
                    arg = _generate_condition_string(arg, component_identifiers)
                except TypeError:
                    pass

                arg_str_list.append(_parse_condition_arg_value(arg))
            args_str = f", {', '.join(arg_str_list)}"

    kwargs_str = ''
    kwargs = {k: v for k, v in args_dict.items() if k not in {'function', var_positional_arg_name}}
    if len(kwargs) > 0:
        kwarg_str_list = []
        for key, val in kwargs.items():
            kwarg_str_list.append(f'{key}={_parse_condition_arg_value(val)}')
        kwargs_str = f", {', '.join(kwarg_str_list)}"

    if 'function' in args_dict and args_dict['function'] is not None:
        func_str = args_dict['function']
    else:
        func_str = ''

    arguments_str = '{0}{1}{2}'.format(
        func_str,
        args_str,
        kwargs_str
    )
    if len(arguments_str) > 0 and arguments_str[0] == ',':
        arguments_str = arguments_str[2:]

    return f'psyneulink.{cond_type}({arguments_str})'


def _generate_composition_string(graphs_dict, component_identifiers):
    def _replace_function_node_with_mech_node(function_dict, name, typ=None):
        if typ is None:
            typ = _parse_component_type(function_dict)
        else:
            typ = typ.__name__

        mech_func_dict = {
            'functions': {
                name: {
                    MODEL_SPEC_ID_TYPE: {MODEL_SPEC_ID_PSYNEULINK: typ},
                    psyneulink.Function_Base._model_spec_id_parameters: function_dict[psyneulink.Component._model_spec_id_parameters]
                },
            }
        }

        try:
            del function_dict[MODEL_SPEC_ID_TYPE]
        except KeyError:
            pass

        function_dict['name'] = f"{name}_wrapped_mech"

        return {**function_dict, **mech_func_dict}

    # used if no generic types are specified
    default_composition_type = psyneulink.Composition
    default_node_type = psyneulink.ProcessingMechanism
    default_edge_type = psyneulink.MappingProjection

    control_mechanism_types = (psyneulink.ControlMechanism, )
    # these are not actively added to a Composition
    implicit_types = (
        psyneulink.ObjectiveMechanism,
        psyneulink.ControlProjection,
        psyneulink.AutoAssociativeProjection,
        psyneulink.LearningMechanism,
        psyneulink.LearningProjection,
    )
    output = []

    # may be given multiple compositions
    for comp_name, composition_dict in graphs_dict.items():
        try:
            assert comp_name == composition_dict['name']
        except KeyError:
            pass

        comp_identifer = parse_valid_identifier(comp_name)

        def alphabetical_order(items):
            alphabetical = enumerate(
                sorted(items)
            )
            return {
                parse_valid_identifier(item[1]): item[0]
                for item in alphabetical
            }

        # get order in which nodes were added
        # may be node names or dictionaries
        try:
            node_order = composition_dict[MODEL_SPEC_ID_METADATA]['node_ordering']
            node_order = {
                parse_valid_identifier(list(node.keys())[0]) if isinstance(node, dict)
                else parse_valid_identifier(node): node_order.index(node)
                for node in node_order
            }

            unspecified_node_order = {
                node: position + len(node_order)
                for node, position in alphabetical_order([
                    n for n in composition_dict[MODEL_SPEC_ID_NODES] if n not in node_order
                ]).items()
            }

            node_order.update(unspecified_node_order)

            assert all([
                (parse_valid_identifier(node) in node_order)
                for node in composition_dict[MODEL_SPEC_ID_NODES]
            ])
        except (KeyError, TypeError, AssertionError):
            # if no node_ordering attribute exists, fall back to
            # alphabetical order
            node_order = alphabetical_order(composition_dict[MODEL_SPEC_ID_NODES])

        # clean up pnl-specific and other software-specific items
        pnl_specific_items = {}
        keys_to_delete = []

        for name, node in composition_dict[MODEL_SPEC_ID_NODES].items():
            try:
                component_type = _parse_component_type(node)
            except KeyError:
                # will use a default type
                pass
            except PNLJSONError:
                # node isn't a node dictionary, but a dict of dicts,
                # indicating a software-specific set of nodes or
                # a composition
                if name == MODEL_SPEC_ID_PSYNEULINK:
                    pnl_specific_items = node

                if MODEL_SPEC_ID_COMPOSITION not in node:
                    keys_to_delete.append(name)
            else:
                # projection was written out as a node for simple_edge_format
                if issubclass(component_type, psyneulink.Projection_Base):
                    assert len(node[MODEL_SPEC_ID_INPUT_PORTS]) == 1
                    assert len(node[MODEL_SPEC_ID_OUTPUT_PORTS]) == 1

                    extra_projs_to_delete = set()

                    sender = None
                    sender_port = None
                    receiver = None
                    receiver_port = None

                    for proj_name, proj in composition_dict[MODEL_SPEC_ID_PROJECTIONS].items():
                        if proj[MODEL_SPEC_ID_RECEIVER_MECH] == name:
                            assert 'dummy' in proj_name
                            sender = proj[MODEL_SPEC_ID_SENDER_MECH]
                            sender_port = proj[MODEL_SPEC_ID_SENDER_PORT]
                            extra_projs_to_delete.add(proj_name)

                        if proj[MODEL_SPEC_ID_SENDER_MECH] == name:
                            assert 'dummy' in proj_name
                            receiver = proj[MODEL_SPEC_ID_RECEIVER_MECH]
                            receiver_port = proj[MODEL_SPEC_ID_RECEIVER_PORT]
                            # if for some reason the projection has node as both sender and receiver
                            # this is a bug, let the deletion fail
                            extra_projs_to_delete.add(proj_name)

                    if sender is None:
                        raise PNLJSONError(f'Dummy node {name} for projection has no sender in projections list')

                    if receiver is None:
                        raise PNLJSONError(f'Dummy node {name} for projection has no receiver in projections list')

                    proj_dict = {
                        **{
                            MODEL_SPEC_ID_SENDER_PORT: sender_port,
                            MODEL_SPEC_ID_RECEIVER_PORT: receiver_port,
                            MODEL_SPEC_ID_SENDER_MECH: sender,
                            MODEL_SPEC_ID_RECEIVER_MECH: receiver
                        },
                        **{
                            MODEL_SPEC_ID_METADATA: {
                                # variable isn't specified for projections
                                **{k: v for k, v in node[MODEL_SPEC_ID_METADATA].items() if k != 'variable'},
                                'functions': node['functions']
                            }
                        },
                    }
                    try:
                        proj_dict[component_type._model_spec_id_parameters] = node[psyneulink.Component._model_spec_id_parameters]
                    except KeyError:
                        pass

                    composition_dict[MODEL_SPEC_ID_PROJECTIONS][name.rstrip('_dummy_node')] = proj_dict

                    keys_to_delete.append(name)
                    for p in extra_projs_to_delete:
                        del composition_dict[MODEL_SPEC_ID_PROJECTIONS][p]

                    for nr_item in ['required_node_roles', 'excluded_node_roles']:
                        nr_removal_indices = []

                        for i, (nr_name, nr_role) in enumerate(
                            composition_dict[MODEL_SPEC_ID_METADATA][nr_item]
                        ):
                            if nr_name == name:
                                nr_removal_indices.append(i)

                        for i in nr_removal_indices:
                            del composition_dict[MODEL_SPEC_ID_METADATA][nr_item][i]

        for nodes_dict in pnl_specific_items:
            for name, node in nodes_dict.items():
                composition_dict[MODEL_SPEC_ID_NODES][name] = node

        for name_to_delete in keys_to_delete:
            del composition_dict[MODEL_SPEC_ID_NODES][name_to_delete]

        try:
            edges_dict = composition_dict[MODEL_SPEC_ID_PROJECTIONS]
            pnl_specific_items = {}
            keys_to_delete = []
        except KeyError:
            pass
        else:
            for name, edge in edges_dict.items():
                try:
                    _parse_component_type(edge)
                except KeyError:
                    # will use a default type
                    pass
                except PNLJSONError:
                    if name == MODEL_SPEC_ID_PSYNEULINK:
                        pnl_specific_items = edge

                    keys_to_delete.append(name)

            for name, edge in pnl_specific_items.items():
                # exclude CIM projections because they are automatically
                # generated
                if (
                    edge[MODEL_SPEC_ID_SENDER_MECH] != comp_name
                    and edge[MODEL_SPEC_ID_RECEIVER_MECH] != comp_name
                ):
                    composition_dict[MODEL_SPEC_ID_PROJECTIONS][name] = edge

            for name_to_delete in keys_to_delete:
                del composition_dict[MODEL_SPEC_ID_PROJECTIONS][name_to_delete]

        # generate string for Composition itself
        output.append(
            "{0} = {1}\n".format(
                comp_identifer,
                _generate_component_string(
                    composition_dict,
                    component_identifiers,
                    component_name=comp_name,
                    default_type=default_composition_type
                )
            )
        )
        component_identifiers[comp_identifer] = True

        mechanisms = {}
        compositions = {}
        control_mechanisms = {}
        implicit_mechanisms = {}

        # add nested compositions and mechanisms in order they were added
        # to this composition
        for name, node in sorted(
            composition_dict[MODEL_SPEC_ID_NODES].items(),
            key=lambda item: node_order[parse_valid_identifier(item[0])]
        ):
            if MODEL_SPEC_ID_COMPOSITION in node:
                compositions[name] = node[MODEL_SPEC_ID_COMPOSITION]
            else:
                try:
                    component_type = _parse_component_type(node)
                except KeyError:
                    component_type = default_node_type
                identifier = parse_valid_identifier(name)
                if issubclass(component_type, control_mechanism_types):
                    control_mechanisms[name] = node
                    component_identifiers[identifier] = True
                elif issubclass(component_type, implicit_types):
                    implicit_mechanisms[name] = node
                else:
                    mechanisms[name] = node
                    component_identifiers[identifier] = True

        implicit_names = [
            x
            for x in [*implicit_mechanisms.keys(), *control_mechanisms.keys()]
        ]

        for name, mech in copy.copy(mechanisms).items():
            try:
                mech_type = _parse_component_type(mech)
            except KeyError:
                mech_type = None

            if (
                isinstance(mech_type, type)
                and issubclass(mech_type, psyneulink.Function)
            ):
                mech = _replace_function_node_with_mech_node(mech, name, mech_type)

                component_identifiers[mech['name']] = component_identifiers[name]
                del component_identifiers[name]

                node_order[mech['name']] = node_order[name]
                del node_order[name]

                mechanisms[mech['name']] = mechanisms[name]
                del mechanisms[name]

                composition_dict['nodes'][mech['name']] = composition_dict['nodes'][name]
                del composition_dict['nodes'][name]

                name = mech['name']

            output.append(
                _generate_component_string(
                    mech,
                    component_identifiers,
                    component_name=name,
                    assignment=True,
                    default_type=default_node_type
                )
            )
        if len(mechanisms) > 0:
            output.append('')

        for name, mech in control_mechanisms.items():
            output.append(
                _generate_component_string(
                    mech,
                    component_identifiers,
                    component_name=name,
                    assignment=True,
                    default_type=default_node_type
                )
            )

        if len(control_mechanisms) > 0:
            output.append('')

        # recursively generate string for inner Compositions
        for name, comp in compositions.items():
            output.append(
                _generate_composition_string(
                    comp,
                    component_identifiers
                )
            )
        if len(compositions) > 0:
            output.append('')

        # generate string to add the nodes to this Composition
        try:
            node_roles = {
                parse_valid_identifier(node): role for (node, role) in
                composition_dict[MODEL_SPEC_ID_METADATA]['required_node_roles']
            }
        except KeyError:
            node_roles = []

        try:
            excluded_node_roles = {
                parse_valid_identifier(node): role for (node, role) in
                composition_dict[MODEL_SPEC_ID_METADATA]['excluded_node_roles']
            }
        except KeyError:
            excluded_node_roles = []

        # do not add the controller as a normal node
        try:
            controller_name = list(composition_dict[MODEL_SPEC_ID_METADATA]['controller'].keys())[0]
        except (AttributeError, KeyError, TypeError):
            controller_name = None

        for name in sorted(
            composition_dict[MODEL_SPEC_ID_NODES],
            key=lambda item: node_order[parse_valid_identifier(item)]
        ):
            if (
                name not in implicit_names
                and name != controller_name
            ):
                name = parse_valid_identifier(name)

                output.append(
                    '{0}.add_node({1}{2})'.format(
                        comp_identifer,
                        name,
                        ', {0}'.format(
                            _parse_parameter_value(
                                node_roles[name],
                                component_identifiers
                            )
                        ) if name in node_roles else ''
                    )
                )
        if len(composition_dict[MODEL_SPEC_ID_NODES]) > 0:
            output.append('')

        if len(excluded_node_roles) > 0:
            for node, roles in excluded_node_roles.items():
                if name not in implicit_names and name != controller_name:
                    output.append(
                        f'{comp_identifer}.exclude_node_roles({node}, {_parse_parameter_value(roles, component_identifiers)})'
                    )
            output.append('')

        try:
            edges_dict = composition_dict[MODEL_SPEC_ID_PROJECTIONS]
        except KeyError:
            pass
        else:
            # generate string to add the projections
            for name, projection_dict in edges_dict.items():
                try:
                    projection_type = _parse_component_type(projection_dict)
                except KeyError:
                    projection_type = default_edge_type

                if (
                    not issubclass(projection_type, implicit_types)
                    and projection_dict[MODEL_SPEC_ID_SENDER_MECH] not in implicit_names
                    and projection_dict[MODEL_SPEC_ID_RECEIVER_MECH] not in implicit_names
                ):
                    output.append(
                        '{0}.add_projection(projection={1}, sender={2}, receiver={3})'.format(
                            comp_identifer,
                            _generate_component_string(
                                projection_dict,
                                component_identifiers,
                                component_name=name,
                                default_type=default_edge_type
                            ),
                            parse_valid_identifier(
                                projection_dict[MODEL_SPEC_ID_SENDER_MECH]
                            ),
                            parse_valid_identifier(
                                projection_dict[MODEL_SPEC_ID_RECEIVER_MECH]
                            ),
                        )
                    )

        # add controller if it exists (must happen after projections)
        if controller_name is not None:
            output.append(
                '{0}.add_controller({1})'.format(
                    comp_identifer,
                    parse_valid_identifier(controller_name)
                )
            )

        # add schedulers
        # blacklist automatically generated nodes because they will
        # not exist in the script namespace
        try:
            conditions = composition_dict['conditions']
        except KeyError:
            conditions = {}

        output.append('')
        output.append(
            _generate_scheduler_string(
                f'{comp_identifer}.scheduler',
                conditions,
                component_identifiers,
                blacklist=implicit_names
            )
        )

    return '\n'.join(output)


def generate_script_from_json(model_input, outfile=None):
    """
        Generate a Python script from JSON **model_input** in the
        `general JSON format <JSON_Model_Specification>`

        .. warning::
           Use of `generate_script_from_json` to generate a Python script from a file without taking proper precautions
           can introduce a security risk to the system on which the Python interpreter is running.  This is because it
           calls exec, which has the potential to execute non-PsyNeuLink-related code embedded in the file.  Therefore,
           `generate_script_from_json` should be used to read only files of known and secure origin.

        Arguments
        ---------

            model_input : str
                a JSON string in the proper format, or a filename
                containing such

        Returns
        -------

            Text of Python script : str



    """

    def get_declared_identifiers(graphs_dict):
        names = set()

        for comp_name, composition_dict in graphs_dict.items():
            try:
                assert comp_name == composition_dict['name']
            except KeyError:
                pass

            names.add(parse_valid_identifier(comp_name))
            for name, node in composition_dict[MODEL_SPEC_ID_NODES].items():
                if MODEL_SPEC_ID_COMPOSITION in node:
                    names.update(
                        get_declared_identifiers(
                            node[MODEL_SPEC_ID_COMPOSITION]
                        )
                    )

                names.add(parse_valid_identifier(name))

        return names

    # accept either json string or filename
    try:
        model_input = open(model_input, 'r').read()
    except (FileNotFoundError, OSError):
        pass

    try:
        model_input = json.loads(model_input)
    except json.decoder.JSONDecodeError:
        raise ValueError(
            f'{model_input} is neither valid JSON nor a file containing JSON'
        )

    assert len(model_input.keys()) == 1
    model_input = model_input[list(model_input.keys())[0]]

    imports_str = ''
    if MODEL_SPEC_ID_COMPOSITION in model_input:
        # maps declared names to whether they are accessible in the script
        # locals. that is, each of these will be names specified in the
        # composition and subcomposition nodes, and their value in this dict
        # will correspond to True if they can be referenced by this name in the
        # script
        component_identifiers = {
            i: False
            for i in get_declared_identifiers(model_input[MODEL_SPEC_ID_COMPOSITION])
        }

        comp_str = _generate_composition_string(
            model_input[MODEL_SPEC_ID_COMPOSITION],
            component_identifiers
        )
    else:
        comp_str = _generate_component_string(
            model_input,
            component_identifiers={},
            assignment=True
        )

    module_friendly_name_mapping = {
        'psyneulink': 'pnl',
        'dill': 'dill',
        'numpy': 'np'
    }

    module_names = set()

    # greedy and non-greedy
    potential_module_names = set([
        *re.findall(r'([A-Za-z_\.]+)\.', comp_str),
        *re.findall(r'([A-Za-z_\.]+?)\.', comp_str)
    ])
    for module in potential_module_names:
        if module not in component_identifiers:
            try:
                exec(f'import {module}')
                module_names.add(module)
            except (ImportError, ModuleNotFoundError, SyntaxError):
                pass

    for module in module_names.copy():
        try:
            friendly_name = module_friendly_name_mapping[module]
            comp_str = re.sub(f'{module}\\.', f'{friendly_name}.', comp_str)
        except KeyError:
            friendly_name = module

        if not re.findall(rf'[^\.]{friendly_name}\.', comp_str):
            module_names.remove(module)

    for m in module_names.copy():
        for n in module_names.copy():
            # remove potential modules that are substrings of another
            if m is not n and m in n:
                module_names.remove(m)

    for module in sorted(module_names):
        try:
            friendly_name = module_friendly_name_mapping[module]
        except KeyError:
            friendly_name = module

        imports_str += 'import {0}{1}\n'.format(
            module,
            f' as {friendly_name}' if friendly_name != module else ''
        )

    model_output = '{0}{1}{2}'.format(
        imports_str,
        '\n' if len(imports_str) > 0 else '',
        comp_str
    )

    if outfile is not None:
        # pass through any file exceptions
        with open(outfile, 'w') as outfile:
            outfile.write(model_output)
            print(f'Wrote JSON to {outfile.name}')
    else:
        return model_output


def generate_script_from_mdf(model_input, outfile=None):
    """
        Generate a Python script from MDF model **model_input**

        .. warning::
           Use of `generate_script_from_mdf` to generate a Python script from a model without taking proper precautions
           can introduce a security risk to the system on which the Python interpreter is running.  This is because it
           calls exec, which has the potential to execute non-PsyNeuLink-related code embedded in the file.  Therefore,
           `generate_script_from_mdf` should be used to read only model of known and secure origin.

        Arguments
        ---------

            model_input : modeci_mdf.Model

        Returns
        -------

            Text of Python script : str
    """
    return generate_script_from_json(model_input.to_json(), outfile)


def generate_json(*compositions, simple_edge_format=True):
    """
        Generate the `general JSON format <JSON_Model_Specification>`
        for one or more `Compositions <Composition>` and associated
        objects.
        .. _JSON_Write_Multiple_Compositions_Note:

        .. note::
           At present, if more than one Composition is specified, all
           must be fully disjoint;  that is, they must not share any
           `Components <Component>` (e.g., `Mechanism`, `Projections`
           etc.). This limitation will be addressed in a future update.

        Arguments:
            *compositions : Composition
                specifies `Composition` or iterable of ones to be output
                in JSON
    """
    import modeci_mdf
    import modeci_mdf.mdf as mdf
    from psyneulink.core.compositions.composition import Composition

    model_name = "_".join([c.name for c in compositions])

    model = mdf.Model(
        id=model_name,
        format=f'ModECI MDF v{modeci_mdf.__version__}',
        generating_application=f'PsyNeuLink v{psyneulink.__version__}',
    )

    for c in compositions:
        if not isinstance(c, Composition):
            raise PNLJSONError(
                f'Item in compositions arg of {__name__}() is not a Composition: {c}.'
            )
        model.graphs.append(c.as_mdf_model(simple_edge_format=simple_edge_format))

    return model.to_json()


def write_json_file(compositions, filename:str, path:str=None, simple_edge_format=True):
    """
        Write one or more `Compositions <Composition>` and associated objects to file in the `general JSON format
        <JSON_Model_Specification>`

        .. _JSON_Write_Multiple_Compositions_Note:

        .. note::
           At present, if more than one Composition is specified, all must be fully disjoint;  that is, they must not
           share  any `Components <Component>` (e.g., `Mechanism`, `Projections` etc.).  This limitation will be
           addressed in a future update.

        Arguments
        ---------
        compositions : Composition or list˚
             specifies `Composition` or list of ones to be written to **filename**

        filename : str
             specifies name of file in which to write JSON specification of `Composition(s) <Composition>`
             and associated objects.

        path : str : default None
             specifies path of file for JSON specification;  if it is not specified then the current directory is used.

    """

    compositions = convert_to_list(compositions)

    with open(filename, 'w') as json_file:
        json_file.write(generate_json(*compositions, simple_edge_format=simple_edge_format))
