"""
This module is designed to make romancal tweakreg and resample functions accessible without
installing the original package due to their complex dependencies. As such, it is only subtly modified from
the original to accommodate the less stringent install requirements.


romancal copyright notice:

Copyright (C) 2010 Association of Universities for Research in Astronomy (AURA)

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

    1. Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.

    2. Redistributions in binary form must reproduce the above
      copyright notice, this list of conditions and the following
      disclaimer in the documentation and/or other materials provided
      with the distribution.

    3. The name of AURA and its representatives may not be used to
      endorse or promote products derived from this software without
      specific prior written permission.

THIS SOFTWARE IS PROVIDED BY AURA ``AS IS'' AND ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL AURA BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
DAMAGE.

Original: https://github.com/spacetelescope/romancal/blob/main/romancal/associations/lib/constraint.py
"""

import abc
import collections
import logging
import re
from copy import deepcopy
from itertools import chain

from spike.romancal.associations_pool import PoolRow
from spike.romancal.associations_lib_process_list import ListCategory, ProcessList
from spike.romancal.associations_lib_utilities import evaluate, getattr_from_list, is_iterable

__all__ = [
    "AttrConstraint",
    "Constraint",
    "ConstraintTrue",
    "SimpleConstraint",
]

# Configure logging
logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())


class SimpleConstraintABC(abc.ABC):
    """Simple Constraint ABC

    Parameters
    ----------
    init : dict
        dict where the key:value pairs define
        the following parameters

    value : object or None
        Value that must be matched.

    name : str or None
        Option name for constraint

    **kwargs : key:value pairs
        Other initialization parameters

    Attributes
    ----------
    matched : bool
        Last call to `check_and_set`
    """

    # Attributes to show in the string representation.
    _str_attrs = ("name", "value")

    def __init__(self, init=None, value=None, name=None, **kwargs):
        # Defined attributes
        self.value = value
        self.name = name
        self.matched = False

        if init is not None:
            self.__dict__.update(init)
        else:
            self.__dict__.update(kwargs)

    @abc.abstractmethod
    def check_and_set(self, item):
        """Check and set the constraint

        Returns
        -------
        success, reprocess : bool, [~jwst.associations.ProcessList[,...]]
            Returns 2-tuple of

                - True if check is successful.
                - List of `~jwst.associations.ProcessList`.
        """
        self.matched = True
        return self.matched, []

    @property
    def dup_names(
        self,
    ):  #  -> dict[str, list[typing.Union[SimpleConstraint, Constraint]]]
        """Return dictionary of constraints with duplicate names

        This method is meant to be overridden by classes
        that need to traverse a list of constraints.

        Returns
        -------
        dups : {str: [constraint[,...]][,...]}
            Returns a mapping between the duplicated name
            and all the constraints that define that name.
        """
        return {}

    @property
    def id(self):
        """Return identifyer for the constraint

        Returns
        -------
        id : str
            The identifyer
        """
        return f"{self.__class__.__name__}:{self.name}"

    def copy(self):
        """Copy ourselves"""
        return deepcopy(self)

    def get_all_attr(
        self, attribute: str
    ):  # -> list[tuple[SimpleConstraint, typing.Any]]:
        """Return the specified attribute

        This method is meant to be overridden by classes
        that need to traverse a list of constraints.

        Parameters
        ----------
        attribute : str
            The attribute to retrieve

        Returns
        -------
        [(self, value)] : [(SimpleConstraint, object)]
            The value of the attribute in a tuple. If there is no attribute,
            an empty tuple is returned.
        """
        value = getattr(self, attribute)
        if value is not None:
            return [(self, value)]
        return []

    # Make iterable to work with `Constraint`.
    # Since this is a leaf, simple return ourselves.
    def __iter__(self):
        yield self

    def __repr__(self):
        result = f"{self.__class__.__name__}({str(self.__dict__)})"
        return result

    def __str__(self):
        attrs = {str_attr: getattr(self, str_attr) for str_attr in self._str_attrs}
        return f"{self.__class__.__name__}({attrs})"


