import re
from functools import lru_cache

from ..exceptions import NotAnElementError, ValidationError
from ..periodic_table import periodictable
from .regex import NUCLEUS

_nucleus = re.compile(r"\A" + NUCLEUS + r"\Z", re.IGNORECASE | re.VERBOSE)


@lru_cache(maxsize=512)
def reconcile_nucleus(
    A: int | None = None,
    Z: int | None = None,
    E: str | None = None,
    mass: float | None = None,
    real: bool | None = None,
    label: str | None = None,
    speclabel: bool = True,
    nonphysical: bool = False,
    mtol: float = 1.0e-3,
    verbose: int = 1,
) -> tuple[int, int, str, float, bool, str]:
    r"""Forms consistent set of nucleus descriptors from all information
    from arguments, supplemented by the periodic table. At the least,
    must provide element identity somehow. Defaults to most-abundant
    isotope.

    Parameters
    ----------
    A
        Mass number, number of protons and neutrons.
    Z
        Atomic number, number of protons.
    E
        Element symbol from periodic table.
    mass
        Atomic mass [u].
    real
        Whether real or ghost/absent.
    label
        Atom label according to :py:data:`qcelemental.molparse.regex.NUCLEUS`.
    speclabel
        If `True`, interpret `label` as potentially full nucleus spec including
        ghosting, isotope, mass, tagging information, e.g., ``@13C_mine`` or
        ``He4@4.01``. If `False`, interpret `label` as only the user/tagging
        extension to nucleus label, e.g. ``_mine`` or ``4`` in the previous examples.
    nonphysical
        When `True`, turns off sanity checking that prevents periodic table
        violations (e.g, light uranium: ``1U@1.007``).
    mtol
        How different `mass` can be from a known nuclide mass and still
        merit the mass number assignment. Note that for elements dominated
        by a single isotope, the default may not be tight enough to
        prevent standard atomic weight (abundance-average of isotopes)
        from being labeled as the dominant isotope for A.
    verbose
        Quantity of printing.

    Returns
    -------
    A, Z, E, mass, real, userlabel : int, int, str, float, bool, str
        mass number, unless clues don't point to a known nuclide, in which case `-1`.
        atomic number.
        element symbol, capitalized.
        mass value [u].
        real/ghost.
        user portion of `label` if present, else ''.

    Raises
    ------
    qcelemental.NotAnElementError
    qcelemental.ValidationError

    Examples
    --------
    >>> reconcile_nucleus(E='co')
    >>> reconcile_nucleus(Z=27)
    >>> reconcile_nucleus(A=59, Z=27)
    >>> reconcile_nucleus(E='cO', mass=58.933195048)
    >>> reconcile_nucleus(A=59, Z=27, E='CO')
    >>> reconcile_nucleus(A=59, E='cO', mass=58.933195048)
    >>> reconcile_nucleus(label='co')
    >>> reconcile_nucleus(label='59co')
    >>> reconcile_nucleus(label='co@58.933195048')
    >>> reconcile_nucleus(A=59, Z=27, E='cO', mass=58.933195048, label='co@58.933195048')
    >>> reconcile_nucleus(A=59, Z=27, E='cO', mass=58.933195048, label='27@58.933195048')
    >>> reconcile_nucleus(label='27')
    59, 27, 'Co', 58.933195048, True, ''

    >>> reconcile_nucleus(label='co_miNe')
    >>> reconcile_nucleus(label='co_mIne@58.933195048')
    59, 27, 'Co', 58.933195048, True, '_mine'

    >>> reconcile_nucleus(E='cO', mass=58.933)
    >>> reconcile_nucleus(label='cO@58.933')
    59, 27, 'Co', 58.933, True, ''
    >>> assert 59, 27, 'Co', 58.933, True, '' == reconcile_nucleus(E='cO', mass=58.933, mtol=1.e-4))
    AssertionError

    >>> reconcile_nucleus(E='Co', A=60)
    >>> reconcile_nucleus(Z=27, A=60, real=True)
    >>> reconcile_nucleus(E='Co', A=60)
    >>> reconcile_nucleus(Z=27, mass=59.933817059)
    >>> reconcile_nucleus(A=60, Z=27, mass=59.933817059)
    >>> reconcile_nucleus(label='60Co')
    >>> reconcile_nucleus(label='27', mass=59.933817059)
    >>> reconcile_nucleus(label='Co', mass=59.933817059)
    >>> reconcile_nucleus(A=60, label='Co')
    60, 27, 'Co', 59.933817059, True, ''

    >>> reconcile_nucleus(E='Co', A=60, real=False))
    >>> reconcile_nucleus(A=60, Z=27, mass=59.933817059, real=0))
    >>> reconcile_nucleus(label='@60Co'))
    >>> reconcile_nucleus(label='Gh(27)', mass=59.933817059))
    >>> reconcile_nucleus(label='@Co', mass=59.933817059))
    >>> reconcile_nucleus(A=60, label='Gh(Co)'))
    60, 27, 'Co', 59.933817059, False, ''

    >>> reconcile_nucleus(Z=27, mass=200, nonphysical=True)
    60, 27, 'Co', 200.00000000, True, ''

    >>> reconcile_nucleus(mass=60.6, Z=27)
    >>> reconcile_nucleus(mass=60.6, E='Co')
    >>> reconcile_nucleus(mass=60.6, label='27')
    >>> reconcile_nucleus(label='Co@60.6')
    -1, 27, 'Co', 60.6, True, ''

    >>> reconcile_nucleus(mass=60.6, Z=27, A=61))
    >>> reconcile_nucleus(A=80, Z=27)
    >>> reconcile_nucleus(Z=27, mass=200)
    >>> reconcile_nucleus(Z=27, mass=-200, nonphysical=True)
    >>> reconcile_nucleus(Z=-27, mass=200, nonphysical=True)
    >>> reconcile_nucleus(Z=1, label='he')
    >>> reconcile_nucleus(A=4, label='3he')
    >>> reconcile_nucleus(label='@U', real=True)
    >>> reconcile_nucleus(label='U', real=False)
    ValidationError

    """

    # <<< define functions
    log_text = verbose >= 2

    def reconcile(exact, tests, feature):
        """Returns a member from `exact` that passes all `tests`, else raises error for `feature`."""

        for candidate in exact:
            assessment = [fn(candidate) for fn in tests]
            if log_text:
                text.append(
                    """Assess {} candidate {}: {} --> {}""".format(feature, candidate, assessment, all(assessment))
                )
            if all(assessment):
                return candidate

        err = """Inconsistent or unspecified {}: A: {}, Z: {}, E: {}, mass: {}, real: {}, label: {}""".format(
            feature, A, Z, E, mass, real, label
        )
        if verbose > -1:
            print("\n\n" + "\n".join(text))
        raise ValidationError(err)

    def offer_element_symbol(e):
        """Given an element, what can be suggested and asserted about Z, A, mass?"""

        _Z = periodictable.to_Z(e, strict=True)
        offer_atomic_number(_Z)

    def offer_atomic_number(z):
        """Given an atomic number, what can be suggested and asserted about Z, A, mass?"""

        z = int(z)

        z_symbol = periodictable.to_E(z)
        z_mass = periodictable.to_mass(z)
        z_a = periodictable.to_A(z)

        z_a2mass = periodictable._el2a2mass[z_symbol]
        z_a2mass_min = min(z_a2mass.keys())
        z_a2mass_max = max(z_a2mass.keys())
        z_mass2a_min = min(z_a2mass.values())
        z_mass2a_max = max(z_a2mass.values())

        Z_exact.append(z)
        Z_range.append(lambda x, z=z: x == z)

        A_exact.append(z_a)
        if nonphysical:
            A_range.append(lambda x: x == -1 or x >= 1)
            if log_text:
                text.append("""For A, input Z: {}, requires 1 < A or -1, nonphysical""".format(z))
        else:
            A_range.append(lambda x, amin=z_a2mass_min, amax=z_a2mass_max: x == -1 or (x >= amin and x <= amax))
            if log_text:
                text.append(
                    """For A, input Z: {} requires {} < A < {} or -1, the known mass number range for element""".format(
                        z, z_a2mass_min, z_a2mass_max
                    )
                )

        m_exact.append(z_mass)
        if nonphysical:
            m_range.append(lambda x: x > 0.5)
            if log_text:
                text.append("""For mass, input Z: {}, requires 0.5 < mass, nonphysical""".format(z))
        else:
            m_range.append(lambda x, mmin=z_mass2a_min, mmax=z_mass2a_max: x >= mmin - mmtol and x <= mmax + mmtol)
            if log_text:
                text.append(
                    """For mass, input Z: {} requires {} < mass < {} +/-{}, the known mass range for element""".format(
                        z, z_mass2a_min, z_mass2a_max, mmtol
                    )
                )

    def offer_mass_number(z, a):
        """Given a mass number and element, what can be suggested and asserted about A, mass?"""

        a = int(a)
        a_eliso = periodictable.to_E(z) + str(a)
        a_mass = periodictable.to_mass(a_eliso)

        A_exact.append(a)
        A_range.append(lambda x, a=a: x == a)
        if log_text:
            text.append("""For A, inputs Z: {}, A: {} require A == {}.""".format(z, a, a))

        m_exact.append(a_mass)
        m_range.append(lambda x, a_mass=a_mass: abs(x - a_mass) < mtol)
        if log_text:
            text.append("""For mass, inputs Z: {}, A: {} require abs(mass - {}) < {}""".format(z, a, a_mass, mtol))

    def offer_mass_value(z, m):
        """Given a mass and element, what can be suggested and asserted about A, mass?"""

        m = float(m)
        m_a = int(round(m, 0))
        m_eliso = periodictable.to_E(z) + str(m_a)

        try:
            if abs(periodictable.to_mass(m_eliso) - m) > mtol:
                # only offer A if known nuclide. C@12.4 != 12C
                m_a = -1
        except NotAnElementError:
            m_a = -1

        A_exact.append(m_a)
        A_range.append(lambda x, m_a=m_a: x == m_a)
        if log_text:
            text.append("""For A, inputs Z: {}, m: {} require A == {}""".format(z, m, m_a))

        m_exact.append(m)
        m_range.append(lambda x, m=m: x == m)
        if log_text:
            text.append("""For mass, inputs Z: {}, m: {} require m == {}""".format(z, m, m))

    def offer_reality(rgh):
        r_exact.append(rgh)
        r_range.append(lambda x, rgh=rgh: x == rgh)
        if log_text:
            text.append("""For real/ghost, input rgh: {} requires rgh == {}""".format(rgh, rgh))

    def offer_user_label(lbl):
        lbl = str(lbl).lower()
        l_exact.append(lbl)
        l_range.append(lambda x, lbl=lbl: x == lbl)
        if log_text:
            text.append("""For user label, input lbl: {} requires lbl == {}""".format(lbl, lbl))

    # <<< initialize

    text = ["", """--> Inp: A={}, Z={}, E={}, mass={}, real={}, label={}""".format(A, Z, E, mass, real, label)]

    Z_exact: list = []  # *_exact are candidates for the final value
    Z_range: list = []  # *_range are tests that the final value must pass to be valid
    A_exact: list = []
    A_range: list = []
    m_exact: list = []
    m_range: list = []

    r_exact = [True]  # default real/ghost is real
    r_range: list = []
    l_exact = [""]  # default user label is empty string
    l_range: list = []
    mmtol = 0.5  # tolerance for mass outside known masses for element

    # <<< collect evidence for Z/A/m, then reconcile Z

    if Z is not None:
        offer_atomic_number(Z)

    if E is not None:
        offer_element_symbol(E)

    if label is not None and speclabel is True:
        lbl_A, lbl_Z, lbl_E, lbl_mass, lbl_real, lbl_user = parse_nucleus_label(label)

        if lbl_Z is not None:
            offer_atomic_number(lbl_Z)
        if lbl_E is not None:
            offer_element_symbol(lbl_E)

    Z_final = reconcile(Z_exact, Z_range, "atomic number")
    E_final = periodictable.to_E(Z_final)

    # <<< collect more evidence for A/m, then reconcile them

    if A is not None:
        offer_mass_number(Z_final, A)

    if mass is not None:
        offer_mass_value(Z_final, mass)

    if real is not None:
        offer_reality(real)

    if label is not None:
        if speclabel:
            offer_reality(lbl_real)
            if lbl_A is not None:
                offer_mass_number(Z_final, lbl_A)
            if lbl_mass is not None:
                offer_mass_value(Z_final, lbl_mass)
            if lbl_user is not None:
                offer_user_label(lbl_user)
        else:
            offer_user_label(label)

    mass_final = reconcile(m_exact, m_range, "mass")
    A_final = reconcile(A_exact, A_range, "mass number")
    real_final = reconcile(r_exact, r_range, "real/ghost")
    user_final = reconcile(l_exact, l_range, "user label")

    if log_text:
        text.append(
            """<-- Out: A={}, Z={}, E={}, mass={}, real={}, user={}""".format(
                A_final, Z_final, E_final, mass_final, real_final, user_final
            )
        )

    if log_text:
        print("\n".join(text))

    return (A_final, Z_final, E_final, mass_final, real_final, user_final)


