"""This module defines classes and functions to display
estimated results from analysis of confusion-matrix data.

The probabilistic models for display are obtained
from a single cm_model.ConfMatrixModel instance.

Results are shown as figures and tables.
Tables can be saved in LaTeX tabular format OR in tab-delimited text files.

*** Main Display Classes:

ConfMatrixDisplaySet: Root structured container for all display results

GroupFactorDisplaySet: a container for all results related to groups / differences

PopulationDisplaySet: a container for all displays related to ONE Population category,
    (1) estimated for a random individual in the population from which subjects were recruited,
    (2) estimated for the population mean,
    (3) overview of individual participants in the group from this population
    (2) estimated for each individual test participant.

PopulationDisplay: container for all predictive displays for ONE population:
    (1) Mutual Information (MI) between stimulus and response phoneme categories.
    (2) Probability of Correct (PC) phoneme recognition.
    (3) Response Probabilities in confusion-matrix cells.
    (4) Credible differences between test conditions for these measures.

ParticipantGroupDisplay: overview of individual results in ONE participant group

SubjectDisplay: predictive results for one participant (same as PopulationDisplay)

GroupEffectSet: container for all displays illustrating differences between populations,
    if the experiment included more than one group.

PopulationDiff: container for ONE type of differences between populations
    either population mean (if model allows) or random individual.

Each display element can be accessed and modified by the user script, before saving.


*** Usage Example:

cm_display = ConfMatrixDisplaySet.show(cmm, **kwargs)
cm_display.save(result_dir, figure_format='pdf', table_format='csv')

Here,
    cmm is a cm_model.ConfMatrixModel instance,
    kwargs are optional keyword arguments controlling display details, e.g.,
    table_format= 'tex',
    figure_format= 'pdf'

    result_dir is the top directory where all results are saved.

Display elements of the cm_display object may be modified, if desired,
before saving.

Figures and tables are assigned descriptive names,
and stored in subdirectories and files with names constructed from
names of populations and subjects,
and labels of performance measures.

If there is more than one Group, i.e., more than one Population,
one subdirectory is created for each population.


*** Global module parameters that may be changed by user:
Defined in global variable FMT below.
Other formatting display parameters are defined in cm_display_format.FMT


*** Version History:
* Version 0.8:
2026-02-21, allow population / group displays in subset of group_factor dimensions
2026-02-11, allow display with empty sr_factors, mi_factors, or pc_factors
2025-10-24, some new and some changed parameters for display details, for user choice.
2025-10-16, using pandas.DataFrame for all result tables,
            file format defined by ConfMatrixDisplaySet.save(..., table_format=xxx)

* Versions <= 0.7:
2018-09-14 first functional version
"""
# *** simplify class structure ?
# *** credible differences (arrows) in measure-range plots ? ***
import numpy as np
from pathlib import Path
import logging
from string import whitespace
import pandas as pd

from . import cm_display_format as fmt

from .cm_data import group_dir_str
from .cm_data import UserInputError

from samppy import credibility_pd as cred_pd

logger = logging.getLogger(__name__)

# Fixed keys for performance measures:
SR_KEY = 'sr'   # stim-response
MI_KEY = 'mi'   # mutual information
PC_KEY = 'pc'   # probability of correct response
# *** use class Measure(StrEnum) instead ? with properties name, short_name, unit ? ***
# *** hide display choices in cm_display_format ?

# ---------------------------- User-selectable display parameters
FMT = {'sr_factors': (),    # sequence of test-factors for display of Resp.prob results
       'pc_factors': (),    # sequence of test-factors for display of Prob.Correct results
       'mi_factors': (),    # sequence of test-factors for display of Mutual-Information results
       'file_labels': dict(mi='MI',
                           pc='PC',
                           sr='SR'),  # label in file names or table header
       'plot_labels': dict(mi='MI (bits / test item)',
                           pc='Prob. Correct (%)',
                           sr='Resp.Prob.'),  # plot y_labels
       'n_samples': 1000,   # in ResponseProbModel approximate distribution
       'credibility_limit': 0.7,
       'credibility': 'Credibility',
       'probability': 'Probability',    # plot label and heading in Situation profile tables
       'diff_threshold': 0.001,         # min response-prob difference to be considered
       'percentiles': [5., 50., 95.],   # allowing 1, 2, 3, or more percentiles
       'random_individual': True,       # show result for random individual in population
       'population_mean': False,        # show result for population mean, (needs sampled model)
       'participants': False,           # show results for each respondent
       'participant_group': True,      # show descriptive results for participant group
       'population_mean_dir': 'population_mean',        # directory name
       'random_individual_dir': 'random_individual',    # directory name
       'participant_group_dir': 'participant_group',    # directory name
       'participants_dir': 'participants',              # directory name
       'group_factor_sep': '\u00D7',    # cross-char. separating group "dimensions" in directory name
       'group_join_str': '_',           # between group dimension and category in path string
       'group_effects_dir': '-effects', # marker at end of directory name
       'anonymous': '-',    # fake category label when NO categories are defined
       }
# dict for display parameters
DEFAULT_FIGURE_FORMAT = 'pdf'
DEFAULT_TABLE_FORMAT = 'txt'


def set_format_param(**kwargs):
    """Set / modify format parameters
    Called before any displays are generated.
    :param kwargs: dict with any formatting variables
    :return: None
    """
    other_fmt = dict()
    for (k, v) in kwargs.items():
        k = k.lower()
        if k in FMT:
            FMT[k] = v
        else:
            other_fmt[k] = v
    FMT['percentiles'].sort()  # ensure increasing values
    if len(other_fmt) > 0:
        # may include 'mpl_style' and 'mpl_params', and more...
        fmt.set_format_param(**other_fmt)



