"""
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/diff.py
"""

import logging
import re
from collections import Counter, UserList
from copy import copy

from spike.romancal.associations_load_asn import load_asn

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


# #########################
# Define the types of diffs
# #########################
class DiffError(AssertionError):
    """Base Class for difference errors"""


class CandidateLevelError(DiffError):
    """Candidate level mismatch"""


class DifferentProductSetsError(DiffError):
    """Different product sets between groups of associations"""


class DuplicateProductError(DiffError):
    """Duplicate products found"""


class MemberMismatchError(DiffError):
    """Membership does not match"""


class TypeMismatchError(DiffError):
    """Association type mismatch"""


class MultiDiffError(UserList, DiffError):
    """List of diff errors"""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    def __str__(self):
        message = ["Following diffs found:\n"]
        for diff in self:
            message.extend(["\n****\n", str(diff), "\n"])
        return "".join(message)


def compare_asn_files(left_paths, right_paths):
    """Compare association files

    Parameters
    ----------
    left_paths: [Path or str[, ...]]
        Set of association files

    right_paths: [Path or str[, ...]]
        Set of association files to compare against

    Raises
    ------
    MultiDiffError
        If there are differences. The message will contain
        all the differences.
    """
    __tracebackhide__ = True
    # Read in all the associations, separating out the products into separate
    # associations.
    left_asns = []
    for path in left_paths:
        with open(path) as fh:
            asn = load_asn(fh)
        single_product_asns = separate_products(asn)

        # Collect the associations.
        left_asns += single_product_asns

    right_asns = []
    for path in right_paths:
        with open(path) as fh:
            asn = load_asn(fh)
        single_product_asns = separate_products(asn)

        # Collect the associations.
        right_asns += single_product_asns

    # Compare the associations.
    compare_asn_lists(left_asns, right_asns)


def compare_asn_lists(left_asns, right_asns):
    """Compare to lists of associations

    Both association lists must contain associations that only have
    single products. Use `separate_products` prior to calling this
    function.

    Parameters
    ----------
    left_asns: [`Association`[, ...]]
        Group of associations

    right_asns: [`Association`[, ...]]
        Group of associations to compare

    Raises
    ------
    MultiDiffError
        If there are differences. The message will contain
        all the differences.
    """
    __tracebackhide__ = True
    diffs = MultiDiffError()

    # Ensure that product names are unique
    left_product_names, left_duplicates = get_product_names(left_asns)
    right_product_names, right_duplicates = get_product_names(right_asns)
    if left_duplicates:
        diffs.append(
            DuplicateProductError(
                f"Left associations have duplicate products {left_duplicates}"
            )
        )
    if right_duplicates:
        diffs.append(
            DuplicateProductError(
                f"Right associations have duplicate products {right_duplicates}"
            )
        )

    # Ensure that the product name lists are the same.
    name_diff = left_product_names ^ right_product_names
    if name_diff:
        diffs.append(
            DifferentProductSetsError(
                "Left and right associations do not share a common set of products:"
                f" {name_diff}"
            )
        )

    # Compare like product associations
    left_asns_by_product = {asn["products"][0]["name"]: asn for asn in left_asns}
    right_asns_by_product = {asn["products"][0]["name"]: asn for asn in right_asns}
    for product_name in left_product_names:
        try:
            compare_asns(
                left_asns_by_product[product_name], right_asns_by_product[product_name]
            )
        except MultiDiffError as compare_diffs:
            diffs.extend(compare_diffs)
        except KeyError:
            # Most likely due to a previous error. Ignore
            pass

    if diffs:
        raise diffs


def compare_asns(left, right):
    """Compare two associations

    This comparison will include metadata such as
    `asn_type` and membership

    Parameters
    ---------
    left, right : dict
        Two, individual, associations to compare

    Raises
    ------
    MultiDiffError
        If there is a difference.
    """
    _compare_asns(left, right)


def _compare_asns(left, right):
    """Compare two associations

    This comparison will include metadata such as
    `asn_type` and membership

    Parameters
    ---------
    left, right : dict
        Two, individual, associations to compare

    Raises
    ------
    MultiDiffError
        If there are differences. The message will contain
        all the differences.

    Note
    ----
    This comparison is dependent on the associations being JWST-like associations.
    The attributes that are compared are as follows:

        - key `asn_type`
        - key `products`. Specifically the following are compared:
            - Length of the list
            - key `name` for each product
            - key `members` for each product
        - For the member lists of each product, the following are compared:
            - Length of the list
            - key `expname` for each member
            - key 'exptype` for each member
    """
    diffs = MultiDiffError()

    # Assert that the same result type is the same.
    if left["asn_type"] != right["asn_type"]:
        diffs.append(
            TypeMismatchError(
                f"Type mismatch {left['asn_type']} != {right['asn_type']}"
            )
        )

    # Assert that the level of association candidate is the same.
    # Cannot guarantee value, but that the 'a'/'c'/'o' levels are similar.
    if left["asn_id"][0] != right["asn_id"][0]:
        diffs.append(
            CandidateLevelError(
                f"Candidate level mismatch left '{left['asn_id'][0]}' != right"
                f" '{right['asn_id'][0]}'"
            )
        )

    # Membership
    try:
        compare_membership(left, right)
    except MultiDiffError as compare_diffs:
        diffs.extend(compare_diffs)

    if diffs:
        raise diffs


