"""
Appendix-4 corrected business case — the Genesys/Broadreach benefits
kept verbatim, with the costs the deck omitted: AI Experience token
consumption, AI implementation effort (V2 LoE), and double-billing of
the existing platforms until their term contracts end.

Single source of truth behind the deliverable notebook
(``notebooks/ctm_business_case_corrected.ipynb``, served with
Mercury) — the presentation layer holds no math.

Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
``docs/ctm_ai_labour_estimate_V2.md`` (implementation hours).
"""

from __future__ import annotations

import dataclasses
import datetime as dt
import math

import pandas as pd

from .business_case import npv, payback_years
from .cost_model import calculate_total_cost
from .defaults import DEFAULT_METERS
from .inputs import FeatureScope, SiteInput
from .meters import Confidence, TokenMeter, TokenPricing
from .rollout import RolloutPlan
from .scenarios import Scenario

# ── Timeline ─────────────────────────────────────────────────────────

YEARS = [2026, 2027, 2028]                     # model years 1..3, contract start Jan 2026
YEAR_INDEX = {2026: 1, 2027: 2, 2028: 3}
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
           "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]


def month_label(m: int) -> str:
    """Calendar label for a 1-indexed month from Jan 2026 (m=21 → 'Sep 2027')."""
    return f"{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12}"


# ── Verbatim Appendix 4 figures ──────────────────────────────────────

REGIONS = ["NA", "ANZ", "EMEA", "ASIA"]
CAPABILITIES = ["Agent Copilot", "WFM", "Email", "STA",
                "Predictive Routing", "Supervisor Copilot"]

#: (annual_value, three_yr_value) — VERBATIM slides 12-15, do not edit.
VERBATIM_BENEFITS: dict[tuple[str, str], tuple[float, float]] = {
    ("NA", "Agent Copilot"): (2_400_000, 3_400_000),
    ("NA", "Email"): (1_900_000, 2_500_000),
    ("NA", "STA"): (294_000, 506_000),
    ("NA", "Supervisor Copilot"): (218_000, 291_000),
    ("NA", "Predictive Routing"): (97_000, 167_000),
    ("NA", "WFM"): (0, 0),                     # NA excluded — has similar feature
    ("ANZ", "Agent Copilot"): (3_600_000, 3_900_000),
    ("ANZ", "WFM"): (1_300_000, 1_400_000),
    ("ANZ", "Predictive Routing"): (279_000, 302_000),
    ("ANZ", "Email"): (132_000, 143_000),
    ("ANZ", "STA"): (97_000, 105_000),
    ("ANZ", "Supervisor Copilot"): (25_000, 27_000),
    ("ASIA", "WFM"): (1_600_000, 914_000),
    ("ASIA", "Email"): (160_000, 93_000),
    ("ASIA", "STA"): (124_000, 72_000),
    ("ASIA", "Predictive Routing"): (87_000, 51_000),
    ("ASIA", "Agent Copilot"): (0, 0),
    ("ASIA", "Supervisor Copilot"): (0, 0),
    ("EMEA", "WFM"): (824_000, 687_000),
    ("EMEA", "Email"): (282_000, 235_000),
    ("EMEA", "STA"): (157_000, 131_000),
    ("EMEA", "Agent Copilot"): (77_000, 64_000),
    ("EMEA", "Supervisor Copilot"): (59_000, 49_000),
    ("EMEA", "Predictive Routing"): (7_000, 6_000),
}

#: The deck's own (rounded) summary rows — slides 8-9.
SLIDE_TOTALS: dict = {
    "regional_3yr": {"NA": 6_900_000, "ANZ": 5_900_000,
                     "ASIA": 1_100_000, "EMEA": 1_200_000},
    "capability_3yr": {"Agent Copilot": 7_400_000, "WFM": 3_000_000,
                       "Email": 2_900_000, "STA": 814_000,
                       "Predictive Routing": 526_000,
                       "Supervisor Copilot": 367_000},
    "total_3yr": 15_000_000,
    "total_annual": 13_600_000,
}