# ---------------------------------------------- Elementary Display Classes:
class DisplayNode:
    """Superclass for elements in a ConfMatrixDisplaySet structure
    allowing pretty printing of contents.
    """
    def __repr__(self, indent=1):
        """Super-method common for all subclasses,
        with indented repr for object sub-variables.
        :param indent: integer indentation level for object sub-variables.
        :return: string
        """
        def dict_repr(dict_v, indent):
            """ Sub-method for dict variables, which may contain DisplayNode values.
            """
            tabs = '\n' + indent * '\t'
            return ('{' + tabs
                    + (',' + tabs).join(f'{repr(k)}: {sub_repr(v, indent+1)}'
                                        for (k, v) in dict_v.items())
                    + tabs + '}')
        # -------------------------------------------------

        def sub_repr(var, indent):
            if isinstance(var, DisplayNode):
                return var.__repr__(indent+1)
            elif isinstance(var, dict):
                return dict_repr(var, indent+1)
            else:
                return repr(var)
        # -------------------------------------------------

        tabs = '\n' + indent * '\t'
        return (self.__class__.__name__ + '(' + tabs
                + (','+tabs).join(f'{k}={sub_repr(v, indent)}'
                                  for (k, v) in vars(self).items())
                + ')')


# -------------------------------- elementary low-level display classes
class MeasureDisplay(DisplayNode):
    """Plot and table display of ONE scalar performance measure
    vs. conditions in ONE user-requested tuple of one or more test-factor "dimensions".
    The measure can be either Prob.Correct (pc) or Mutual Information (mi).
    """
    def __init__(self, plt=None, tab=None, diff=None, path=None):
        """
        :param plt: (optional) a fmt.FigureRef instance with percentile plot
        :param tab: (optional) a fmt.ResultTable instance with corresponding range table
        :param diff: (optional) a fmt.ResultTable instance with measure differences
                    between categories in selected test-factor(s)
        :param path: (optional) Path to directory containing saved files from self,
            to be assigned by save() method.
            File names are defined by sub-objects.
        """
        self.plt = plt
        self.tab = tab
        self.diff = diff
        self.path = path

    def save(self, path,
             figure_format=None,
             table_format=None,
             **kwargs):
        """Save plot and table displays to files.
        May be called repeatedly with different file formats and kwargs.
        :param path: path to directory for saving files
        :param figure_format: (optional) single figure-file format string
        :param table_format: (optional) single table-file format string
        :param kwargs: (optional) additional parameters passed to ResultTable.save() method.
            NOTE: NO extra kwargs allowed for plot.save() method!
        :return: None
        """
        if figure_format is None and table_format is None:
            figure_format = DEFAULT_FIGURE_FORMAT
            table_format = DEFAULT_TABLE_FORMAT
        # One of them may be None -> only the other type of data are saved.
        path.mkdir(parents=True, exist_ok=True)
        self.path = path
        if figure_format is not None:
            if self.plt is not None:
                self.plt.save(path, figure_format=figure_format)
        if table_format is not None:
            if self.tab is not None:
                self.tab.save(path, table_format=table_format, **kwargs)
            if self.diff is not None:
                self.diff.save(path, table_format=table_format, **kwargs)

    @classmethod
    def show_quantiles(cls, m_key, tf_keys, m_ds):
        """Create percentile displays, with
        plt = a plot of percentiles vs. test_factors
        tab = a table of percentiles vs. test_factors
        :param m_key: string name of this measure
        :param tf_keys: tuple with one or more test-factor keys for display
        :param m_ds: pd.Series instance with indices test-factor keys
            and ONE column with key m_key
        :return: single cls instance with plot and table displays
        """
        quantiles = np.array(FMT['percentiles']) / 100.
        if len(tf_keys) == 0:  # use ALL rows as samples, NO grouping
            # mq_ds = m_ds.groupby(level=1,
            #                      sort=False, group_keys=True).quantile(quantiles)
            mq_ds = m_ds.quantile(quantiles)
            # insert ONE unnamed test condition for this result
            mq_ds.index = pd.MultiIndex.from_product(([FMT['anonymous']], quantiles),
                                                    names=[FMT['anonymous'], None])
        else:
            mq_ds = m_ds.groupby(level=tf_keys,
                                   sort=False, group_keys=True).quantile(quantiles)
        tab_perc = fmt.tab_percentiles_pd(mq_ds,
                                          file_label=FMT['file_labels'][m_key])
        fig_perc = fmt.fig_percentiles(tab_perc,
                                       y_label=FMT['plot_labels'][m_key],
                                       file_label=FMT['file_labels'][m_key])
        return cls(plt=fig_perc, tab=tab_perc)  # NO diff results

    @staticmethod
    def show_diffs(m_key, tf_keys, m_ds):
        """Table with credible differences between test-factor categories,
        corresponding to results already shown as quantile plots and tables.
        If more than one test-factor "dimension" is included,
        differences are shown between categories in tf_keys[1:], for each category in tf_keys[0],
        because show_quanties plots tf_keys[0] categories along x-axis, and
        tf_keys[1:] as separate ranges within each x-axis category.
        :param m_ds: pd.Series instance with indices test-factor keys
            and values of a scalar measure m_key
        :param m_key: string name of this measure
        :param tf_keys: tuple with one or more test-factor "dimensions" for display
        :return: a fmt.DiffTable instance with credible differences between test-factor categories
        """
        # NOTE: v. 0.8-: Compare only between tf_keys[1:], given tf_keys[0], if several dimensions
        if len(tf_keys) == 0:
            return None
        if len(tf_keys) == 1:
            diff_axis = tf_keys
            case_axis = ()
        else:
            diff_axis = tf_keys[1:]
            case_axis = tf_keys[0:1]
        diff_pd = cred_pd.cred_diff(m_ds, diff_axis=diff_axis, case_axis=case_axis,
                                    p_lim=FMT['credibility_limit'])  # wrong order ******
        return fmt.tab_credible_diff(diff_pd, diff_head=diff_axis, case_head=case_axis,
                                     cred_head=FMT['credibility'],
                                     y_label=FMT['file_labels'][m_key],  #FMT['plot_labels'][m_key],
                                     file_label=FMT['file_labels'][m_key])

    @classmethod
    def display(cls, m_key, tf_keys, m_ds):
        """Create all displays for ONE SCALAR performance measure,
        stored as
        plt = a plot of percentiles vs. test-factor categories
        tab = a table of percentiles vs. test-factor categories
        diff = a table of credible differences between test-factor categories
        :param m_ds: pd.Series instance with indices test-factor keys
            and ONE column with key m_key
        :param m_key: string name of this measure
        :param tf_keys: tuple with one or more test-factor keys for display
        :return: single cls instance plot and table displays
        """
        m_disp = cls.show_quantiles(m_key, tf_keys, m_ds)
        m_disp.diff = cls.show_diffs(m_key, tf_keys, m_ds)
        return m_disp


