"""Behavioural diagnostic of a digital savings behaviour: funnel and barrier prioritisation.

Worked example on synthetic data, by Ahmed Beddi. No real provider, programme or person is described.

Target behaviour: adults who already hold an active mobile-money wallet make at least one
deposit into its savings feature each calendar month.

The script
  1. generates a seeded synthetic event log (mulberry32, seed 20261103, 20,000 wallet holders),
  2. computes the journey funnel aware -> intend -> initiate -> complete -> repeat, overall and by segment,
  3. scores and ranks 14 candidate barriers with  score = P^wP * S^wS * T^wT  (defaults wP = wS = wT = 1),
  4. groups the top barriers by COM-B component and by the intervention package they point to,
  5. writes the barrier register (EN and FR CSV).

Mirrors components/demos/lib/behaviouralDiagnostic.ts (the in-browser demo): same PRNG, same draw
order, same probabilities, same scores, same tie-breaks, same defaults.

Run:  python behavioural_diagnostic.py
"""
# %%
import math

import pandas as pd

SEED, N_USERS = 20261103, 20_000
STEPS = ["aware", "intend", "initiate", "complete", "repeat"]
M32 = 0xFFFFFFFF


def _imul(a, b):
    r = (a * b) & M32
    return r - (1 << 32) if r & 0x80000000 else r


def _i32(x):
    x &= M32
    return x - (1 << 32) if x & 0x80000000 else x


def mulberry32(a):
    state = [_i32(a)]

    def rand():
        a = _i32(state[0] + 0x6D2B79F5)
        state[0] = a
        t = _imul(a ^ ((a & M32) >> 15), 1 | a)
        t = _i32(_i32(t + _imul(t ^ ((t & M32) >> 7), 61 | t)) ^ t)
        return ((t ^ ((t & M32) >> 14)) & M32) / 4294967296

    return rand


# %%
# Step-conversion probabilities of the synthetic population (assumptions, not estimates).
# Each user consumes exactly 7 draws, in this order: sex, area, aware, intend, initiate, complete, repeat.
P_FEMALE, P_RURAL = 0.48, 0.42


def step_probs(female, rural):
    return {
        "aware": 0.78 - 0.10 * rural,          # knows the savings feature exists (survey module)
        "intend": 0.62 - 0.04 * female,        # plans to deposit this month, given aware (survey module)
        "initiate": 0.55 - 0.08 * rural,       # opens the savings deposit flow, given intend (session log)
        "complete": 0.70 - 0.10 * rural,       # deposit confirmed in month 1, given initiate (transaction log)
        "repeat": 0.48,                        # deposits again in month 2, given complete (transaction log)
    }


def simulate(seed=SEED, n=N_USERS):
    """Return users (one row each) and the event log (one row per event)."""
    u = mulberry32(seed)
    users, events = [], []
    for i in range(n):
        female = 1 if u() < P_FEMALE else 0
        rural = 1 if u() < P_RURAL else 0
        p = step_probs(female, rural)
        draws = [u() for _ in STEPS]          # always 5 draws, whatever the path
        reached, alive = {}, True
        for s, d in zip(STEPS, draws):
            alive = alive and d < p[s]
            reached[s] = int(alive)
        uid = f"U{i + 1:05d}"
        users.append({"user_id": uid, "sex": "F" if female else "M", "area": "rural" if rural else "urban", **reached})
        source = {"aware": "survey", "intend": "survey", "initiate": "session_log", "complete": "transaction_log", "repeat": "transaction_log"}
        month = {"aware": 1, "intend": 1, "initiate": 1, "complete": 1, "repeat": 2}
        for s in STEPS:
            if reached[s]:
                events.append({"user_id": uid, "event": s, "month": month[s], "source": source[s]})
    return pd.DataFrame(users), pd.DataFrame(events)


def funnel(users, events, mask=None):
    ids = users["user_id"] if mask is None else users.loc[mask, "user_id"]
    ev = events[events["user_id"].isin(set(ids))]
    base = len(ids)
    counts = [base] + [ev.loc[ev["event"] == s, "user_id"].nunique() for s in STEPS]
    rows = []
    for k, s in enumerate(STEPS):
        prev, cur = counts[k], counts[k + 1]
        rows.append({"step": s, "users": cur, "pct_of_base": 100 * cur / base,
                     "conversion_from_previous": 100 * cur / prev if prev else float("nan"),
                     "drop_off": prev - cur})
    return base, pd.DataFrame(rows)