#: Verbatim TCO anchors — slides 5-6.
TCO_VERBATIM: dict[str, float] = {
    "current_annual": 7_300_000,   # current global spend / yr
    "current_3yr": 22_000_000,
    "ccaas_annual": 4_300_000,     # licence run-rate / yr
    "ccaas_3yr": 15_400_000,       # deck's 3-yr CCaaS investment (no ramp, no AI costs)
    "prof_services_y1": 2_400_000,
    "training_y1": 167_000,
    "npv_discount_rate": 0.135,    # deck's benefit-NPV rate
}

#: Actual contracted values where they differ from the deck — 🟢
#: contractual. Layered over TCO_VERBATIM, which stays the untouched
#: record of what Genesys pitched (the anchor CTM can follow);
#: presentation reads through :func:`tco`.
TCO_CONTRACTED: dict[str, float] = {
    "ccaas_annual": 3_200_000,     # signed licence run-rate (deck pitched $4.3M/yr)
}

#: NTT professional services — SOW billing milestones (🟢 contractual).
#: The deck's verbatim anchor books $2.4M PS in year 1; the signed SOW
#: bills $2,025,446.48 in four milestones split 50/50 across 2026-27.
PS_MILESTONES: list[dict] = [
    {"name": "SOW Effective Date",                   "date": dt.date(2026, 3, 15),
     "share": 0.30, "amount": 607_633.94},
    {"name": "Start of client UAT (first region, NA)", "date": dt.date(2026, 10, 16),
     "share": 0.20, "amount": 405_089.30},
    {"name": "Start of client UAT (last region, APAC)", "date": dt.date(2027, 10, 1),
     "share": 0.30, "amount": 607_633.94},
    {"name": "Completion of last go-live migration",   "date": dt.date(2027, 11, 1),
     "share": 0.20, "amount": 405_089.30},
]
PS_CONTRACTED_TOTAL = sum(m["amount"] for m in PS_MILESTONES)

#: NTT managed services — commences billing at MCX go-live (🟢 contractual).
#: Not in the deck's TCO at all; an ongoing run-rate cost thereafter.
MANAGED_SERVICES_ANNUAL = 410_918.40
MCX_GO_LIVE = dt.date(2026, 10, 16)


def tco(key: str) -> float:
    """Contracted value where one exists, else the deck's verbatim anchor."""
    return TCO_CONTRACTED.get(key, TCO_VERBATIM[key])

#: Deployment schedule per the current PM delivery timeline (Jul 2026),
#: months from Jan 2026 inclusive. UAT start is the implementation anchor:
#: NA Oct 2026 (m10), EMEA Feb 2027 (m14), ANZ Jun 2027 (m18),
#: APAC Oct 2027 (m22). Benefits realize IMPL + 3 months.
IMPL_MONTH = {"NA": 10, "EMEA": 14, "ANZ": 18, "ASIA": 22}
BENEFIT_LAG_MONTHS = 3
REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}
#: NA-Email-early exception retired under the compressed timeline
#: (NA implements Oct 2026; Email cannot realize before NA itself).
NA_EMAIL_IMPL_MONTH = IMPL_MONTH["NA"]

DEFAULT_RAMP_MONTHS = 6                        # Genesys ramp programme (🟢 order form)
DEFAULT_TERMINATION = dt.date(2027, 12, 31)    # current-platform term contracts

# ── Region ⇄ site mapping ────────────────────────────────────────────


def site_region(site_name: str) -> str:
    """Map a tokencalc site to its Appendix-4 region (APAC * → ASIA)."""
    return {"NAM": "NA", "AUZ": "ANZ", "EMEA": "EMEA"}.get(site_name, "ASIA")


def region_site_names(sites: list[SiteInput]) -> dict[str, list[str]]:
    return {r: [s.site_name for s in sites if site_region(s.site_name) == r]
            for r in REGIONS}


def region_agents(sites: list[SiteInput]) -> dict[str, int]:
    return {r: sum(s.agents for s in sites if site_region(s.site_name) == r)
            for r in REGIONS}


# ── Verbatim benefit helpers ─────────────────────────────────────────


def verbatim_dataframe() -> pd.DataFrame:
    """Long DataFrame of the verbatim benefits: region, capability, annual, three_yr."""
    return pd.DataFrame(
        [{"region": r, "capability": c, "annual": a, "three_yr": t}
         for (r, c), (a, t) in VERBATIM_BENEFITS.items()]
    )


