"""Convert initial to final or final to initial masses
for white dwarfs using a number of published IFMR trends.

Written by David R. Miller, University of British Columbia

Please send bug reports, and requests for changes (including 
additional trendlines) to drmiller@phas.ubc.ca

Available via wdifmr.org
Last edited August 2, 2026

Available IFMRs
---------------
- Miller_2026_gaia      : Miller et al. (2026), 2026ApJ...996...69M
- Miller_2026_Fit1      : Miller et al. (2026), 2026ApJ...996...69M
- Miller_2026_Fit2      : Miller et al. (2026), 2026ApJ...996...69M
- Cunningham_2024       : Cunningham et al. (2024), 2024MNRAS.527.3602C
- Hollands_2024         : Hollands et al. (2024), 2024MNRAS.527.9061H
- Marigo_2020           : Marigo et al. (2020), 2020NatAs...4.1102M
- Cummings_2018_parsec  : Cummings et al. (2018), 2018ApJ...866...21C
- ElBadry_2018           : El-Badry et al. (2018), 2018ApJ...860L..17E

The default IFMR is Miller_2026_Fit1.

Inputs and outputs
------------------
Mass may be a scalar, list, tuple, NumPy array, or pandas Series. Array-like
forward results are NumPy arrays; array-like inverse results are lists of
solution lists. Series inputs preserve their index. Unsupported values return
None, NaN, or an empty solution list, as appropriate. A message is printed once
per call if any value is unsupported or has multiple solutions.

Example usage
-------------
>>> from wd_ifmr import initial_to_final, final_to_initial

>>> initial_to_final(3.0)
0.773

>>> initial_to_final([1.0, 2.0, 3.0], ifmr="ElBadry_2018")
array([0.50472222, 0.59916667, 0.7143038 ])

>>> final_to_initial(0.8)
3.155172413793104
"""

import numpy as np

def _line(mi1, mf1, mi2, mf2, bounds):
    """Turn two (initial mass, final mass) points into a linear segment."""
    slope = (mf2 - mf1) / (mi2 - mi1)
    return mi1, mi2, slope, mf1 - slope * mi1, bounds


# Each segment is: (minimum Mi, maximum Mi, slope, intercept, bounds).
# For example, "[)" means minimum <= Mi < maximum.
IFMRS = {
    "Cummings_2018_parsec": [
        (0.87, 2.80, 0.0873, 0.476, "[]"),
        (2.80, 3.65, 0.181, 0.210, "(]"),
        (3.65, 8.20, 0.0835, 0.565, "(]"),
    ],
    "Cunningham_2024": [
        _line(1.09, 0.561, 2.65, 0.70, "[)"),
        _line(2.65, 0.70, 3.42, 0.79, "[)"),
        _line(3.42, 0.79, 5.06, 0.91, "[)"),
        _line(5.06, 0.91, 7.44, 1.30, "[)"),
    ],
    "ElBadry_2018": [
        _line(0.95, 0.50, 2.75, 0.67, "[)"),
        _line(2.75, 0.67, 3.54, 0.81, "[)"),
        _line(3.54, 0.81, 5.21, 0.91, "[)"),
        _line(5.21, 0.91, 8.00, 1.37, "[]"),
    ],
    "Hollands_2024": [
        _line(1.00, 0.552, 1.25, 0.595, "[)"),
        _line(1.25, 0.595, 1.50, 0.614, "[)"),
        _line(1.50, 0.614, 2.00, 0.632, "[)"),
        _line(2.00, 0.632, 2.50, 0.666, "[)"),
        _line(2.50, 0.666, 3.00, 0.727, "[)"),
        _line(3.00, 0.727, 3.50, 0.803, "[)"),
        _line(3.50, 0.803, 4.00, 0.861, "[)"),
        _line(4.00, 0.861, 5.00, 0.909, "[)"),
    ],
    "Marigo_2020": [
        (0.85, 1.510, 0.103, 0.447, "[]"),
        (1.51, 1.845, 0.399, 0.001, "(]"),
        (1.845, 2.21, -0.342, 1.367, "(]"),
        (2.21, 3.650, 0.181, 0.210, "(]"),
    ],
    "Miller_2026_gaia": [
        (2.67, 3.84, 0.179, 0.244, "[)"),
        (3.84, 8.39, 0.079, 0.628, "[)"),
    ],
    "Miller_2026_Fit1": [
        (0.84, 1.98, 0.160, 0.394, "[)"),
        (1.98, 2.33, -0.155, 1.015, "[)"),
        (2.33, 3.98, 0.174, 0.251, "[)"),
        (3.98, 8.39, 0.075, 0.644, "[)"),
    ],
    "Miller_2026_Fit2": [
        (0.84, 1.91, 0.168, 0.384, "[)"),
        (1.91, 2.78, 0.021, 0.666, "[)"),
        (2.78, 3.96, 0.187, 0.207, "[)"),
        (3.96, 8.39, 0.075, 0.646, "[)"),
    ],
}