class CellDisplay(DisplayNode):
    """Display groups of phoneme stimulus-response cells with
    jointly credibly different response probabilities
    between categories in the main FIRST test factor,
    for some combination of categories in secondary test factor(s).
    For now, only a table is presented.
    Plot display may be added in a future version.
    """
    # ****** -> subclass of MeasureDisplay, if it can also create a plot?
    def __init__(self, tab):
        # ******************** how to plot response-prob matrix ? *******
        self.tab = tab

    def save(self, dir, figure_format=None, table_format=DEFAULT_TABLE_FORMAT, **kwargs):
        """
        :param dir: path to directory for saving
        :param figure_format: Not used
        :param table_format: file suffix for table
        :param kwargs: additional kwargs for pandas table save
        :return: None
        """
        if self.tab is not None:
            dir.mkdir(parents=True, exist_ok=True)
            self.tab.save(dir, table_format=table_format, **kwargs)

    @classmethod
    def display(cls, cmf, tf_keys, u_ds):
        """Make table of stimulus-response cells with credibly different response probabilities
        between pair(s) of test-factor categories
        :param cmf: a ConfMatrixFrame instance
        :param tf_keys: tuple with one or more test-factor keys for display
        :param u_ds: pd.DataFrame with sampled response probabilities,
            index levels = (_sample, *test-factors);
            columns = cmf.stim_resp_index
        :return: a cls instance, or None if nothing to show
        """
        if len(tf_keys) == 0:
            return None
        case_axis = tuple(cmf.stim_resp_index.names)
        case_head = case_axis
        if len(tf_keys) > 1:
            case_axis = tf_keys[1:] + case_axis
            case_head = tf_keys[1:] + case_head
        diff_axis = tf_keys[0:1]
        diff = cred_pd.cred_diff(u_ds, diff_axis=diff_axis, case_axis=case_axis,
                                 p_lim=FMT['credibility_limit'],
                                 threshold=FMT['diff_threshold'])
        diff_df = fmt.tab_credible_diff(diff, diff_head=diff_axis, case_head=case_head,
                                        cred_head=FMT['credibility'],
                                        y_label=FMT['plot_labels'][SR_KEY],  #'R ' + FMT['probability'],
                                        file_label=FMT['file_labels'][SR_KEY]
                                        )
        return cls(tab=diff_df)


# ---------------------------------------------------------- Main Display Class:
class ConfMatrixDisplaySet(DisplayNode):
    """Root container for all displays from one ConfMatrixModel object.
    All display elements can be saved as files within a selected directory tree.
    The complete instance can also be serialized and dumped to a single pickle file,
    then re-loaded, edited if needed, and re-saved.
    """
    def __init__(self, group_factors):
        """
        :param group_factors: dict with elements (gf, GroupFactorDisplaySet object)
        """
        self.group_factors = group_factors

    def save(self, dir_top, **kwargs):
        """Save all displays in a directory tree
        :param dir_top: Path or string with top directory for all displays
        :param kwargs: (optional) dict with any additional format parameters, e.g.,
            figure_format, table_format, and any Pandas file-writer parameters.
        :return: None
        """
        for (gf, gf_set) in self.group_factors.items():
            if len(gf) == 0 or all(s in whitespace for s in gf):
                gf_set.save(Path(dir_top), **kwargs)
                # only one unnamed group_factor -> no group-factor directory
            else:
                gf_set.save(Path(dir_top) / FMT['group_factor_sep'].join(gf), **kwargs)
                # separate dir for each group_factor

    @classmethod
    def show(cls, cmm, 
             sr_factors=None, mi_factors=None, pc_factors=None, 
             group_factors=None, **kwargs):
        """Create requested displays for results from an EMA study,
        and store all display elements in a single structured object.
        :param cmm: a cm_model.ConfMatrixModel instance, with
            cm[group] = a cm_model.ResponseProbGroup instance
        :param sr_factors: (optional) list with selected test-factor "dimensions",
            for Response-probability displays, given Stim,
            given as single key, or tuple of test-factor keys.
        :param pc_factors: (optional) list with selected test-factor "dimensions",
            for Prob.Correct displays.
        :param mi_factors: (optional) list with selected test-factor "dimensions",
            for Mutual-Information displays.
        :param group_factors: (optional) list with selected grouping factors (population "dimensions"),
                given as single key, or tuple of group-dimension keys.
        :param: kwargs: (optional) dict with any other display formatting parameters
            for ema_display.FMT and / or ema_display_format.FMT and / or matplotlib
        :return: a cls instance filled with display objects
        """
        cmf = cmm.cmf  # common ConfMatrixFrame instance
        set_format_param(sr_factors=_check_test_factors(sr_factors, cmf),
                         pc_factors=_check_test_factors(pc_factors, cmf),
                         mi_factors=_check_test_factors(mi_factors, cmf),
                         **kwargs)
        group_factors = _check_group_factors(group_factors, cmf)
        gfs = {gf: GroupFactorDisplaySet.show(cmm, gf)
               for gf in group_factors}
        # NOTE: can show individual participant and participant_group results
        #   only for gf including ALL group dimensions
        logger.info(fig_comments(FMT['percentiles']))
        logger.info(table_comments())
        return cls(gfs)