def crossfoot_tolerance(value: float) -> float:
    """The deck rounds to $0.1M and its own tables cross-foot ±$50-120K."""
    return max(100_000, 0.015 * value)


# ── Schedules & rollouts ─────────────────────────────────────────────


def build_rollouts(
    sites: list[SiteInput],
    na_email_early: bool = True,
    ramp_months: int = DEFAULT_RAMP_MONTHS,
) -> tuple[RolloutPlan, RolloutPlan, RolloutPlan]:
    """(token, email_token, benefit) rollout plans on the deck's schedule.

    ``RolloutPlan.go_live_month = m`` means active from month m+1; the
    deck's labels are inclusive (NA "realizes Sep 2027" ⇒ September
    counts), so keys are set to label − 1. The benefit plan is keyed by
    region (plus ``NA_EMAIL`` for the NA Gantt exception); the token
    plans are keyed by site.
    """
    token = RolloutPlan(
        contract_start="2026-01", build_months=max(IMPL_MONTH.values()),
        ramp_months=ramp_months,
        first_year_platform_discount=0.0,   # licences are handled verbatim, not by this plan
        go_live_month={s.site_name: IMPL_MONTH[site_region(s.site_name)] - 1
                       for s in sites},
    )
    email = dataclasses.replace(
        token,
        go_live_month={**token.go_live_month,
                       "NAM": (NA_EMAIL_IMPL_MONTH - 1) if na_email_early
                       else IMPL_MONTH["NA"] - 1},
    )
    benefit = RolloutPlan(
        first_year_platform_discount=0.0,
        go_live_month={**{r: REALIZE_MONTH[r] - 1 for r in REGIONS},
                       "NA_EMAIL": (NA_EMAIL_IMPL_MONTH + BENEFIT_LAG_MONTHS - 1)
                       if na_email_early else REALIZE_MONTH["NA"] - 1},
    )
    return token, email, benefit


def benefits_by_year(
    benefit_rollout: RolloutPlan, na_email_early: bool = True
) -> pd.DataFrame:
    """Phase each verbatim 3-yr value by its region's realization window.

    Scaling is at the finest grain (region × capability), so every
    verbatim per-region, per-capability, and grand total is reproduced
    exactly. Long DataFrame: region, capability, year, benefit.
    """
    rows = []
    for (region, cap), (_annual, three_yr) in VERBATIM_BENEFITS.items():
        key = ("NA_EMAIL" if (region == "NA" and cap == "Email" and na_email_early)
               else region)
        live = [benefit_rollout.live_months_in_year(key, YEAR_INDEX[y]) for y in YEARS]
        total_live = sum(live)
        for y, m in zip(YEARS, live):
            rows.append({"region": region, "capability": cap, "year": y,
                         "benefit": three_yr * m / total_live if total_live else 0.0})
    return pd.DataFrame(rows)


# ── Base cost lines (verbatim + contract mechanics) ──────────────────


def current_months_in_year(termination: dt.date, cal_year: int) -> int:
    """Months a term contract bills in ``cal_year`` (through its termination month)."""
    if cal_year < termination.year:
        return 12
    if cal_year > termination.year:
        return 0
    return termination.month


def current_state_inputs(
    sites: list[SiteInput],
    total_annual: float | None = None,
    termination: dt.date = DEFAULT_TERMINATION,
) -> pd.DataFrame:
    """Per-region current-platform inputs, seeded by agent share of the
    verbatim global spend. Region-indexed; annual_cost and
    contract_termination are the editable columns."""
    total = TCO_VERBATIM["current_annual"] if total_annual is None else total_annual
    agents = region_agents(sites)
    total_agents = sum(agents.values())
    return pd.DataFrame([
        {"region": r,
         "agents": agents[r],
         "share": agents[r] / total_agents,
         "annual_cost": total * agents[r] / total_agents,
         "contract_termination": termination,
         "confidence": "🟡 agent-share allocation of the verbatim total"}
        for r in REGIONS
    ]).set_index("region")