def compare_membership(left, right):
    """Compare two associations' membership

    Parameters
    ---------
    left, right : dict
        Two, individual, associations to compare

    Raises
    ------
    MultiDiffError
        If there are differences. The message will contain
        all the differences.
    """
    diffs = MultiDiffError()
    products_left = left["products"]
    products_right = copy(right["products"])

    if len(products_left) != len(products_right):
        diffs.append(
            DifferentProductSetsError(
                f"# products differ: {len(products_left)} != {len(products_right)}"
            )
        )

    for left_idx, left_product in enumerate(products_left):
        left_product_name = components(left_product["name"])
        for right_idx, right_product in enumerate(products_right):
            if components(right_product["name"]) != left_product_name:
                continue
            try:
                compare_product_membership(left_product, right_product)
            except MultiDiffError as compare_diffs:
                diffs.extend(compare_diffs)
            products_right.remove(right_product)
            break
        else:
            diffs.append(
                DifferentProductSetsError(f"Left has {len(products_left)} left over")
            )

    if len(products_right) != 0:
        diffs.append(
            DifferentProductSetsError(f"Right has {len(products_right)} left over")
        )

    if diffs:
        raise diffs


def compare_product_membership(left, right):
    """Compare membership between products

    Parameters
    ---------
    left, right : dict
        Two, individual, association products to compare

    Raises
    ------
    MultiDiffError
        If there are differences. The message will contain
        all the differences.
    """
    diffs = MultiDiffError()
    if len(right["members"]) != len(left["members"]):
        diffs.append(
            MemberMismatchError(
                "Product Member length differs:"
                " Left Product {left_product_name} len {left_len} !=  "
                " Right Product {right_product_name} len {right_len}"
                "".format(
                    left_product_name=left["name"],
                    left_len=len(left["members"]),
                    right_product_name=right["name"],
                    right_len=len(right["members"]),
                )
            )
        )

    members_right = copy(right["members"])
    for left_member in left["members"]:
        for right_member in members_right:
            if left_member["expname"] != right_member["expname"]:
                continue

            if left_member["exptype"] != right_member["exptype"]:
                diffs.append(
                    MemberMismatchError(
                        "Left {left_expname}:{left_exptype} != Right"
                        " {right_expname}:{right_exptype}".format(
                            left_expname=left_member["expname"],
                            left_exptype=left_member["exptype"],
                            right_expname=right_member["expname"],
                            right_exptype=right_member["exptype"],
                        )
                    )
                )

            members_right.remove(right_member)
            break
        else:
            diffs.append(
                MemberMismatchError(
                    f"Left {left_member['expname']}:{left_member['exptype']} has no"
                    " counterpart in right"
                )
            )

    if len(members_right) != 0:
        diffs.append(
            MemberMismatchError(
                "Right has {len_over} unaccounted for members starting with"
                " {right_expname}:{right_exptype}".format(
                    len_over=len(members_right),
                    right_expname=members_right[0]["expname"],
                    right_exptype=members_right[0]["exptype"],
                )
            )
        )

    if diffs:
        raise diffs


def components(s):
    """split string into its components"""
    return set(re.split("[_-]", s))


def separate_products(asn):
    """Separate products into individual associations

    Parameters
    ----------
    asn: `Association`
        The association to split

    Returns
    -------
    separated: [`Association`[, ...]]
        The list of separated associations
    """
    separated = []
    for product in asn["products"]:
        new_asn = copy(asn)
        new_asn["products"] = [product]
        separated.append(new_asn)
    return separated


def get_product_names(asns):
    """Return product names from associations and flag duplicates

    Parameters
    ----------
    asns: [`Association`[, ...]]

    Returns
    -------
    product_names, duplicates: set(str[, ...]), [str[,...]]
        2-tuple consisting of the set of product names and the list of duplicates.
    """
    product_names = [asn["products"][0]["name"] for asn in asns]

    dups = [name for name, count in Counter(product_names).items() if count > 1]
    if dups:
        logger.debug("Duplicate product names: %s", dups)

    return set(product_names), dups