DEFAULT_IFMR = "Miller_2026_Fit1"

def _convert_mass(mass, ifmr, inverse):
    if ifmr not in IFMRS:
        raise ValueError(f"Unknown IFMR {ifmr!r}. Choose from: {', '.join(IFMRS)}")

    segments = IFMRS[ifmr]

    def forward(mi):
        for low, high, slope, intercept, bounds in segments:
            lower_ok = mi >= low if bounds[0] == "[" else mi > low
            upper_ok = mi <= high if bounds[1] == "]" else mi < high
            if lower_ok and upper_ok:
                return slope * mi + intercept
        return None

    def one(value):
        if not inverse:
            return forward(value)

        solutions = []
        for low, high, slope, intercept, bounds in segments:
            mi = (value - intercept) / slope
            if np.isclose(mi, low, rtol=0.0, atol=1e-10):
                mi = low
            elif np.isclose(mi, high, rtol=0.0, atol=1e-10):
                mi = high
            predicted = forward(mi)
            if (low <= mi <= high and predicted is not None and
                    np.isclose(predicted, value, rtol=1e-10, atol=1e-12) and
                    not any(np.isclose(mi, old) for old in solutions)):
                solutions.append(float(mi))

        solutions.sort()
        if not solutions:
            return None
        return solutions[0] if len(solutions) == 1 else solutions

    if np.isscalar(mass):
        result = one(float(mass))
        multiple_found = isinstance(result, list)
        no_solution_found = result is None
    else:
        array = np.asarray(mass)
        results = [one(float(value)) for value in array.flat]
        multiple_found = any(isinstance(value, list) for value in results)
        no_solution_found = any(value is None for value in results)

        if inverse:
            results = [value if isinstance(value, list)
                       else [] if value is None
                       else [value]
                       for value in results]
            if mass.__class__.__module__.startswith("pandas"):
                result = mass.__class__(results, index=mass.index, name=mass.name)
            else:
                shaped = np.empty(array.shape, dtype=object)
                shaped.flat[:] = results
                result = shaped.tolist()
        else:
            results = [np.nan if value is None else value for value in results]
            if mass.__class__.__module__.startswith("pandas"):
                result = mass.__class__(results, index=mass.index, name=mass.name)
            else:
                result = np.asarray(results, dtype=float).reshape(array.shape)

    if multiple_found:
        print(f"{ifmr}: at least one final mass has multiple initial-mass "
              "solutions because this IFMR is non-monotonic at that input. "
              "All valid solutions are returned.")

    if no_solution_found:
        if not inverse:
            low, high = segments[0][0], segments[-1][1]
            upper_sign = "<=" if segments[-1][4][1] == "]" else "<"
            print(f"{ifmr}: at least one initial mass is outside the valid "
                  f"range {low:g} <= Mi {upper_sign} {high:g} solar masses. "
                  "Unsupported values are returned as None or NaN.")
        else:
            endpoints = [(slope * limit + intercept, included)
                         for low, high, slope, intercept, bounds in segments
                         for limit, included in ((low, bounds[0] == "["),
                                                 (high, bounds[1] == "]"))]
            low = min(value for value, included in endpoints)
            high = max(value for value, included in endpoints)
            low_included = any(np.isclose(value, low) and included
                               for value, included in endpoints)
            high_included = any(np.isclose(value, high) and included
                                for value, included in endpoints)
            lower_sign = "<=" if low_included else "<"
            upper_sign = "<=" if high_included else "<"
            print(f"{ifmr}: at least one final mass has no valid initial-mass "
                  f"solution. The overall final-mass span is {low:g} "
                  f"{lower_sign} Mf {upper_sign} {high:g} solar masses. "
                  "Unsupported scalar values return None; unsupported values "
                  "in a collection return an empty list.")

    return result


def initial_to_final(initial_mass, ifmr=DEFAULT_IFMR):
    """Convert initial mass(es) to final mass(es) with the selected IFMR."""
    return _convert_mass(initial_mass, ifmr, inverse=False)


def final_to_initial(final_mass, ifmr=DEFAULT_IFMR):
    """Convert final mass(es) to every valid initial-mass solution.

    A scalar returns a float or a list when ambiguous. In a collection, every
    input maps to a list of solutions. Multiple solutions produce a message.
    """
    return _convert_mass(final_mass, ifmr, inverse=True)


__all__ = ["initial_to_final", "final_to_initial", "IFMRS", "DEFAULT_IFMR"]