def parse_nucleus_label(label: str):
    r"""Separate molecule nucleus string into fields.

    Parameters
    ----------
    label
        Conveys at least element and ghostedness and possibly isotope, mass, and
        user info in accordance with :py:data:`qcelemental.molparse.regex.NUCLEUS`.

    Returns
    -------
    A, Z, E, mass, real, user : int or None, int or None, str or None, float or None, bool, str or None
        Field breakdown of `label`.

    Raises
    ------
    qcelemental.ValidationError
        If `label` does not match NUCLEUS.

    Examples
    --------
    >>> parse_nucleus_label('@ca_miNe')
    None, None, 'ca', None False, '_miNe'

    >>> parse_nucleus_label('Gh(Ca_mine)')
    None, None, 'Ca', None '_mine', False

    >>> parse_nucleus_label('@Ca_mine@1.07')
    None, None, 'Ca', 1.07 False, '_mine'

    >>> parse_nucleus_label('Gh(cA_MINE@1.07)')
    None, None, 'cA', 1.07 False, '_MINE'

    >>> parse_nucleus_label('@40Ca_mine@1.07')
    40, None, 'Ca', 1.07 False, '_mine'

    >>> parse_nucleus_label('Gh(40Ca_mine@1.07)')
    40, None, 'Ca', 1.07 False, '_mine'

    >>> parse_nucleus_label('444lu333@4.0')
    444, None, 'lu', 4.0 True, '333'

    >>> parse_nucleus_label('@444lu333@4.4')
    444, None, 'lu', 4.4 False, '333'

    >>> parse_nucleus_label('8i')
    8, None, 'i', None True, None

    >>> parse_nucleus_label('53_mI4')
    None, 53, None, None True, '_mI4'

    >>> parse_nucleus_label('@5_MINEs3@4.4')
    None, 5, None, 4.4 False, '_MINEs3'

    >>> parse_nucleus_label('Gh(555_mines3@0.1)')
    None, 555, None, 0.1 False, '_mines3'

    """
    matchobj = _nucleus.match(label)

    if matchobj:
        real = not (matchobj.group("gh1") or matchobj.group("gh2"))

        if matchobj.group("A"):
            A = int(matchobj.group("A"))
        else:
            A = None

        if matchobj.group("Z"):
            Z = int(matchobj.group("Z"))
        else:
            Z = None

        E = matchobj.group("E")

        if matchobj.group("user1"):
            user = matchobj.group("user1")
        elif matchobj.group("user2"):
            user = matchobj.group("user2")
        else:
            user = None

        if matchobj.group("mass"):
            mass = float(matchobj.group("mass"))
        else:
            mass = None
    else:
        raise ValidationError("""Nucleus label is not parseable: {}""".format(label))

    return A, Z, E, mass, real, user