def current_costs_by_year(current_state: pd.DataFrame) -> dict[int, float]:
    """Existing-platform run-off per calendar year (the double-billing line)."""
    return {
        y: float(sum(
            row["annual_cost"]
            * current_months_in_year(row["contract_termination"], y) / 12
            for _, row in current_state.iterrows()))
        for y in YEARS
    }


def licence_months_in_year(year_index: int, ramp_months: int) -> int:
    """Ramp programme: licence billing starts in calendar month ramp_months + 1."""
    start, end = 12 * (year_index - 1) + 1, 12 * year_index
    return max(0, end - max(start, ramp_months + 1) + 1)


def licence_costs_by_year(
    ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
) -> dict[int, float]:
    rate = TCO_VERBATIM["ccaas_annual"] if annual is None else annual
    return {y: rate * licence_months_in_year(YEAR_INDEX[y], ramp_months) / 12
            for y in YEARS}


def ps_costs_by_year(contracted: bool = False) -> dict[int, float]:
    """Base professional services + training.

    Verbatim: the deck's $2.4M PS lump plus training, all in year 1.
    Contracted: PS phased on the SOW billing milestones (50/50 across
    2026-27, $2.03M total); training stays the verbatim year-1 line —
    the SOW milestones don't itemize it separately.
    """
    training = TCO_VERBATIM["training_y1"]
    if contracted:
        ps = {y: 0.0 for y in YEARS}
        for m in PS_MILESTONES:
            ps[m["date"].year] += m["amount"]
        return {y: ps[y] + (training if y == 2026 else 0.0) for y in YEARS}
    return {2026: TCO_VERBATIM["prof_services_y1"] + training,
            2027: 0.0, 2028: 0.0}


def ps_milestones_dataframe() -> pd.DataFrame:
    """The SOW billing milestones as a display table."""
    return pd.DataFrame(PS_MILESTONES)


def managed_services_by_year(
    annual: float = MANAGED_SERVICES_ANNUAL, start: dt.date = MCX_GO_LIVE
) -> dict[int, float]:
    """Managed services bill from the month after go-live (Oct 16 → Nov),
    then run at the full annual rate — an ongoing cost with no end date
    inside the model window."""
    def _months(y: int) -> int:
        if y < start.year:
            return 0
        return 12 if y > start.year else 12 - start.month
    return {y: annual * _months(y) / 12 for y in YEARS}


# ── Token consumption (missing cost #1) ──────────────────────────────


def claim_scenario(email_auto_respond_rate: float = 0.255) -> Scenario:
    """Claim-level scenario: deck parameters, no consumption maturity ramp."""
    return Scenario(
        name="genesys-claim",
        voice_bot_deflection=0.0, voice_bot_avg_minutes=0.0,
        agentic_va_deflection=0.0,
        voice_summarization_eligibility=0.0,
        voice_knowledge_eligibility=0.0,       # unused by the Appendix-4 scope set
        email_auto_respond_rate=email_auto_respond_rate,
        consumption_cost_realization={1: 1.0, 2: 1.0, 3: 1.0},
    )


def autorespond_meter(tokens_per_msg: float = 0.05) -> TokenMeter:
    """Email Auto-Respond working meter — rate unpublished (🔴→🟡).

    Anchor: ≈1 AI action per generated response; Genesys Cloud Copilot
    meters 20 AI actions per token.
    """
    return dataclasses.replace(
        DEFAULT_METERS["Email AI (Auto-Respond)"],
        units_per_token=1.0 / tokens_per_msg,
        tokens_per_unit=tokens_per_msg,
        confidence=Confidence.ESTIMATED,
        notes="WORKING ASSUMPTION — rate unpublished; ≈1 AI action per generated "
              "response (Genesys Cloud Copilot meters 20 AI actions/token).",
    )