# ---------------------------------------------------------- Top Display Class:
class GroupFactorDisplaySet:
    """Container for all result displays related to groups
    in ONE requested tuple of group "dimension" categories.
    """
    def __init__(self, group_head, groups, group_effects=None):
        """
        :param group_head: tuple of group dimension(s) for this grouping factor
        :param groups: dict with (group_id, PopulationDisplaySet) elements
        :param group_effects: (optional) single GroupEffectSet instance,
            showing jointly credible differences between groups,
            IFF there is more than one group
        """
        self.group_head = group_head  # copy of container key for this display set
        self.groups = groups
        self.group_effects = group_effects

    def save(self, dir_top, **kwargs):
        """Save all displays in a directory tree
        :param dir_top: Path or string with top directory for all displays
        :param kwargs: (optional) dict with any additional format parameters, e.g.,
            figure_format, table_format, and any Pandas file-writer parameters.
        :return: None
        """
        for (g, g_display) in self.groups.items():
            # g = (g,)  # ***** Temp fix, future must be tuple
            g = group_dir_str(self.group_head, g, sep=FMT['group_join_str'])
            if len(g) == 0 or all(s in whitespace for s in g):
                g_display.save(dir_top, **kwargs)
            else:
                g_display.save(dir_top / g, **kwargs)
        if self.group_effects is not None:
            gf_dir = FMT['group_factor_sep'].join(self.group_head) + FMT['group_effects_dir']
            self.group_effects.save(dir_top / gf_dir, **kwargs)

    @classmethod
    def show(cls, cmm, gf):
        """Create all displays for this requested grouping factor
        :param cmm: a cm_model.ConfMatrixModel instance
        :param gf: group-label head ******* OR (future) tuple with group key(s) to be included in this display,
            with a weighted mixture distribution across other not-included group dimensions
        :return: a cls instance
        """
        cmf = cmm.cmf
        groups = dict(cmm.gen_population_models(gf))
        # can include cm_model.ResponseProbGroup instances, for gf including ALL group_factors.keys()
        # OR cm_model.PopulationModelMixture instances, for gf with fewer than all group_factors.keys()
        group_displays = {g_key: PopulationDisplaySet.display(cmm_g)
                          for (g_key, cmm_g) in groups.items()}
        if len(group_displays) > 1:
            group_effects = GroupEffectSet.display(gf, groups, cmf)
        else:
            group_effects = None
        return cls(gf, group_displays, group_effects)


# ---------------------------------------------------- Second-level Display Classes:
class PopulationDisplaySet(DisplayNode):
    """Container for all displays related to ONE Population category,
    along ONE group-factor "dimension".
    defined by a user-requested tuple of one or more group_factor keys.
    If the population is represented by a participant group,
    i.e., is specified in all group-factor dimensions,
    displays can also be generated for individual participants in the group,
    and for an overview of the group.
    """
    def __init__(self,
                 population_mean=None,
                 random_individual=None,
                 participants=None,
                 participant_group=None):
        """Container for all results related to ONE group of participants
        :param population_mean: a PopulationResult instance, showing
            estimated means for the population from which the participants were recruited
        :param random_individual:  a PopulationResult instance, showing
            estimated distributions for random individual from the population
        :param participants: dict with elements (subject_id: SubjectDisplay instance)
        :param participant_group: list with FigureBoxplot instances
        """
        self.population_mean = population_mean
        self.random_individual = random_individual
        self.participants = participants
        self.participant_group = participant_group

    def save(self, path, **kwargs):
        """Save all stored display objects in their corresponding subdirectories
        """
        if self.population_mean is not None:
            self.population_mean.save(path / FMT['population_mean_dir'], **kwargs)
        if self.random_individual is not None:
            self.random_individual.save(path / FMT['random_individual_dir'], **kwargs)
        if self.participant_group is not None:
            self.participant_group.save(path /  FMT['participant_group_dir'], **kwargs)
        if self.participants is not None:
            for (s, s_disp) in self.participants.items():
                s_disp.save(path / FMT['participants_dir'] / str(s), **kwargs)

    @classmethod
    def display(cls, g_model):  
        """Generate all displays for a given group
        :param g_model: a cm_model.ResponseProbGroup instance
        :return: a PopulationDisplaySet instance with all displays for this group
        """
        population_mean = None
        if FMT['population_mean']:
            try:
                population_mean = PopulationDisplay.display(g_model.population.mean())
            except NotImplementedError:  # cannot do .mean() distribution
                population_mean = None
        random_individual = None
        if FMT['random_individual']:
            random_individual = PopulationDisplay.display(g_model.population.random_ind())
        subjects = None
        if FMT['participants']:
            try:
                subjects = {s: SubjectDisplay.display(s_model)
                            for (s, s_model) in g_model.subjects.items()}
            except AttributeError as e:  # no subjects in mixture population model
                subjects = None
        participant_group = None
        if FMT['participant_group']:
            try:
                participant_group = ParticipantGroupDisplay.show(g_model)
            except AttributeError as e:  # no subjects in mixture population model
                participant_group = None
        return cls(random_individual=random_individual,
                   population_mean=population_mean,
                   participants=subjects,
                   participant_group=participant_group)