# %%
# Barrier register. P = prevalence, S = severity, T = tractability, each 1-5 (rubric in scoring-rubric.md).
COMB = {
    "C-Ps": ("Capability, psychological", "Capacité psychologique"),
    "C-Ph": ("Capability, physical", "Capacité physique"),
    "O-Ph": ("Opportunity, physical", "Opportunité physique"),
    "O-So": ("Opportunity, social", "Opportunité sociale"),
    "M-Re": ("Motivation, reflective", "Motivation réfléchie"),
    "M-Au": ("Motivation, automatic", "Motivation automatique"),
}
STEP_LABEL = {
    "aware": ("Aware", "Connaître"), "intend": ("Intend", "Avoir l’intention"), "initiate": ("Initiate", "Amorcer"),
    "complete": ("Complete", "Finaliser"), "repeat": ("Repeat", "Recommencer"),
}
SOURCE_LABEL = {
    "admin": ("administrative data", "données administratives"), "survey": ("short survey", "court sondage"),
    "testing": ("user testing", "tests utilisateurs"), "interviews": ("interviews", "entretiens"),
}
PACKAGES = {
    "P1": ("Simplify the savings path", "Simplifier le parcours d’épargne"),
    "P2": ("Prompt when money arrives", "Rappeler quand l’argent arrive"),
    "P3": ("Opt-in automatic set-aside", "Mise de côté automatique, sur adhésion"),
    "P4": ("Explain and reassure at the point of use", "Informer et rassurer au moment de l’usage"),
    "P5": ("Show that others save", "Montrer que d’autres épargnent"),
    "S": ("Structural: outside a behavioural package", "Structurel : hors d’un dispositif comportemental"),
}

