import itertools
from typing import Any, Dict, List, Union

import numpy as np

from ..exceptions import ValidationError
from ..util import unique_everseen


def _apply_default(llist, default):
    return [default if (c is None) else c for c in llist]


def _high_spin_sum(mult_list):
    mm = 1
    for m in mult_list:
        mm += m - 1
    return mm


def _mult_ok(m):
    return isinstance(m, (int, np.integer, float, np.float64)) and m >= 1


def _sufficient_electrons_for_mult(z, c, m):
    """Require sufficient electrons in total: total mult ({}) - 1 > raw electrons ({}) - total chg ({})"""
    return m - 1 <= z - c


def _parity_ok(z, c, m):
    """Check total electrons (neutral protons `z` and charge `c`) is (un)paired-compatible with multiplicity `m`"""
    return (m % 2) != ((z - c) % 2)


# def _alpha_beta_allocator(z, c, m):
#    nbeta = (z - c - m + 1) // 2
#    nalpha = nbeta + m - 1
#    return nalpha, nbeta


def validate_and_fill_chgmult(
    zeff: np.ndarray,
    fragment_separators: np.ndarray,
    molecular_charge: Union[float, None],
    fragment_charges: Union[List[float], None],
    molecular_multiplicity: Union[int, None],
    fragment_multiplicities: Union[List[int], None],
    zero_ghost_fragments: bool = False,
    verbose: int = 1,
) -> Dict[str, Any]:
    r"""Forms molecular and fragment charge and multiplicity specification
    by completing and reconciling information from argument, supplemented
    by physical constraints and sensible defaults.

    Parameters
    ----------
    zeff
        (nat,) electron counts (float) for neutral atoms, generally Z nuclear charge.
        0 indicates ghosts such that a full fragment of 0s will be constained
        to `0 1` charge & multiplicity.
    fragment_separators
        (nfr - 1, ) indices splitting `zeff` into nfr fragments.
    molecular_charge
        Total charge for molecular system.
    fragment_charges
        (nfr,) known fragment charges with `None` as placeholder for
        unknown. Expected pre-defaulted so even if nothing known, if
        `fragment_separators` breaks `zeff` into `nfr=2` fragments, input
        value should be ``fragment_charges=[None, None]``.
    molecular_multiplicity
        Total multiplicity for molecular system.
    fragment_multiplicities
        (nfr,) known fragment charges with `None` as placeholder for
        unknown. Expected pre-defaulted so even if nothing known, if
        `fragment_separators` breaks `zeff` into `nfr=2` fragments, input
        value should be ``fragment_multiplicities=[None, None]``.
    zero_ghost_fragments
        Fragments composed entirely of ghost atoms (Zeff=0) are required to have
        chgmult `0 1`. When `False`, violations of this will cause a
        ValidationError. When `True`, treat ghost fragments indicated by `zeff` to
        contain superior information over chgmult arguments that might still
        correspond to full-real molecule. Clears information from
        `molecular_charge` and `molecular_multiplicity` and sets ghost fragments
        to `0 1`, leaving other positions free to readjust. Unused (prefer to set
        up such manipulations outside function call) but works.
    verbose
        Amount of printing.

    Returns
    -------
    molecular_charge : float
        Total charge for molecular system.
    fragment_charges : list of float
        (nfr,) Charge on each fragment.
    molecular_multiplicity : int
        Total multiplicity for molecular system.
    fragment_multiplicities : list of int
        (nfr,) Multiplicity for each fragment.

    Raises
    ------
    qcelemental.ValidationError
        When no solution to input arguments subject to the constraints below can be found.

    Notes
    -----
    Returns combination of total & fragment charge & multiplicity among
    values of S1-7 that fulfill rules R1-9. A few derived implications in I1-3.

    * Constraints

      * R1 require all chg & mult exist
      * R2 require total charge to be the sum of frag chg
      * R3 require mult is positive int
      * R4 require sufficient tot electrons for mult: mult - 1 <= neut_el - chg
      * R5 require total parity consistent among tot electrons and mult: (mult % 2) != ((neut_el - chg) % 2)
      * R6 require chg match input argument values
      * R7 require mult match input argument values
      * R8 require that tot = sum(frag) mult follow high spin addition unless tot & frag mult fully specified
      * R9 require that ghost fragments (zeff all 0) be neutral singlet

    * Allowed values

      * S1 suggest input argument values for tot chg, frag chg, tot mult or frag mult
      * S2 suggest sum frag chg for tot chg, allowing for indiv frag chg defaulting to 0
      * S3 suggest distributing unallocated chg onto frag chg
      * S4 suggest 0 default for frag chg
      * S5 suggest range of high-spin sum frag mult for tot mult, allowing for indiv frag mult defaulting to 1 or 2
      * S6 suggest range of unallocated mult = tot - high_spin_sum(frag - 1), allowing for all indiv but self defaulting to 1 or 2.
      * S7 suggest 1 or 2 default for frag mult

    * Implications

      * I1 won't form an ion just to be closed shell (would require choosing +1 vs. -1)
      * I2 unallocated chg or mult lands on the first unspecified fragment able to bear it (enforced by returning first match encountered; subsequent matches distribute charge to later frags)
      * I3 missing chg or mult from tot - frags will always be allocated as a block, not distributed

    Examples
    --------
    >>> validate_and_fill_chgmult(*sys('He'), 0, [0], 1, [1])
    0, [0], 1, [1]
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], None, [None])
    0, [0], 1, [1]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], None, [None, None])
    0, [0, 0], 1, [1, 1])
    >>> validate_and_fill_chgmult(*sys('He/He'), 2, [None, None], None, [None, None])
    2, [2, 0], 1, [1, 1])
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [2, None], None, [None, None])
    2, [2, 0], 1, [1, 1])
    >>> validate_and_fill_chgmult(*sys('He/He'), 0, [2, None], None, [None, None])
    0, [2, -2], 1, [1, 1])
    >>> validate_and_fill_chgmult(*sys('Ne/He/He'), -2, [None, 2, None], None, [None, None, None])
    -2, [-4, 2, 0], 1, [1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('Ne/He/He'), 2, [None, -2, None], None, [None, None, None])
    2, [4, -2, 0], 1, [1, 1, 1]
    #  9 - residual +4 distributes to first fragment able to wholly accept it (He+4 is no-go)
    >>> validate_and_fill_chgmult(*sys('He/He/Ne'), 2, [None, -2, None], None, [None, None, None])
    2, [0, -2, 4], 1, [1, 1, 1]
    # 10 - residual +4 unsuited for only open fragment, He, so irreconcilable
    >>> validate_and_fill_chgmult(*sys('He/He/Ne'), 2, [None, -2, 0], None, [None, None, None])
    ValidationError
    # 11 - non-positive multiplicity
    >>> validate_and_fill_chgmult(*sys('He/He/Ne'), 2, [2, -2, None], None, [None, None, None])
    2, [2, -2, 2], 1, [1, 1, 1])
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [-2, 2], None, [None, None])
    0, [-2, 2], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, -2], None, [None, None])
    -2, [0, -2], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('Ne/Ne'), 0, [None, 4], None, [None, None])
    0, [-4, 4], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('He/He/He'), 4, [2, None, None], None, [None, None, None])
    4, [2, 2, 0], 1, [1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), 0, [-2, 2], None, [None, None])
    0, [-2, 2], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), 0, [-2, -2], None, [None, None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], 0, [None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], None, [1])
    0, [0], 1, [1]
    # 20 - doublet non consistent with closed-shell, neutral default charge
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], None, [2])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], None, [3])
    0, [0], 3, [3]
    # 22 - insufficient electrons for pentuplet
    >>> validate_and_fill_chgmult(*sys('He'), None, [None], None, [5])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He'), None, [-1], None, [2])
    -1, [-1], 2, [2]
    # 24 - doublet not consistent with even charge
    >>> validate_and_fill_chgmult(*sys('He'), None, [-2], None, [2])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], None, [1, 1])
    0, [0, 0], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], None, [3, 1])
    0, [0, 0], 3, [3, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], None, [1, 3])
    0, [0, 0], 3, [1, 3]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], None, [3, 3])
    0, [0, 0], 5, [3, 3]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], 3, [3, 3])
    0, [0, 0], 3, [3, 3]
    # 30 - bad parity btwn mult and total # electrons
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [None, None], 2, [3, 3])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('H'), None, [None], None, [None])
    0, [0], 2, [2]
    >>> validate_and_fill_chgmult(*sys('H'), 1, [None], None, [None])
    1, [1], 1, [1]
    >>> validate_and_fill_chgmult(*sys('H'), None, [-1], None, [None])
    -1, [-1], 1, [1]
    >>> validate_and_fill_chgmult(*sys('funnyH'), None, [None], None, [None])
    0, [0], 1, [1]
    # 35 - insufficient electrons
    >>> validate_and_fill_chgmult(*sys('funnierH'), None, [None], None, [None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('H/H'), None, [None, None], None, [None, None])
    0, [0, 0], 3, [2, 2]
    >>> validate_and_fill_chgmult(*sys('H/He'), None, [None, None], None, [None, None])
    0, [0, 0], 2, [2, 1]
    >>> validate_and_fill_chgmult(*sys('H/He'), None, [1, 1], None, [None, None])
    2, [1, 1], 2, [1, 2]
    >>> validate_and_fill_chgmult(*sys('H/He'), -2, [-1, None], None, [None, None])
    -2, [-1, -1], 2, [1, 2]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), None, [1, None, 1, None], None, [None, None, None, None])
    2, [1, 0, 1, 0], 1, [1, 1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), None, [-1, None, 1, None], None, [None, None, None, None])
    0, [-1, 0, 1, 0], 1, [1, 1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), 2, [None, None, 1, None], None, [None, None, None, None])
    2, [1, 0, 1, 0], 1, [1, 1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), 3, [None, None, 1, None], None, [None, None, None, None])
    3, [0, 2, 1, 0], 2, [2, 1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('H/He'), None, [1, None], None, [2, None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('H/He'), None, [None, 0], None, [None, 2])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('H/He'), None, [None, -1], None, [None, 3])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), None, [None, 1, 0, 1], None, [None, None, None, None])
    2, [0, 1, 0, 1], 5, [2, 2, 2, 2]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), None, [None, 1, 0, None], None, [None, None, None, None])
    1, [0, 1, 0, 0], 4, [2, 2, 2, 1]
    >>> validate_and_fill_chgmult(*sys('H/He/Na/Ne'), None, [None, 1, 0, None], None, [None, None, 4, None])
    1, [0, 1, 0, 0], 6, [2, 2, 4, 1]
    >>> validate_and_fill_chgmult(*sys('He/He/He'), 0, [None, None, 1], None, [1, None, 2])
    0, [0, -1, 1], 3, [1, 2, 2]
    >>> validate_and_fill_chgmult(*sys('N/N/N'), None, [1, 1, 1], 3, [None, 3, None])
    3, [1, 1, 1], 3, [1, 3, 1]
    >>> validate_and_fill_chgmult(*sys('N/N/N'), None, [1, 1, 1], 3, [None, None, None])
    3, [1, 1, 1], 3, [3, 1, 1]
    >>> validate_and_fill_chgmult(*sys('N/N/N'), None, [None, None, None], 3, [None, None, 2])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('N/N/N'), 1, [None, -1, None], 3, [None, None, 2])
    1, [2, -1, 0], 3, [2, 1, 2]
    # 55 - both (1, (1, 0.0, 0.0), 4, (1, 3, 2)) and (1, (0.0, 0.0, 1), 4, (2, 3, 1)) plausible
    >>> validate_and_fill_chgmult(*sys('N/Ne/N'), 1, [None, None, None], 4, [None, 3, None])
    1, [1, 0, 0], 4, [1, 3, 2]
    >>> validate_and_fill_chgmult(*sys('N/Ne/N'), None, [None, None, 1], 4, [None, 3, None])
    1, [0, 0, 1], 4, [2, 3, 1]
    >>> validate_and_fill_chgmult(*sys('He/He'), None, [-1, 1], None, [None, None])
    0, [-1, 1], 3, [2, 2]
    >>> validate_and_fill_chgmult(*sys('Gh'), 1, [None], None, [None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('Gh'), -1, [None], None, [None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('Gh'), None, [None], 3, [None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('He/Gh'), None, [2, None], None, [None, None])
    2, [2, 0], 1, [1, 1]
    >>> validate_and_fill_chgmult(*sys('Gh/He'), None, [2, None], None, [None, None])
    ValidationError
    >>> validate_and_fill_chgmult(*sys('Gh/He/Ne'), 2, [None, -2, None], None, [None, None, None])
    2, [0, -2, 4], 1, [1, 1, 1]
    >>> validate_and_fill_chgmult(*sys('Gh/He/Gh'), 1, [None, None, None], None, [None, None, None])
    1, [0, 1, 0], 2, [1, 2, 1]
    >>> sys = {
        'He': (np.array([2]), np.array([])),
        'He/He': (np.array([2, 2]), np.array([1])),
        'Ne/He/He': (np.array([10, 2, 2]), np.array([1, 2])),
        'He/He/Ne': (np.array([2, 2, 10]), np.array([1, 2])),
        'Ne/Ne': (np.array([10, 10]), np.array([1])),
        'He/He/He': (np.array([2, 2, 2]), np.array([1, 2])),
        'H': (np.array([1]), np.array([])),
        'funnyH': (np.array([0]), np.array([])),  # has no electrons
        'funnierH': (np.array([-1]), np.array([])),  # has positron
        'H/H': (np.array([1, 1]), np.array([1])),
        'H/He': (np.array([1, 2]), np.array([1])),
        'H/He/Na/Ne': (np.array([1, 2, 11, 10]), np.array([1, 2, 3])),
        'N/N/N': (np.array([7, 7, 7]), np.array([1, 2])),
        'N/Ne/N': (np.array([7, 10, 7]), np.array([1, 2])),
        'He/Gh': (np.array([2, 0]), np.array([1])),
        'Gh/He': (np.array([0, 2]), np.array([1])),
        'Gh': (np.array([0, 0]), np.array([])),
        'Gh/He/Ne': (np.array([0, 0, 2, 10]), np.array([2, 3])),
        'Gh/He/Gh': (np.array([0, 2, 0]), np.array([1, 2]))}

    """
    log_full = verbose >= 2
    log_brief = verbose >= 2  # TODO: Move back to 1
    text = []

    def int_if_possible(val):
        if isinstance(val, float) and val.is_integer():
            return int(val)
        else:
            return val

    molecular_multiplicity = int_if_possible(molecular_multiplicity)
    fragment_multiplicities = [int_if_possible(m) for m in fragment_multiplicities]
    if (molecular_multiplicity and molecular_multiplicity < 1.0) or any(m < 1.0 for m in fragment_multiplicities if m):
        raise ValidationError(
            f"validate_and_fill_chgmult(): Multiplicity must be positive. m: {molecular_multiplicity}, fm: {fragment_multiplicities}"
        )

    felez = np.split(zeff, fragment_separators)
    nfr = len(felez)
    if log_full:
        text.append("felez: {}".format(felez))

    cgmp_exact_c = []  # exact_* are candidates for the final value
    cgmp_exact_fc: List[List[float]] = [[] for f in range(nfr)]
    cgmp_exact_m = []
    cgmp_exact_fm: List[List[int]] = [[] for f in range(nfr)]

    cgmp_range = []  # tests that the final value must pass to be valid
    cgmp_rules = []  # key to what rules in cgmp_range are T/F

    real_fragments = np.array([not all(f == 0 for f in felez[ifr]) for ifr in range(nfr)])
    all_fc_known = all(f is not None for f in fragment_charges)
    all_fm_known = all(f is not None for f in fragment_multiplicities)
    if log_full:
        text.append("all_fc_known: {}".format(all_fc_known))
        text.append("all_fm_known: {}".format(all_fm_known))

    if zero_ghost_fragments and not all(real_fragments):
        if log_brief:
            print("possibly adjusting charges")
        molecular_charge = None
        fragment_charges = [(fr if real_fragments[ifr] else 0.0) for ifr, fr in enumerate(fragment_charges)]
        molecular_multiplicity = None
        fragment_multiplicities = [(fr if real_fragments[ifr] else 1) for ifr, fr in enumerate(fragment_multiplicities)]

    # <<< assert broad physical requirements

    #   * (R1) require all chg & mult exist
    cgmp_range.append(
        lambda c, fc, m, fm: c is not None
        and all(f is not None for f in fc)
        and m is not None
        and all(f is not None for f in fm)
    )
    cgmp_rules.append("1")

    #   * (R2) require total charge to be the sum of fragment charges
    cgmp_range.append(lambda c, fc, m, fm: c == sum(fc))
    cgmp_rules.append("2")

    #   * (R3) require mult is positive int
    cgmp_range.append(lambda c, fc, m, fm: _mult_ok(m) and all(_mult_ok(f) for f in fm))
    cgmp_rules.append("3")

    # <<< assert electron count requirements

    zel = np.sum(zeff)  # note: number electrons in neutral species, not number total electrons
    fzel = [np.sum(f) for f in felez]
    if log_full:
        text.append("zel: {}".format(zel))
        text.append("fzel: {}".format(fzel))

    #   * (R4) require sufficient electrons for mult: mult - 1 <= neutral_electrons - chg
    cgmp_range.append(lambda c, fc, m, fm: _sufficient_electrons_for_mult(zel, c, m))
    cgmp_rules.append("4")
    for ifr in range(nfr):
        cgmp_range.append(
            lambda c, fc, m, fm, ifr=ifr: _sufficient_electrons_for_mult(fzel[ifr], fc[ifr], fm[ifr])  # type: ignore
        )
        cgmp_rules.append("4-" + str(ifr))

    #   * (R5) require total parity consistent among neutral_electrons, chg, and mult
    cgmp_range.append(lambda c, fc, m, fm: _parity_ok(zel, c, m))
    cgmp_rules.append("5")
    for ifr in range(nfr):
        cgmp_range.append(lambda c, fc, m, fm, ifr=ifr: _parity_ok(fzel[ifr], fc[ifr], fm[ifr]))  # type: ignore
        cgmp_rules.append("5-" + str(ifr))

    # <<< (R6, R7, S1) assert & suggest input values

    if molecular_charge is not None:
        cgmp_exact_c.append(molecular_charge)
        cgmp_range.append(lambda c, fc, m, fm: c == molecular_charge)
        cgmp_rules.append("6")
    for ifr, chg in enumerate(fragment_charges):
        if chg is not None:
            cgmp_exact_fc[ifr].append(chg)
            cgmp_range.append(lambda c, fc, m, fm, ifr=ifr, chg=chg: fc[ifr] == chg)  # type: ignore
            cgmp_rules.append("6-" + str(ifr))
    if molecular_multiplicity is not None:
        cgmp_exact_m.append(molecular_multiplicity)
        cgmp_range.append(lambda c, fc, m, fm: m == molecular_multiplicity)
        cgmp_rules.append("7")
    for ifr, mult in enumerate(fragment_multiplicities):
        if mult is not None:
            cgmp_exact_fm[ifr].append(mult)
            cgmp_range.append(lambda c, fc, m, fm, ifr=ifr, mult=mult: fm[ifr] == mult)  # type: ignore
            cgmp_rules.append("7-" + str(ifr))

    # <<< assert high-spin-rule and suggest "missing quantity" and default values

    #   * (S2) suggest net frag charge for total charge, allowing for indiv frag defaulting to 0
    cgmp_exact_c.append(sum(filter(None, fragment_charges)))

    missing_frag_chg = 0.0 if molecular_charge is None else molecular_charge
    missing_frag_chg -= sum(filter(None, fragment_charges))

    #   * (S3) suggest distributing unallocated charge onto fragment
    #   * (S4) suggest 0 default charge for fragment
    for ifr in range(nfr):
        if fragment_charges[ifr] is None:  # unneeded, but shortens the exact lists
            cgmp_exact_fc[ifr].append(missing_frag_chg)
            cgmp_exact_fc[ifr].append(0.0)

    #   * (R8) require that frag mult follow high spin addition unless fully specified
    if molecular_multiplicity is None or any(f is None for f in fragment_multiplicities):
        cgmp_range.append(lambda c, fc, m, fm: m == _high_spin_sum(fm))
        cgmp_rules.append("8")

    #   * (S5) suggest range of net frag mult for total mult, allowing for indiv frag defaulting to 1 or 2.
    #          many in range may be unphysical, but those will be caught by physical rules.
    if molecular_multiplicity is None:  # unneeded, but shortens the exact lists
        frag_mult_hi = _high_spin_sum(_apply_default(fragment_multiplicities, 2))
        frag_mult_lo = _high_spin_sum(_apply_default(fragment_multiplicities, 1))
        try:
            mult_range = range(frag_mult_lo, frag_mult_hi + 1)
        except TypeError:
            if frag_mult_lo == frag_mult_hi:
                mult_range = [frag_mult_hi]
            else:
                raise ValidationError(
                    f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
                )
        for m in mult_range:
            cgmp_exact_m.append(m)

    #   * (S6) suggest range of missing mult = tot - high_spin_sum(frag - 1),
    #          allowing for all indiv but self defaulting to 1 or 2.  Many in range
    #          may be unphysical, but those will be caught by physical rules.
    #   * (S7) suggest 1 or 2 default multiplicity for fragment
    if molecular_multiplicity is not None and any(f is None for f in fragment_multiplicities):
        frag_mult_less_one_none = fragment_multiplicities[:]
        frag_mult_less_one_none.remove(None)  # "missing" slot to solve for
        frag_mult_hi = _high_spin_sum(_apply_default(frag_mult_less_one_none, 2))
        frag_mult_lo = _high_spin_sum(_apply_default(frag_mult_less_one_none, 1))
        missing_mult_hi = molecular_multiplicity - frag_mult_lo + 1
        missing_mult_lo = molecular_multiplicity - frag_mult_hi + 1
    else:
        missing_mult_hi = 0
        missing_mult_lo = 0

    for ifr in range(nfr):
        if fragment_multiplicities[ifr] is None:  # unneeded, but shortens the exact lists
            try:
                mult_range = reversed(range(max(missing_mult_lo, 1), missing_mult_hi + 1))
            except TypeError:
                if missing_mult_lo == missing_mult_hi:
                    mult_range = [missing_mult_hi]
                else:
                    raise ValidationError(
                        f"Cannot process: please fully specify float multiplicity: m: {molecular_multiplicity} fm: {fragment_multiplicities}"
                    )
            for m in mult_range:
                cgmp_exact_fm[ifr].append(m)
            cgmp_exact_fm[ifr].append(1)
            cgmp_exact_fm[ifr].append(2)

    #   * (R9) require that ghost fragments be neutral singlet
    for ifr in range(nfr):
        if all(f == 0 for f in felez[ifr]):
            cgmp_range.append(lambda c, fc, m, fm, ifr=ifr: fc[ifr] == 0 and fm[ifr] == 1)  # type: ignore
            cgmp_rules.append("9-" + str(ifr))

    # <<< reconcile and report

    def reconcile(exact_c, exact_fc, exact_m, exact_fm):
        """Returns a member from all combinations of `exact` that passes all tests in cgmp_range, else raises error."""

        # remove duplicates
        uniq_c = unique_everseen(exact_c)
        uniq_fc = [unique_everseen(f) for f in exact_fc]
        uniq_m = unique_everseen(exact_m)
        uniq_fm = [unique_everseen(f) for f in exact_fm]
        text.append("c: {}".format(list(exact_c)))
        for f in exact_fc:
            text.append("fc: {}".format(list(f)))
        text.append("m: {}".format(list(exact_m)))
        for f in exact_fm:
            text.append("fm: {}".format(list(f)))

        header = True
        for candidate in itertools.product(*[uniq_c, itertools.product(*uniq_fc), uniq_m, itertools.product(*uniq_fm)]):
            cc, cfc, cm, cfm = candidate
            if header:
                if log_full:
                    text.append(
                        """Assess candidate {}: {}""".format(
                            candidate, " ".join(("{:3}".format(r) for r in cgmp_rules))
                        )
                    )
                header = False
            assessment = [fn(cc, cfc, cm, cfm) for fn in cgmp_range]
            sass = ["{:3}".format("T" if b else "") for b in assessment]
            if log_full:
                text.append("""Assess candidate {:}: {} --> {}""".format(candidate, " ".join(sass), all(assessment)))
            if all(assessment):
                return candidate

        err = """Inconsistent or unspecified chg/mult: sys chg: {}, frag chg: {}, sys mult: {}, frag mult: {}""".format(
            molecular_charge, fragment_charges, molecular_multiplicity, fragment_multiplicities
        )
        extra_err = [ln for ln in text if "candidate" in ln]
        if extra_err:
            err += "\n\n" + "\n".join(extra_err)
        raise ValidationError(err)

    def stringify(start, final):
        fcgmp = "{:^4}"
        return fcgmp.format(final) if final == start else fcgmp.format("(" + str(int(final)) + ")")

    # TODO could winnow down the exact_* lists a bit by ruling out
    #      independent values. do this if many-frag molecular systems take too
    #      long in the itertools.product

    c_final, fc_final, m_final, fm_final = reconcile(cgmp_exact_c, cgmp_exact_fc, cgmp_exact_m, cgmp_exact_fm)

    c_text = stringify(molecular_charge, c_final)
    fc_text = ", ".join((stringify(fs, ff) for fs, ff in zip(fragment_charges, fc_final)))
    m_text = stringify(molecular_multiplicity, m_final)
    fm_text = ", ".join((stringify(fs, ff) for fs, ff in zip(fragment_multiplicities, fm_final)))

    brief = []
    if log_brief:
        brief.append("    {:26} {}".format("      charge = " + c_text, "fragments = " + fc_text))
        brief.append("    {:26} {}".format("multiplicity = " + m_text, "fragments = " + fm_text))

    been_defaulted = []
    if c_text.count("(") + fc_text.count("(") > 1:
        been_defaulted.append("charge")
    if "(" in m_text or "(" in fm_text:
        been_defaulted.append("multiplicity")

    if been_defaulted and log_brief:
        brief.append(
            "    Note: Default values have been applied for {}. Specify intentions in molecule input block".format(
                " and ".join(been_defaulted)
            )
        )

    if (m_final != _high_spin_sum(fm_final)) and log_brief:
        brief.append(
            "    Warning: Total multiplicity is not high-spin sum of fragments; may be clobbered by psi4.core.Molecule.update_geometry()."
        )

    if log_full:
        print("\n".join(text))
    if log_brief:
        # TODO add back when printing worked out
        # print('\n'.join(brief))
        pass

    return {
        "molecular_charge": float(c_final),
        "fragment_charges": [float(f) for f in fc_final],
        "molecular_multiplicity": m_final,
        "fragment_multiplicities": list(fm_final),
    }