# ------------------------------------------ Third-level Display Classes:
class PopulationDisplay(DisplayNode):
    """Container for all predictive displays for ONE population,
    either population mean or unseen random individual drawn from the population.
    """
    def __init__(self, measures):
        """
        :param measures: a nested dict with (measure_label, measure_dict), where
            measure_label is PC, MI, or SR,
            measure_dict has elements (tf_keys, display), where
                tf_keys is a tuple of one or more test-factor "dimensions",
                display is a MeasureDisplay or CellDisplay instance.
        """
        # *** ensure no empty sub-dict ? ********
        self.measures = measures

    def save(self, dir, **kwargs):
        """Save all stored display objects in specified directory
        :param dir: Path instance for save directory
        :return: None
        """
        for (m, m_dict) in self.measures.items():
            dir_m = dir / m
            for (tfk, m_display) in m_dict.items():
                if m_display is not None:
                    m_display.save(dir_m, **kwargs)

    @classmethod
    def display(cls, population):  # **** get cmf as arg ? ********
        """Create all displays for ONE population
        :param population: a cm_model population model object
            allowing sampling by a method rvs_df.
         :return: a cls instance
        """
        def _show_mi(u_df, cmf):
            """Calculate MI displays for ONE user-selected test-factor dimension
            :param u_df: pd.DataFrame with sample response probabilities
                with index levels (_sample, test-factor keys),
                columns = cmf.stim_resp_index
            :param cmf: current ConfMatrixFrame instance
            :return: a MeasureDisplay instance
            """
            # m_ds_old = mutual_information_old(u_df, cmf)
            m_ds = mutual_information(u_df, cmf)
            return {tfk: MeasureDisplay.display(MI_KEY, tfk, m_ds)
                    for tfk in FMT['mi_factors']}

        def _show_pc(u_df, cmf):
            """Calculate PC displays for ONE test-factor dimension
            :param u_df: pd.DataFrame with sample response probabilities
            with index (test-factor keys, S); columns = response categories
            :param cmf: current ConfMatrixFrame instance
            :return: a MeasureDisplay instance
            """
            m_ds = prob_correct(u_df, cmf)
            return {tfk: MeasureDisplay.display(PC_KEY, tfk, m_ds)
                    for tfk in FMT['pc_factors']}

        def _show_sr(u_df, cmf):
            """Calculate MI displays for ONE test-factor dimension
            :param u_df: pd.DataFrame with sample response probabilities
            with index (test-factor keys, S); columns = response categories
            :param cmf: current ConfMatrixFrame instance
            :return: a MeasureDisplay instance
            """
            return {tfk: CellDisplay.display(cmf, tfk,
                                             u_df.stack(level=0, future_stack=True))  # ************ ?
                    for tfk in FMT['sr_factors']}

        # -------------------------------------------------------------
        cmf = population.cmf
        u_df = population.rvs_df(n_samples=FMT['n_samples'])
        m_dict = {SR_KEY: _show_sr(u_df, cmf), # **** cmf needed? *****
                  PC_KEY: _show_pc(u_df, cmf),
                  MI_KEY: _show_mi(u_df, cmf),
                  }
        return cls(m_dict)


class ParticipantGroupDisplay(PopulationDisplay):
    """Container for overview display of participant results
    for ONE group of participants,
    including boxplots of median MI and PC,
    for all categories in user-requested test-factor "dimension(s)".
    """
    @classmethod
    def show(cls, group):  # **** get cmf as arg ? ****************** TEST empty tf request
        """Create all displays for ONE group of participants
        :param group: a cm_model.GroupResponseModel instance
            with individual results in group.subjects
            each allowing sampling by method rvs_df.
         :return: a cls instance
        """
        def measure_md(u_df, m_tf_fcn):
            """
            :param u_df: a pd.DataFrame with resp-prob samples
                    in ALL test conditions for ONE participant
            :param m_tf_fcn: a dict with elements (measure_label, tf_fcn) as defined by caller.
            :return: a nested dict with elements (measure_label, measure_tf), where
                measure_tf is a dict with elements (tfk, m_series), where
                m_series is a pd.Series with medians values for the (m, tfk)
            """
            def median(m_ds, tfk):
                """Calc median of given results, for requested test-factors
                :param m_ds: a pd.Series instance with scalar measure results
                    with index MultiIndex levels= (samples, *test_factors)
                :param tfk: a tuple with zero, one, or more test_factor keys
                :return: a scalar float result
                """
                if len(tfk) == 0:
                    return pd.Series(m_ds.median(),
                                     # index = [FMT['anonymous']],
                                     index = pd.CategoricalIndex([FMT['anonymous']],
                                                                 name=FMT['anonymous'])
                                     )
                                     # name=FMT['anonymous'])
                else:
                    return m_ds.groupby(level=tfk,
                                        sort=False,
                                        group_keys=True).median()
            # ---------------------------------------------
            m_dict = dict()
            for (m, (m_tf, m_fcn)) in m_tf_fcn.items():
                m_samples = m_fcn(u_df, cmf)
                m_dict[m] = {tfk: median(m_samples, tfk)
                             for tfk in m_tf}
            return m_dict
        # -------------------------------------------------------------
        cmf = group.cmf  # **** get as arg ?
        m_tf_fcn = {MI_KEY: (FMT['mi_factors'], mutual_information),
                    PC_KEY: (FMT['pc_factors'], prob_correct)}
        s_meas = {s: measure_md(s_model.rvs_df(FMT['n_samples']),
                                m_tf_fcn)
                  for (s, s_model) in group.subjects.items()}
        m_dict = {m: {tfk: pd.concat({s: smi[m][tfk]
                                      for (s, smi) in s_meas.items()})
                      for tfk in m_tf_fcn[m][0]}
                  for m in m_tf_fcn.keys()}
        for (m, d_m) in m_dict.items():
            if () in d_m.keys():  # in case empty test-factor key: use fake 'anonymous' name instead
                d_m[(FMT['anonymous'],)] = d_m.pop(())
        m_disp = {m: {tfk: MeasureDisplay(plt=fmt.fig_boxplot(m_tf_ds, x_cases=tfk,
                                                              y_label=FMT['plot_labels'][m],
                                                              file_label=FMT['file_labels'][m]))
                      # *** future include tab= ?
                      for (tfk, m_tf_ds) in m_tf.items()}
                  for (m, m_tf) in m_dict.items()}
        return cls(m_disp)