BARRIERS = [
    # id, step, COM-B, sources, P, S, T, package, EN label, FR label, EN test, FR test
    ("B01", "aware", "C-Ps", ["admin", "survey"], 3, 5, 5, "P4",
     "Does not know the wallet has a savings feature, or what it does",
     "Ignore que le portefeuille offre une fonction d’épargne, ou à quoi elle sert",
     "Share of holders who never opened the savings menu in 12 months (logs); unprompted-recall item in the survey",
     "Part des titulaires n’ayant jamais ouvert le menu épargne en 12 mois (journaux) ; question de rappel spontané dans le sondage"),
    ("B02", "intend", "M-Re", ["survey", "interviews"], 3, 3, 4, "P4",
     "Sees saving as something for larger amounts, not for people like them",
     "Pense que l’épargne est réservée aux gros montants, pas aux gens comme soi",
     "Agreement items on the smallest amount worth saving; probing in interviews",
     "Énoncés d’accord sur le plus petit montant « qui vaut la peine » ; relances en entretien"),
    ("B03", "intend", "M-Re", ["interviews", "survey"], 4, 4, 3, "P4",
     "Fears that money in the savings pocket cannot be taken out quickly in an emergency",
     "Craint de ne pas pouvoir retirer vite l’argent épargné en cas d’urgence",
     "Stated belief compared with the actual withdrawal rule; accounts of past experiences in interviews",
     "Croyance déclarée comparée à la règle de retrait réelle ; récits d’expériences passées en entretien"),
    ("B04", "initiate", "M-Au", ["admin", "survey"], 5, 3, 4, "P3",
     "Intends to save but spends first when money arrives",
     "Compte épargner mais dépense d’abord quand l’argent arrive",
     "Time from an inflow to the first payment vs. to a savings deposit (logs); stated plan vs. logged behaviour",
     "Délai entre une entrée d’argent et le premier paiement, comparé au délai jusqu’au dépôt (journaux) ; intention déclarée comparée au comportement observé"),
    ("B05", "initiate", "C-Ps", ["admin", "interviews"], 4, 3, 5, "P2",
     "Forgets: nothing prompts a deposit when money is in the wallet",
     "Oublie : rien ne rappelle le dépôt au moment où l’argent est sur le portefeuille",
     "Share of months with an inflow above the usual deposit but no savings deposit (logs)",
     "Part des mois avec une entrée d’argent supérieure au dépôt habituel mais sans dépôt d’épargne (journaux)"),
    ("B06", "initiate", "O-Ph", ["admin", "survey"], 4, 4, 2, "S",
     "No balance in the wallet; cashing in means a trip to an agent",
     "Pas de solde sur le portefeuille ; l’alimenter exige un déplacement chez un agent",
     "Share of months with no inflow (logs); distance to the nearest active agent (agent register)",
     "Part des mois sans entrée d’argent (journaux) ; distance à l’agent actif le plus proche (registre des agents)"),
    ("B07", "complete", "O-Ph", ["testing", "admin"], 3, 4, 5, "P1",
     "The savings path is buried: seven USSD screens and a session that times out",
     "Le parcours d’épargne est enfoui : sept écrans USSD et une session qui expire",
     "Timed think-aloud tests; abandonment by screen in session logs",
     "Tests chronométrés avec verbalisation ; abandons par écran dans les journaux de session"),
    ("B08", "complete", "M-Re", ["testing", "survey"], 2, 4, 3, "P1",
     "Is not sure whether moving money to savings costs a fee",
     "Ne sait pas si le transfert vers l’épargne est payant",
     "Fee belief compared with the actual tariff; hesitation observed at the confirmation screen",
     "Croyance sur les frais comparée au barème réel ; hésitation observée à l’écran de confirmation"),
    ("B09", "complete", "C-Ps", ["testing", "survey"], 2, 4, 4, "P1",
     "Has difficulty reading menu text or checking amounts on screen",
     "Lit difficilement les menus ou vérifie mal les montants à l’écran",
     "Task success in user tests by literacy level; short reading item in the survey",
     "Réussite des tâches en test selon le niveau de lecture ; court exercice de lecture dans le sondage"),
    ("B10", "repeat", "O-So", ["interviews", "survey"], 2, 3, 2, "S",
     "Visible savings attract requests from relatives",
     "Une épargne visible attire les demandes des proches",
     "Accounts in interviews; list-experiment item to limit social-desirability bias",
     "Récits en entretien ; question en liste (list experiment) pour limiter le biais de désirabilité sociale"),
    ("B11", "repeat", "O-Ph", ["admin", "survey"], 4, 4, 1, "S",
     "Income is too irregular or too low to set money aside every month",
     "Revenu trop irrégulier ou trop faible pour mettre de côté chaque mois",
     "Coefficient of variation of monthly inflows (logs); income module of the survey",
     "Coefficient de variation des entrées mensuelles (journaux) ; module revenu du sondage"),
    ("B12", "repeat", "M-Au", ["testing", "admin"], 3, 2, 5, "P2",
     "No feedback: the savings balance and progress are not shown after a deposit",
     "Aucune rétroaction : ni le solde épargné ni la progression ne s’affichent après un dépôt",
     "Review of confirmation messages; repeat rate after a first deposit (logs)",
     "Examen des messages de confirmation ; taux de récidive après un premier dépôt (journaux)"),
    ("B13", "intend", "O-So", ["survey", "interviews"], 3, 2, 4, "P5",
     "Does not see peers using the savings feature",
     "Ne voit pas ses pairs utiliser la fonction d’épargne",
     "Perceived share of peers who save in the wallet vs. actual share (logs)",
     "Part perçue des pairs qui épargnent sur le portefeuille comparée à la part réelle (journaux)"),
    ("B14", "initiate", "O-Ph", ["admin"], 1, 5, 3, "P1",
     "The account tier (identity-verification level) does not include the savings feature",
     "Le niveau de compte (vérification d’identité) ne donne pas accès à l’épargne",
     "Share of active holders on the entry tier (identity-verification register)",
     "Part des titulaires actifs au niveau d’entrée (registre de vérification d’identité)"),
]
DEFAULT_WEIGHTS = {"P": 1.0, "S": 1.0, "T": 1.0}
DEFAULT_TOP_K = 5