class ConstraintTrue(SimpleConstraintABC):
    """Always return True"""

    def check_and_set(self, item):
        return super().check_and_set(item)


class SimpleConstraint(SimpleConstraintABC):
    """A basic constraint

    Parameters
    ----------
    init : dict
        dict where the key:value pairs define
        the following parameters

    value : object or None
        Value that must be matched.
        If None, any retrieved value will match.

    sources : func(item) or None
        Function taking `item` as argument used to
        retrieve a value to check against.
        If None, the item itself is used as the value.

    force_unique : bool
        If the constraint is satisfied, reset `value`
        to the value of the source.

    test : function
        The test function for the constraint.
        Takes two arguments:

            - constraint
            - object to compare against.

        Returns a boolean.
        Default is `SimpleConstraint.eq`

    name : str or None
        Option name for constraint

    reprocess_on_match : bool
        Reprocess the item if the constraint is satisfied.

    reprocess_on_fail : bool
        Reprocess the item if the constraint is not satisfied.

    work_over : ListCategory.[BOTH, EXISTING, RULES]
        The condition on which this constraint should operate.

    reprocess_rules : [rule[,..]] or None
        List of rules to be applied to.
        If None, calling function will determine the ruleset.
        If empty, [], all rules will be used.

    Attributes
    ----------
    All `Parameters` are also `Attributes`

    Examples
    --------

    Create a constraint where the attribute `attr` of an object
    matches the value `my_value`:

    >>> c = SimpleConstraint(value='my_value')
    >>> print(c)
    SimpleConstraint({'name': None, 'value': 'my_value'})

    To check a constraint, call `check_and_set`. A successful match
    will return a tuple of `True` and a reprocess list.
    >>> item = 'my_value'
    >>> c.check_and_set(item)
    (True, [])

    If it doesn't match, `False` will be returned.
    >>> bad_item = 'not_my_value'
    >>> c.check_and_set(bad_item)
    (False, [])

    A `SimpleConstraint` can also be initialized by a `dict`
    of the relevant parameters:
    >>> init = {'value': 'my_value'}
    >>> c = SimpleConstraint(init)
    >>> print(c)
    SimpleConstraint({'name': None, 'value': 'my_value'})

    If the value to check is `None`, the `SimpleConstraint` will
    successfully match whatever object given. However, a new `SimpleConstraint`
    will be returned where the `value` is now set to whatever the attribute
    was of the object.
    >>> c = SimpleConstraint(value=None)
    >>> matched, reprocess = c.check_and_set(item)
    >>> print(c)
    SimpleConstraint({'name': None, 'value': 'my_value'})

    This behavior can be overridden by the `force_unique` parameter:
    >>> c = SimpleConstraint(value=None, force_unique=False)
    >>> matched, reprocess = c.check_and_set(item)
    >>> print(c)
    SimpleConstraint({'name': None, 'value': None})

    """

    def __init__(
        self,
        init=None,
        sources=None,
        force_unique=True,
        test=None,
        reprocess_on_match=False,
        reprocess_on_fail=False,
        work_over=ListCategory.BOTH,
        reprocess_rules=None,
        **kwargs,
    ):
        # Defined attributes
        self.sources = sources
        self.force_unique = force_unique
        self.test = test
        self.reprocess_on_match = reprocess_on_match
        self.reprocess_on_fail = reprocess_on_fail
        self.work_over = work_over
        self.reprocess_rules = reprocess_rules
        super().__init__(init=init, **kwargs)

        # Give defaults some real meaning.
        if self.sources is None:
            self.sources = lambda item: item
        if test is None:
            self.test = self.eq

    def check_and_set(self, item):
        """Check and set the constraint

        Returns
        -------
        success, reprocess : bool, [~jwst.associations.ProcessList[,...]]
            Returns 2-tuple of

                - True if check is successful.
                - List of `~jwst.associations.ProcessList`.
        """
        source_value = self.sources(item)

        satisfied = True
        if self.value is not None:
            satisfied = self.test(self.value, source_value)
        self.matched = satisfied

        if self.matched:
            if self.force_unique:
                self.value = source_value

        # Determine reprocessing
        reprocess = []
        if (self.matched and self.reprocess_on_match) or (
            not self.matched and self.reprocess_on_fail
        ):
            reprocess.append(
                ProcessList(
                    items=[item],
                    work_over=self.work_over,
                    rules=self.reprocess_rules,
                    trigger_constraints=[self.id],
                )
            )

        return self.matched, reprocess

    def eq(self, value1, value2):
        """True if constraint.value and item are equal."""
        return value1 == value2