def build_scopes(
    sites: list[SiteInput],
    copilot_includes_asia: bool = False,
    pr_eligibility: float = 1.0,
    ai_translate_eligibility: float = 0.01,
) -> tuple[list[FeatureScope], list[FeatureScope]]:
    """(core, email) feature scopes mirroring the six deck capabilities.

    No ``adoption_curve`` on any scope — a curve would silently override
    the claim scenario's flat consumption realization. Email scopes are
    separate because NA Email implements early (own rollout plan).
    """
    all_names = [s.site_name for s in sites]
    asia = [n for n in all_names if site_region(n) == "ASIA"]
    non_asia = [n for n in all_names if site_region(n) != "ASIA"]
    copilot_sites = non_asia + (asia if copilot_includes_asia else [])
    core = [
        FeatureScope("Agent Copilot [named]", copilot_sites, phase=1),
        FeatureScope("Speech & Text Analytics [named]", all_names, phase=1),
        FeatureScope("Predictive Routing", all_names, phase=1,
                     eligibility_pct=pr_eligibility),
        # $0 by Rule 1 (Copilot covers summarization) — kept visible.
        FeatureScope("AI Summary & Insights", copilot_sites, phase=1),
        # Supervisor Copilot small-volume proxy.
        FeatureScope("AI Translate", asia + ["EMEA"], phase=1,
                     eligibility_pct=ai_translate_eligibility),
    ]
    email = [FeatureScope("Email AI (Auto-Respond)", all_names, phase=1)]
    return core, email


def token_costs_by_year(
    sites: list[SiteInput],
    meters: dict[str, TokenMeter],
    pricing: dict[str, TokenPricing],
    scenario: Scenario,
    core_scopes: list[FeatureScope],
    email_scopes: list[FeatureScope],
    token_rollout: RolloutPlan,
    email_rollout: RolloutPlan,
    use_contracted: bool = False,
) -> pd.DataFrame:
    """Engine-computed token costs, rollout-gated, per calendar year.

    Long DataFrame: cost_line, scope, annual_cost, confidence, year.
    """
    frames = []
    for y in YEARS:
        for scopes, rollout in ((core_scopes, token_rollout),
                                (email_scopes, email_rollout)):
            part = calculate_total_cost(
                sites, scopes, meters, pricing, scenario, YEAR_INDEX[y],
                include_platform=False, use_contracted=use_contracted,
                rollout=rollout,
            )
            part["year"] = y
            frames.append(part)
    return pd.concat(frames, ignore_index=True)


# ── AI implementation effort (missing cost #2, V2 LoE) ───────────────

#: (low, high) Y1 hours — docs/ctm_ai_labour_estimate_V2.md.
AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
    "Agent Copilot": (1_200, 1_800),      # voice + digital incl. email Auto-Suggest
    "Email Auto-Respond": (800, 1_400),   # separate flow; needs SoR integration
    "STA": (800, 1_200),                  # topics, programs, tuning × 7 languages
    "Supervisor Copilot": (200, 400),
    "Predictive Routing": (400, 700),
    "Cross-cutting (PM, governance, testing, integration)": (1_000, 1_800),
}
KB_READINESS_HOURS = (500, 1_500)         # prerequisite project — flagged separately
STEADY_STATE_HOURS = (500, 900)           # absolute h/yr, 2027-2028
DEFAULT_BLENDED_RATE = 225.0
SMELL_TEST_FLOOR = 0.15                   # impl ≥ 15% of benefit claim, or flag


def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[str]]:
    """Which regions each implementation workstream serves."""
    return {
        "Agent Copilot": (["NA", "ANZ", "EMEA"]
                          + (["ASIA"] if copilot_includes_asia else [])),
        "Email Auto-Respond": list(REGIONS),
        "STA": list(REGIONS),
        "Supervisor Copilot": ["NA", "ANZ", "EMEA"],   # deck: $0 SupCopilot in ASIA
        "Predictive Routing": list(REGIONS),
        "Cross-cutting (PM, governance, testing, integration)": list(REGIONS),
    }


def hours_pick(rng: tuple[float, float], mode: str) -> float:
    low, high = rng
    return {"low": low, "mid": (low + high) / 2, "high": high}[mode]


def impl_year_fractions(impl_month: int) -> list[float]:
    """Spend spreads uniformly from contract start (month 0) to the impl month."""
    prev, fracs = 0, []
    for yi in (1, 2, 3):
        cur = min(12 * yi, impl_month)
        fracs.append((cur - prev) / impl_month)
        prev = cur
    return fracs