def score(p, s, t, w=DEFAULT_WEIGHTS):
    return (p ** w["P"]) * (s ** w["S"]) * (t ** w["T"])


def rank_barriers(w=DEFAULT_WEIGHTS, overrides=None):
    """Score descending; ties broken by severity, then prevalence (both descending), then id."""
    rows = []
    for b in BARRIERS:
        bid, step, comb, src, p, s, t, pkg = b[:8]
        if overrides and bid in overrides:
            p, s, t = overrides[bid]
        rows.append({"id": bid, "step": step, "com_b": comb, "P": p, "S": s, "T": t, "package": pkg,
                     "score": score(p, s, t, w), "barrier": b[8]})
    rows.sort(key=lambda r: (-round(r["score"], 9), -r["S"], -r["P"], r["id"]))
    for i, r in enumerate(rows):
        r["rank"] = i + 1
    return pd.DataFrame(rows)


def top_k_summary(ranked, k=DEFAULT_TOP_K):
    top = ranked.head(k)
    by_comb = top.groupby("com_b")["score"].agg(["count", "sum"]).sort_values("sum", ascending=False)
    by_pkg = top.groupby("package")["score"].agg(["count", "sum"])
    by_pkg["share_%"] = 100 * by_pkg["sum"] / by_pkg["sum"].sum()
    return by_comb, by_pkg.sort_values(["sum", "count"], ascending=False)


def barrier_register(lang="en"):
    j = 0 if lang == "en" else 1
    ranked = rank_barriers().set_index("id")
    cols_en = ["id", "barrier", "journey_step", "com_b_component", "evidence_sources", "how_to_test",
               "prevalence_1_5", "severity_1_5", "tractability_1_5", "score_PxSxT", "rank", "intervention_package"]
    cols_fr = ["id", "frein", "etape_du_parcours", "composante_com_b", "sources_de_donnees", "comment_le_verifier",
               "prevalence_1_5", "gravite_1_5", "modifiabilite_1_5", "score_PxGxM", "rang", "dispositif_d_intervention"]
    rows = []
    for b in BARRIERS:
        bid, step, comb, src, p, s, t, pkg = b[:8]
        rows.append([bid, b[8 + j], STEP_LABEL[step][j], f"{comb} ({COMB[comb][j]})",
                     "; ".join(SOURCE_LABEL[x][j] for x in src), b[10 + j], p, s, t,
                     int(ranked.loc[bid, "score"]), int(ranked.loc[bid, "rank"]), f"{pkg}: {PACKAGES[pkg][j]}"])
    df = pd.DataFrame(rows, columns=cols_en if lang == "en" else cols_fr)
    return df.sort_values(df.columns[10]).reset_index(drop=True)


# %%
def survey_n(p=0.5, moe=0.03, z=1.959963984540054):
    """Simple-random-sample size to estimate a prevalence within +/- moe at 95 % confidence."""
    return math.ceil(z * z * p * (1 - p) / (moe * moe))


# %%
if __name__ == "__main__":
    users, events = simulate()
    base, f = funnel(users, events)
    print(f"Eligible wallet holders: {base:,}")
    print(f.round(1).to_string(index=False))
    for name, mask in [("women", users.sex == "F"), ("men", users.sex == "M"),
                       ("rural", users.area == "rural"), ("urban", users.area == "urban")]:
        b, g = funnel(users, events, mask)
        print(f"{name:6s} n={b:,}  " + "  ".join(f"{r.step}={r.pct_of_base:.1f}%" for r in g.itertuples()))
    ranked = rank_barriers()
    print(ranked[["rank", "id", "com_b", "P", "S", "T", "score", "package"]].to_string(index=False))
    by_comb, by_pkg = top_k_summary(ranked)
    print(by_comb, by_pkg.round(1), sep="\n")
    print("Without tractability (wT = 0):",
          rank_barriers({"P": 1, "S": 1, "T": 0}).head(5)[["id", "score"]].values.tolist())
    print("Survey n for +/-3 pp:", survey_n())
    barrier_register("en").to_csv("barrier-register-en.csv", index=False)
    barrier_register("fr").to_csv("barrier-register-fr.csv", index=False, encoding="utf-8-sig")