SubjectDisplay = PopulationDisplay


# --------------------------------- Classes showing differences between populations
class GroupEffectSet(DisplayNode):
    """Container for all displays illustrating differences between populations
    along one or several group "dimensions", requested by user.
    Population distributions are calculated for
    the population mean AND/OR for random individuals in the population.
    """
    def __init__(self, population_mean=None, random_individual=None):
        """Container for all results related to ONE group of participants
        :param population_mean: a PopulationResult instance, showing
            estimated means for the population from which the participants were recruited
        :param random_individual:  a PopulationResult instance, showing
            estimated distributions for random individual from the population
        """
        self.population_mean = population_mean
        self.random_individual = random_individual

    def save(self, path, **kwargs):
        """Save all stored display objects in their corresponding subdirectories
        """
        if self.population_mean is not None:
            self.population_mean.save(path / FMT['population_mean_dir'], **kwargs)
        if self.random_individual is not None:
            self.random_individual.save(path / FMT['random_individual_dir'], **kwargs)

    @classmethod
    def display(cls, gf, groups, cmf):
        """Generate all displays of group differences within ONE group_factor case
        :param gf: tuple with population (group_head) "dimensions" included in this effect set
        :param groups: dict with elements (g, g_model), where
            g is a tuple with one category for each element in gf,
            g_model is the corresponding cm_model.PopulationModel instance,
                which may be merged across other non-included group "dimensions"
        :param cmf: common ConfMatrixFrame instance
        :return: cls instance with all displays of differences
            between predictive population distributions for
            population random individual, AND/OR
            population mean
        """
        pop_ind = None
        pop_mean = None
        if FMT['random_individual']:
            pop_ind = PopulationDiff.show(gf,
                                          {g: g_model.population.random_ind()
                                         for (g, g_model) in groups.items()},
                                          cmf)
        if FMT['population_mean']:
            try:
                pop_mean = PopulationDiff.show(gf,
                                               {g: g_model.population.mean()
                                              for (g, g_model) in groups.items()},
                                               cmf)
            except NotImplementedError:  # cannot do .mean() distr
                pop_mean = None
        return cls(population_mean=pop_mean,
                   random_individual=pop_ind)


class PopulationDiff(DisplayNode):
    """Container for differences between populations,
    along one or several group "dimensions", requested by user,
    as represented by ONE type of population distribution,
    EITHER the population mean OR an unseen random individual in the population.
    """
    def __init__(self, measures):
        """
        :param measures: a nested dict with elements (measure_label, m_displays),
            where m_displays is a dict with elements (tf_keys, display object), where
            display-object is a MeasureDisplay or CellDiff instance
        """
        self.measures = measures

    def save(self, dir, **kwargs):
        """Save all stored display objects in specified directory
        :param dir: Path instance for save directory
        :return: None
        """
        for (m, m_dict) in self.measures.items():
            dir_m = dir / m
            for (tfk, m_display) in m_dict.items():
                m_display.save(dir_m, **kwargs)

    @classmethod
    def show(cls, gf, groups, cmf):
        """Calc MeasureDisplay for all requested test-factors incl groups,
        and credible differences between group categories along ONE group-effect "dimension".
        :param gf: tuple with population (group_head) "dimensions" to be included in this display set
        :param groups: dict with elements (g_id, g_model), where
            g_id is a tuple of group-id categories, one for each element in gf,
            g_model is a cm_model population model instance,
                allowing sampling by method rvs_df
        :param cmf: common ConfMatrixFrame instance
        :return: single cls instance
        """
        def show_measure(m_key, g_ds, tf_list):
            """Calculate all displays of a SCALAR measure for all test-factor categories
            :param m_key: string label for this scalar measure
            :param g_ds: dict with pd.Series instances,
                each containing samples of the measure,
                indexed by test-factor categories
            :param tf_list: list of tuples (tf0, ...) of test factors "dimensions"
            :return: a dict with (tfk, MeasureDiffSet instance), where
                tfk is element of tf_list
            """
            m_ds = pd.concat(g_ds, axis=0, names=list(gf))
            m_disp = {tfk: MeasureDiffSet.show_quantiles(m_key,
                                                         tfk + gf, m_ds)
                      for tfk in tf_list}
            for (tfk, m_d) in m_disp.items():
                m_d.diff = MeasureDiffSet.show_popdiff(m_key,
                                                       tfk, g_ds, gf)
            return m_disp

        def show_mi(u_groups):
            """Calculate MI displays for ONE test-factor dimension
            :param u_groups: dict with pd.DataFrame instances
                with index (test-factor keys, S); columns = response categories
            :return: a dict with (tfk, MeasureDisplay instance), where
                tfk is element of tf_list
            """
            g_ds = {g: mutual_information(u_g, cmf)
                    for (g, u_g) in u_groups.items()}
            return show_measure(MI_KEY, g_ds, FMT['mi_factors'])

        def show_pc(u_groups):
            """Calculate PC displays for ONE test-factor dimension
            :param u_groups: dict with pd.DataFrame instances
            :return: a dict with (tfk, MeasureDiffSet instance), where
                tfk is element of tf_list
            """
            g_ds = {g: prob_correct(u_g, cmf)
                    for (g, u_g) in u_groups.items()}
            return show_measure(PC_KEY, g_ds, FMT['pc_factors'])

        def show_sr(u_groups):
            """Calculate MI displays for ONE test-factor dimension
            :param u_groups: pd.DataFrame with sample response probabilities
            with index (test-factor keys, S); columns = response categories
             :return: a dict with (tfk, CellDiffSet instance), where
                tfk is element of tf_list
           """
            return {tfk: CellDiffSet.show_popdiff(tfk,
                                                     u_groups, gf, cmf)
                    for tfk in FMT['sr_factors']}
        # ---------------------------------------------------------------

        u_groups = {g: g_model.rvs_df(n_samples=FMT['n_samples'])
                for (g, g_model) in groups.items()}
        m_dict = {SR_KEY: show_sr(u_groups),
                  PC_KEY: show_pc(u_groups),
                  MI_KEY: show_mi(u_groups),
                  }
        return cls(m_dict)