def region_impl_month(feature: str, region: str, na_email_early: bool = True) -> int:
    if feature == "Email Auto-Respond" and region == "NA" and na_email_early:
        return NA_EMAIL_IMPL_MONTH
    return IMPL_MONTH[region]


def build_impl_costs(
    sites: list[SiteInput],
    mode: str = "mid",
    rate: float = DEFAULT_BLENDED_RATE,
    include_kb: bool = True,
    copilot_includes_asia: bool = False,
    na_email_early: bool = True,
) -> tuple[pd.DataFrame, dict[int, float], dict[int, float], dict[int, float]]:
    """V2 hours-range × rate model (swap point for the future LoE engine).

    Returns (detail_df, impl_by_year, kb_by_year, steady_by_year).
    Hours allocate to each workstream's scoped regions by agent share;
    steady-state is booked program-level in 2027-2028.
    """
    agents = region_agents(sites)
    feature_regions = impl_feature_regions(copilot_includes_asia)
    workstreams = dict(AI_IMPL_HOURS)
    if include_kb:
        workstreams["KB readiness (prerequisite)"] = KB_READINESS_HOURS
    rows = []
    for feature, rng in workstreams.items():
        regions = feature_regions.get(feature, list(REGIONS))
        scope_agents = sum(agents[r] for r in regions)
        for r in regions:
            hours = hours_pick(rng, mode) * agents[r] / scope_agents
            fracs = impl_year_fractions(
                region_impl_month(feature, r, na_email_early))
            rows.append({"workstream": feature, "region": r, "hours": hours,
                         "cost": hours * rate,
                         **{y: hours * rate * f for y, f in zip(YEARS, fracs)}})
    df = pd.DataFrame(rows)
    is_kb = df["workstream"].str.startswith("KB")
    impl_y = {y: float(df.loc[~is_kb, y].sum()) for y in YEARS}
    kb_y = {y: float(df.loc[is_kb, y].sum()) for y in YEARS}
    steady = hours_pick(STEADY_STATE_HOURS, mode) * rate
    steady_y = {2026: 0.0, 2027: steady, 2028: steady}
    return df, impl_y, kb_y, steady_y


# ── Business case (baseline-relative frame) ──────────────────────────


def case_flows(
    total_cost_by_year: dict[int, float],
    benefit_total_by_year: dict[int, float],
    baseline_annual: float | None = None,
) -> tuple[dict[int, float], dict[int, float]]:
    """(incremental cost, net) vs the do-nothing baseline.

    One frame captures both the 2026-27 double-billing penalty and the
    post-termination cost-avoidance credit.
    """
    base = TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
    inc = {y: total_cost_by_year[y] - base for y in YEARS}
    net = {y: benefit_total_by_year[y] - inc[y] for y in YEARS}
    return inc, net


def payback_label(net_by_year: dict[int, float]) -> str:
    pb = payback_years([net_by_year[y] for y in YEARS])
    if pb is None:
        return f"beyond {YEARS[-1]}"
    if pb == 0:
        return "immediate"
    m = math.ceil(pb * 12)
    return f"{m} months (~{month_label(m)})"


def case_kpis(
    inc: dict[int, float],
    net: dict[int, float],
    discount_rate: float | None = None,
) -> dict:
    """KPIs for one cost frame. Benefits are recoverable as net + inc."""
    rate = TCO_VERBATIM["npv_discount_rate"] if discount_rate is None else discount_rate
    net_list = [net[y] for y in YEARS]
    inc_total = sum(inc.values())
    net_total = sum(net_list)
    return {
        "benefits_3yr": net_total + inc_total,
        "incremental_cost_3yr": inc_total,
        "net_3yr": net_total,
        "roi": (net_total / inc_total) if inc_total > 0 else None,
        "npv": npv(net_list, rate),
        "discount_rate": rate,
        "payback": payback_label(net),
    }


# ── Display helpers ──────────────────────────────────────────────────


def money(v: float) -> str:
    sign, a = ("-" if v < 0 else ""), abs(v)
    return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"


def html_money(v: float) -> str:
    """Plotly text with two or more bare ``$`` triggers MathJax math mode —
    annotations holding several amounts must use the HTML entity instead."""
    return money(v).replace("$", "&#36;")