class AttrConstraint(SimpleConstraintABC):
    """Test attribute of an item

    Parameters
    ----------
    sources : [str[,...]]
        List of attributes to query

    value : str, function or None
        The value to check for. If None and
        `force_unique`, any value in the first
        available source will become the value.
        If function, the function takes no arguments
        and returns a string.

    evaluate : bool
        Evaluate the item's value before checking condition.

    force_reprocess : ListCategory.state or False
        Add item back onto the reprocess list using
        the specified `~jwst.associations.ProcessList` work over state.

    force_unique : bool
        If the initial value is `None` or a list of possible values,
        the constraint will be modified to be the value first matched.

    invalid_values : [str[,...]]
        List of values that are invalid in an item.
        Will cause a non-match.

    name : str or None
        Name of the constraint.

    only_on_match : bool
        If `force_reprocess`, only do the reprocess
        if the entire constraint is satisfied.

    onlyif : function
        Boolean function that takes `item` as argument.
        If True, the rest of the condition is checked. Otherwise
        return as a matched condition

    required : bool
        One of the sources must exist. Otherwise,
        return as a matched constraint.

    Attributes
    ----------
    found_values : set(str[,...])
        Set of actual found values for this condition.

    matched : bool
        Last result of `check_and_set`

    """

    # Attributes to show in the string representation.
    _str_attrs = ("name", "sources", "value")

    def __init__(
        self,
        init=None,
        sources=None,
        evaluate=False,
        force_reprocess=False,
        force_undefined=False,
        force_unique=True,
        invalid_values=None,
        only_on_match=False,
        onlyif=None,
        required=True,
        **kwargs,
    ):
        # Attributes
        self.sources = sources
        self.evaluate = evaluate
        self.force_reprocess = force_reprocess
        self.force_undefined = force_undefined
        self.force_unique = force_unique
        self.invalid_values = invalid_values
        self.only_on_match = only_on_match
        self.onlyif = onlyif
        self.required = required
        super().__init__(init=init, **kwargs)

        # Give some defaults real meaning.
        if invalid_values is None:
            self.invalid_values = []
        if onlyif is None:
            self.onlyif = lambda item: True

        # Haven't actually matched anything yet.
        self.found_values = set()
        self.matched = False

    def check_and_set(self, item):
        """Check and set constraints based on item

        Parameters
        ----------
        item : dict
            The item to check on.

        Returns
        -------
        success, reprocess : bool, [~jwst.associations.ProcessList[,...]]
            Returns 2-tuple of

                - True if check is successful.
                - List of `~jwst.associations.ProcessList`.
        """
        reprocess = []

        # Only perform check on specified `onlyif` condition
        if not self.onlyif(item):
            if self.force_reprocess:
                reprocess.append(
                    ProcessList(
                        items=[item],
                        work_over=self.force_reprocess,
                        only_on_match=self.only_on_match,
                        trigger_constraints=[self.id],
                    )
                )
            self.matched = True
            return self.matched, reprocess

        # Get the condition information.
        try:
            source, value = getattr_from_list(
                item, self.sources, invalid_values=self.invalid_values
            )
        except KeyError:
            if self.required and not self.force_undefined:
                self.matched = False
                return self.matched, reprocess
            else:
                self.matched = True
                return self.matched, reprocess
        else:
            if self.force_undefined:
                self.matched = False
                return self.matched, reprocess

        evaled = value
        if self.evaluate:
            evaled = evaluate(value)

        # If the constraint has no value to check against, and given
        # value evaluates to a list, the item must be duplicated,
        # with each value from its list, and all the new items reprocessed.
        # Otherwise, the value is the value to set the constraint by.
        if self.value is None:
            if is_iterable(evaled):
                reprocess.append(reprocess_multivalue(item, source, evaled, self))
                self.matched = False
                return self.matched, reprocess
            value = str(evaled)

        # Else, the constraint does have a value. Check against it.
        else:
            if callable(self.value):
                match_value = self.value()
            else:
                match_value = self.value
            if not is_iterable(evaled):
                evaled = [evaled]
            matched = []
            for evaled_item in evaled:
                value = str(evaled_item)
                if meets_conditions(value, match_value):
                    matched.append(evaled_item)

            # If the condition is not matched, leave now.
            if not matched:
                self.matched = False
                return self.matched, reprocess

            # Matches were found. The first match becomes
            # the value for the current constraint.
            # The rest get reprocessed.
            value = str(matched[0])
            if matched[1:]:
                reprocess.append(reprocess_multivalue(item, source, matched[1:], self))

        # At this point, the constraint has passed.
        # Fix the conditions.
        escaped_value = re.escape(value)
        self.found_values.add(escaped_value)
        if self.force_unique:
            self.value = escaped_value
            self.sources = [source]
            self.force_unique = False

        # If required to reprocess, add to the reprocess list.
        if self.force_reprocess:
            reprocess.append(
                ProcessList(
                    items=[item],
                    work_over=self.force_reprocess,
                    only_on_match=self.only_on_match,
                    trigger_constraints=[self.id],
                )
            )

        # That's all folks
        self.matched = True
        return self.matched, reprocess