class MeasureDiffSet(MeasureDisplay):
    """Container for displays of ONE SCALAR performance measure across Populations
    and differences between populations for requested Test Factors.
    """
    # def show_quantiles inherited from superclass
    @staticmethod
    def show_popdiff(m_key, tf_keys, m_group_ds, gf):
        """Create all displays for ONE scalar performance measure
        :param m_key: string name of this measure
        :param tf_keys: tuple (tf_0,...), where
            each element tf_i is a test-factor key, as requested by user
        :param gf: tuple (gf_0,...), where
            each element gf_i is a group factor = population "dimension"
        :param m_group_ds: dict with (g_cat, pd.Series instance),
            each with samples for this measure,
            with MultiIndex defining test conditions for each test factor
        :return: DiffTable with jointly credible differences between group categories,
            among pairs of test-factor categories.
        """
        d = cred_pd.cred_group_diff(m_group_ds, group_axis=gf, case_axis=tf_keys,
                                    p_lim=FMT['credibility_limit'])
        return fmt.tab_credible_diff(d, diff_head=gf, case_head=tf_keys,
                                     cred_head=FMT['credibility'],
                                     y_label=FMT['file_labels'][m_key],
                                     file_label=FMT['file_labels'][m_key])


class CellDiffSet(CellDisplay):
    """Container for displays of response probabilities across Populations
    and differences between populations for requested Test Factors.
    """
    @classmethod
    def show_popdiff(cls, tf_keys, m_group_u, gf, cmf):
        """Create all displays for one performance measure
        :param tf_keys: tuple (tf_0,...), where
            each element tf_i is a test-factor key, as requested by user
        :param m_group_u: dict with (g_cat, pd.DataFrame instance),
            each with samples of response probabilities, given stim,
            with MultiIndex defining test conditions for each test factor
        :param cmf: common ConfMatrixFrame object
        :return: single cls instance with all displays
        """
        case_axis = tf_keys + tuple(cmf.stim_resp_index.names)
        case_head = case_axis
        d = cred_pd.cred_group_diff(m_group_u, group_axis=gf, case_axis=case_axis,
                                 p_lim=FMT['credibility_limit'],
                                 threshold=FMT['diff_threshold'])
        diff_df = fmt.tab_credible_diff(d, diff_head=gf, case_head=case_head,
                                        cred_head=FMT['credibility'],
                                        y_label=FMT['plot_labels'][SR_KEY],  #'R ' + FMT['probability'],
                                        file_label=FMT['file_labels'][SR_KEY]
                                        )
        return cls(tab=diff_df)


# ---------------------------------- Module-internal Result-calculation Functions:


def prob_correct(u_df, cmf):
    """Calculate probability of correct responses,
    for given set of sample response-probabilities
    :param u_df: pd.DataFrame with
        index = MultiIndex (sample, *test_factors),
        columns = cmf.stim_resp_index = predefined MultiIndex (stim_group, stim, resp)
    :param cmf: a cm_data.ConfMatrixFrame instance
    :return: pd.Series with MultiIndex(sample *test_factors)
        with correct-probability results (in percent)
    """
    pc = u_df[[(g, s, s) for (g, s, r) in u_df.columns.values if r == s]].to_numpy()
    # pc[..., i(g, s)] = CONDITIONAL prob correct response, given stim = (g, s), for ...-th (sample, test-cond)
    pc = 100.* np.dot(pc, cmf.stim_prob())
    return pd.Series(pc, index=u_df.index)