class Constraint:
    """Constraint that is made up of SimpleConstraints

    Parameters
    ----------
    init : object or [object[,...]]
        A single object or list of objects where the
        objects are as follows.
        - SimpleConstraint or subclass
        - Constraint

    reduce : function
        A reduction function with signature `x(iterable)`
        where `iterable` is the `components` list. Returns
        boolean indicating state of the components.
        Default value is `Constraint.all`

    name : str or None
        Optional name for constraint.

    reprocess_on_match : bool
        Reprocess the item if the constraint is satisfied.

    reprocess_on_fail : bool
        Reprocess the item if the constraint is not satisfied.

    work_over : ListCategory.[BOTH, EXISTING, RULES]
        The condition on which this constraint should operate.

    reprocess_rules : [rule[,..]] or None
        List of rules to be applied to.
        If None, calling function will determine the ruleset.
        If empty, [], all rules will be used.

    Attributes
    ----------
    constraints : [Constraint[,...]]
        List of `Constraint` or `SimpleConstraint` that
        make this constraint.

    matched : bool
        Result of the last `check_and_set`

    reduce : function
        A reduction function with signature `x(iterable)`
        where `iterable` is the `components` list. Returns
        boolean indicating state of the components.
        Predefined functions are:
        - `all`: True if all components return True
        - `any`: True if any component returns True

    Notes
    -----
    Named constraints can be accessed directly through indexing:

    >>> c = Constraint(SimpleConstraint(name='simple', value='a_value'))
    >>> c['simple']  # doctest: +SKIP
    SimpleConstraint({'sources': <function SimpleConstraint.__init__.<locals>.<lambda> at 0x7f8be05f5730>,
                      'force_unique': True,
                      'test': <bound method SimpleConstraint.eq of SimpleConstraint({...})>,
                      'reprocess_on_match': False,
                      'reprocess_on_fail': False,
                      'work_over': 1,
                      'reprocess_rules': None,
                      'value': 'a_value',
                      'name': 'simple',
                      'matched': False})
    """  # noqa: E501

    def __init__(
        self,
        init=None,
        reduce=None,
        name=None,
        reprocess_on_match=False,
        reprocess_on_fail=False,
        work_over=ListCategory.BOTH,
        reprocess_rules=None,
    ):
        self.constraints = []

        # Initialize from named parameters
        self.reduce = reduce
        self.name = name
        self.reprocess_on_match = reprocess_on_match
        self.reprocess_on_fail = reprocess_on_fail
        self.work_over = work_over
        self.reprocess_rules = reprocess_rules

        # Initialize from a structure.
        if init is None:
            pass
        elif isinstance(init, list):
            self.constraints = init
        elif isinstance(init, Constraint):
            self.reduce = init.reduce
            self.name = init.name
            self.reprocess_on_match = init.reprocess_on_match
            self.reprocess_on_fail = init.reprocess_on_fail
            self.work_over = init.work_over
            self.reprocess_rules = init.reprocess_rules
            self.constraints = deepcopy(init.constraints)
        elif isinstance(init, SimpleConstraintABC):
            self.constraints = [init]
        else:
            raise TypeError(
                "Invalid initialization value type {}.\nValid types are"
                " `SimpleConstraint`, `Constraint`,\nor subclass.".format(type(init))
            )

        # Give some defaults real meaning.
        self.matched = False
        if self.reduce is None:
            self.reduce = self.all

    @property
    def dup_names(
        self,
    ):  # -> dict[str, list[typing.Union[SimpleConstraint, Constraint]]]:
        """Return dictionary of constraints with duplicate names

        This method is meant to be overridden by classes
        that need to traverse a list of constraints.

        Returns
        -------
        dups : {str: [constraint[,...]][,...]}
            Returns a mapping between the duplicated name
            and all the constraints that define that name.
        """
        attrs = self.get_all_attr("name")
        constraints, names = zip(*attrs)
        dups = [name for name, count in collections.Counter(names).items() if count > 1]
        result = collections.defaultdict(list)
        for name, constraint in zip(names, constraints):
            if name in dups:
                result[name].append(constraint)

        # Turn off the defaultdict factory.
        result.default_factory = None
        return result

    @property
    def id(self):
        """Return identifyer for the constraint

        Returns
        -------
        id : str
            The identifyer
        """
        return f"{self.__class__.__name__}:{self.name}"

    def append(self, constraint):
        """Append a new constraint"""
        self.constraints.append(constraint)

    def check_and_set(self, item, work_over=ListCategory.BOTH):
        """Check and set the constraint

        Returns
        -------
        success, reprocess : bool, [~jwst.associations.ProcessList[,...]]
            Returns 2-tuple of

                - success : True if check is successful.
                - List of `~jwst.associations.ProcessList`.
        """
        if work_over not in (self.work_over, ListCategory.BOTH):
            return False, []

        # Do we have positive?
        self.matched, reprocess = self.reduce(item, self.constraints)

        # Determine reprocessing
        if (self.matched and self.reprocess_on_match) or (
            not self.matched and self.reprocess_on_fail
        ):
            reprocess.append(
                [
                    ProcessList(
                        items=[item],
                        work_over=self.work_over,
                        rules=self.reprocess_rules,
                        trigger_constraints=[self.id],
                    )
                ]
            )

        return self.matched, list(chain(*reprocess))

    def copy(self):
        """Copy ourselves"""
        return deepcopy(self)

    def get_all_attr(
        self, attribute: str
    ):  # -> list[tuple[typing.Union[SimpleConstraint, Constraint], typing.Any]]:
        """Return the specified attribute

        This method is meant to be overridden by classes
        that need to traverse a list of constraints.

        Parameters
        ----------
        attribute : str
            The attribute to retrieve

        Returns
        -------
        result : [(SimpleConstraint or Constraint, object)[,...]]
            The list of values of the attribute in a tuple. If there is no attribute,
            an empty tuple is returned.

        Raises
        ------
        AttributeError
            If the attribute is not found.
        """
        result = []
        value = getattr(self, attribute)
        if value is not None:
            result = [(self, value)]
        for constraint in self.constraints:
            result.extend(constraint.get_all_attr(attribute))

        return result

    @staticmethod
    def all(item, constraints):
        """Return positive only if all results are positive."""

        # If there are no constraints, there is nothing to match.
        # Result is false.
        if len(constraints) == 0:
            return False, []

        # Find all negatives. Note first negative
        # that requires reprocessing and how many
        # negatives do not.
        all_match = True
        negative_reprocess = None
        to_reprocess = []
        for constraint in constraints:
            match, reprocess = constraint.check_and_set(item)

            if match:
                if all_match:
                    to_reprocess.append(reprocess)
            else:
                all_match = False

                # If not match and no reprocessing, then fail
                # completely. However, if there is reprocessing, take
                # the first one. Continue to check to ensure
                # there is no further complete fail.
                if len(reprocess) == 0:
                    negative_reprocess = None
                    break
                elif negative_reprocess is None:
                    negative_reprocess = [reprocess]

        if not all_match:
            if negative_reprocess is not None:
                to_reprocess = negative_reprocess
            else:
                to_reprocess = []

        return all_match, to_reprocess

    @staticmethod
    def any(item, constraints):
        """Return the first successful constraint."""
        # If there are no constraints, there is nothing to match.
        # Result is false.
        if len(constraints) == 0:
            return False, []

        to_reprocess = []
        for constraint in constraints:
            match, reprocess = constraint.check_and_set(item)
            if match:
                to_reprocess = [reprocess]
                break
            to_reprocess.append(reprocess)
        return match, to_reprocess

    @staticmethod
    def notany(item, constraints):
        """True if none of the constraints match"""
        match, to_reprocess = Constraint.any(item, constraints)
        return not match, to_reprocess

    @staticmethod
    def notall(item, constraints):
        """True if not all of the constraints match"""
        match, to_reprocess = Constraint.all(item, constraints)
        return not match, to_reprocess

    def __delitem__(self, key):
        """Not implemented"""
        raise NotImplementedError("Cannot delete a constraint by index.")

    # Make iterable
    def __iter__(self):
        yield from chain(*map(iter, self.constraints))

    # Index implementation
    def __getitem__(self, key):
        """Retrieve a named constraint"""
        for constraint in self.constraints:
            name = getattr(constraint, "name", None)
            if name is not None and name == key:
                return constraint
            try:
                found = constraint[key]
            except (KeyError, TypeError):
                pass
            else:
                return found
        raise KeyError(f"Constraint {key} not found")

    def __repr__(self):
        result = "{}(name={}).{}([{}])".format(
            self.__class__.__name__,
            str(getattr(self, "name", None)),
            str(self.reduce.__name__),
            "".join([repr(constraint) for constraint in self.constraints]),
        )
        return result

    def __setitem__(self, key, value):
        """Not implemented"""
        raise NotImplementedError("Cannot set constraints by index.")

    def __str__(self):
        result = "\n".join(
            [str(constraint) for constraint in self if constraint.name is not None]
        )
        return result


# ---------
# Utilities
# ---------
def meets_conditions(value, conditions):
    """Check whether value meets any of the provided conditions

    Parameters
    ----------
    values : str
        The value to be check with.

    condition : regex,
        Regular expressions to match against.

    Returns
    -------
    True if any condition is meant.
    """

    if not is_iterable(conditions):
        conditions = [conditions]
    for condition in conditions:
        condition = "".join(["^", condition, "$"])
        match = re.match(condition, value, flags=re.IGNORECASE)
        if match:
            return True
    return False


def reprocess_multivalue(item, source, values, constraint):
    """Reprocess items that have a list of values

    Parameters
    ----------
    item : dict
        The item.

    source : str
        The attribute which has the multi-values.

    values : list
        The list of values

    constraint : Constraint
        The constraint which is triggering the reprocessing.

    Returns
    -------
    process_list : ProcessList
        The process list to put on the reprocess queue
    """
    reprocess_items = []
    for value in values:
        new_item = PoolRow(item)
        new_item[source] = str(value)
        reprocess_items.append(new_item)
    process_list = ProcessList(
        items=reprocess_items, trigger_constraints=[constraint.id]
    )
    return process_list