def mutual_information(u_df, cmf):
    """Calculate mutual information,
    for given set of sample response-probabilities
    :param u_df: pd.DataFrame with
        index = MultiIndex (sample, *test_factors),
        columns = cmf.stim_resp_index = predefined MultiIndex (stim_group, stim, resp)
    :param cmf: a cm_data.ConfMatrixFrame instance
    :return: mi = pd.Series with MultiIndex(sample, *test_factors)
        of mutual-information results (in bits / test item)
    """
    def group_mi(u_sg, p_g):
        """MI contribution from ONE stim-group of stimuli and corresponding subset of responses
        :param u_sg: pd.DataFrame with conditional response-prob arrays, given each stim,
            with index = MultiIndex with levels (sample, test-factors),
            columns = MultiIndex with two levels (stim, resp)
        :param p_g: pd.Series with stim probs for the current stim group
            p_g.loc[s] = common stim prob for all response-probs in u_sg[s]
         :return: mi = array with mi contribution from current stim group, with
            mi[...] = sum_j p(R=r_j) * log2 p(R = r_j | S=s_i) / p(R=r_j)
            for (s_i, r_j) within current stim group
        Method: doc report sec:MutualInformation
        """
        u_sr = np.array([u_sg[s] for s in p_g.index.values])
        # u_sr[i, ..., j] = p(R=r_j | S=s_i) for ...-th (sample, condition)
        # sum_j u_sr[..., j] == 1 for all ...
        psr = np.array([u_i * p_i  # p_i is scalar
                        for (u_i, p_i) in zip(u_sr, p_g)])
        # psr[i, ..., j] = p(S=s_i AND R=r_j) for ...-th sample / condition
        # *** some might be exactly zero, in case stim-prob is zero
        # sum_i,j psr[i, ..., j] == sum_i p_s[i]
        pr = np.sum(psr, axis=0, keepdims=True)
        pr_sum = np.sum(pr, axis=-1, keepdims=True)
        # *** might be exactly zero, in case some p_stim exactly zero
        pr_sum = np.maximum(pr_sum, np.finfo(0.).tiny)
        pr /= pr_sum
        # pr[0, ..., j] = p(R=r_j) for ...-th sample / condition, given stim within current group
        # sum_j pr[..., j] == 1 for all ...
        u_sr = np.maximum(u_sr, np.finfo(0.).tiny)
        # avoid 0 * log(0) in case any u_sr == 0.
        pr = np.maximum(pr, np.finfo(0.).tiny)
        # avoid nan in case any pr == 0.
        return np.sum(psr * np.log2(u_sr / pr), axis=(0, -1))
    # --------------------------------------------
    p_stim = cmf.stim_prob()
    mi = np.sum([group_mi(u_df[g], p_stim.loc[g])
                 for g in cmf.stim_groups.keys()],
                axis=0)
    return pd.Series(mi, index=u_df.index)


# ---------------------------------- Module-internal Help Functions:

def _check_test_factors(tf, cmf):
    """Check input argument to ConfMatrixDisplaySet.show(...)
    :param tf: (optional) list with selected test-factor dimensions to be displayed.
        tf[i] = a selected key in cmf.test_factors, or a tuple of such keys.
    :param cmf: current EmaFrame instance
    :return: list with usable values
    """
    if tf is None:
        tf = [*cmf.test_factors.keys()]
        # default show only MAIN effects for each situation dimension
    tf = [(sit,) if isinstance(sit, str) else tuple(sit)  # *** in case it was a list
          for sit in tf]  # ensure tuple elements
    # *** check, that requested factors exist in model, correcting if needed:
    tf_checked = []
    for s in tf:
        try:
            s_new = tuple(cmf.match_test_factor(s_i) for s_i in s)
            if s_new != s:
                logger.warning(f'*** Test factor {s} corrected to {s_new}')
            tf_checked.append(s_new)
        except UserInputError as e:
            logger.warning(f'*** Requested situation factor in {s} unknown, removed. '
                           + str(e))
    return tf_checked


def _check_group_factors(group_factors, cmf):
    """Check input argument group_factors
    :param group_factors: (optional) list with selected grouping factors (population "dimensions"),
            given as single key, or tuple of grouping factors.
    :param cmf: current EmaFrame instance
    :return: list with usable values
        May include empty tuple, meaning ALL group dimensions will be merged into one group
    """
    if group_factors is None:
        group_factors = [tuple(cmf.group_factors.keys())]
        # = single "dimension" including all group factors
    group_factors = [(gf,) if isinstance(gf, str) else tuple(gf)
                     for gf in group_factors]  # ensure tuple elements
    # *** check that requested grouping dimensions exist in model:
    gf_checked = []
    for gf in group_factors:
        try:
            gf_new = tuple(cmf.match_group_factor(gf_i)
                           for gf_i in gf)
            if gf_new != gf:
                logger.warning(f'*** Requested group_factor {gf} corrected to {gf_new}')
            gf_checked.append(gf_new)
        except UserInputError as e:
            logger.warning(f'*** Requested key in {gf} unknown, removed. '
                           + str(e))
    return gf_checked


# --------------------------- info text for logger output:
def fig_comments(perc):
    """Generate figure explanations.
    :param perc: vector with percentile values
    :return: long comment string
    """
    p_min = np.amin(perc)
    p_max = np.amax(perc)
    c = f"""Figure Explanations:
    
Figure files with names like XX_tf1xtf2.pdf 
display percentiles and credible intervals (vertical bars) 
for performance measure XX vs. test factors (tf1, tf2), 
estimated for the population from which test individual were recruited.
Vertical bars show the range between {p_min:.1f}- and {p_max:.1f}- percentiles.
Markers show other user-defined percentiles.

The credible ranges include all uncertainty
caused both by real inter-individual perceptual differences
and by the limited number of phoneme presentations for each listener.
"""
    if FMT['participant_group']:
        c += """
Files in 'participant_group' with names like XX_tf1xtf2.pdf 
show boxplots of point-estimated (median) individual results
for performance measure XX vs. test factors (tf1, tf2).
"""
    return c


def table_comments():
    c = """Table Explanations:

*** Tables of Percentiles:
Files with names like XX_tf1xtf2.txt  
show numerical versions of the information in percentile plots,
for test factors (tf1, tf2).

*** Tables of Credible Differences in scalar performance measure:
Files with names like XX_tf1-diff_tf2xtf3.txt
show a list of test-condition pairs, where
the XX measures are ALL JOINTLY credibly different between categories in tf1,
for given categories in (tf2, tf3).
The credibility value in each row is the JOINT probability
for the pairs in the same row and all rows above it.

*** Tables of Credibly Different Response Probabilities:
Files with names like SR_tf1-diff_tf2xSxR.txt 
show a list of test-condition pairs, where
the response probabilities are ALL JOINTLY credibly different between categories in tf1,
for a given category in tf2 and a given stimulus-response pair (stim, resp).

The credibility value in each row is the JOINT probability
for the pairs in the same row AND all rows above it.
"""
    return c