"""
Placeholder Resolver for AI Prompts

Provides a registry of placeholder functions that resolve to actual user data.
Used for prompt templates and preview functionality.

Phase 0c: Refactored to use data_layer for structured data.
This module now focuses on FORMATTING for AI consumption.
"""
import json
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Callable, Tuple
from db import get_db, get_cursor, r2d

# Phase 0c: Import data layer
from data_layer.body_metrics import (
    get_latest_weight_data,
    get_bmi_data,
    get_profile_goal_weight_data,
    get_profile_goal_bf_pct_data,
    get_weight_trend_data,
    get_body_composition_data,
    get_circumference_summary_data
)
from data_layer.nutrition_metrics import (
    get_nutrition_average_data,
    get_nutrition_days_data,
    get_protein_targets_data
)
from data_layer.activity_metrics import (
    get_activity_summary_data,
    get_activity_detail_data,
    get_training_type_distribution_data,
    get_training_frequency_by_type_data,
    get_training_inter_session_gap_data,
    get_training_sessions_recent_weeks_data,
)
from data_layer.recovery_metrics import (
    get_sleep_duration_data,
    get_sleep_quality_data,
    get_rest_days_data
)
from data_layer.health_metrics import (
    get_resting_heart_rate_data,
    get_heart_rate_variability_data,
    get_vo2_max_data
)

from placeholder_registry import build_ai_placeholder_caption, get_registry

# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
_PLACEHOLDER_TOKEN_RE = re.compile(
    r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}"
)


def get_catalog_row_for_key(
    catalog: Optional[Dict[str, List[Dict[str, Any]]]], key: str
) -> Optional[Dict[str, Any]]:
    """Katalogzeile zu Platzhalter-Key (key ohne {{}})."""
    if not catalog:
        return None
    for items in catalog.values():
        for item in items:
            raw = item.get("key") or ""
            ik = str(raw).replace("{{", "").replace("}}", "").strip()
            if ik == key:
                return item
    return None


def _description_for_registry_key(key: str) -> str:
    meta = get_registry().get(key)
    if not meta:
        return ""
    return (meta.description or "").strip()


def _explain_for_registry_key(key: str) -> str:
    meta = get_registry().get(key)
    if not meta:
        return ""
    return build_ai_placeholder_caption(meta).strip()


def format_value_with_d_modifier(value: str, catalog_row: Dict[str, Any]) -> str:
    """
    Vorschau für Export wie {{key|d}}: „Wert — description“ (kein ai_caption).
    """
    cap = (catalog_row.get("description") or "").strip()
    if cap:
        return f"{value} — {cap}"
    return str(value)


# ── Helper Functions ──────────────────────────────────────────────────────────

def get_profile_data(profile_id: str) -> Dict:
    """Load profile data for a user."""
    with get_db() as conn:
        cur = get_cursor(conn)
        cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
        return r2d(cur.fetchone()) if cur.rowcount > 0 else {}


def pv_unavailable(reason: str, detail: Optional[str] = None) -> str:
    """
    Standard-Antwort wenn kein Platzhalter-Wert lieferbar ist.
    Grund ist für Nutzer und KI lesbar (ggf. Alternativen im Text).
    """
    r = (reason or "Keine auswertbaren Daten").strip()
    if detail:
        d = str(detail).strip()
        if d:
            return f"nicht verfügbar — {r} ({d})"
    return f"nicht verfügbar — {r}"


def pv_unavailable_json(reason: str, detail: Optional[str] = None) -> str:
    """Strukturierte JSON-Antwort statt leeres {} (für KI / Clients)."""
    payload: Dict[str, object] = {
        "_available": False,
        "_reason": (reason or "Keine auswertbaren Daten").strip(),
    }
    if detail:
        d = str(detail).strip()
        if d:
            payload["_detail"] = d
    return json.dumps(payload, ensure_ascii=False)


def get_latest_weight(profile_id: str) -> Optional[str]:
    """
    Get latest weight entry.

    Phase 0c: Refactored to use data_layer.body_metrics.get_latest_weight_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_latest_weight_data(profile_id)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Kein aktuelles Gewicht",
            f"confidence={data.get('confidence')}, data_points={data.get('data_points',0)}",
        )

    return f"{data['weight']:.1f} kg"


def get_weight_trend(profile_id: str, days: int = 28) -> str:
    """
    Calculate weight trend description.

    Phase 0c: Refactored to use data_layer.body_metrics.get_weight_trend_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_weight_trend_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Gewichtstrend nicht ermittelbar",
            f"confidence={data.get('confidence')}, Fenster={days} Tage",
        )

    direction = data['direction']
    delta = data['delta']

    if direction == "stable":
        return "stabil"
    elif direction == "increasing":
        return f"steigend (+{delta:.1f} kg in {days} Tagen)"
    else:  # decreasing
        return f"sinkend ({delta:.1f} kg in {days} Tagen)"


def get_latest_bf(profile_id: str) -> Optional[str]:
    """
    Get latest body fat percentage from caliper.

    Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_body_composition_data(profile_id)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Körperfett nicht ermittelbar",
            f"confidence={data.get('confidence')} (keine ausreichenden Caliper-/Kompositionsdaten)",
        )

    return f"{data['body_fat_pct']:.1f}%"


def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str:
    """
    Calculate average nutrition value.

    Phase 0c: Refactored to use data_layer.nutrition_metrics.get_nutrition_average_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_nutrition_average_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Ernährungsmittelwert nicht ermittelbar",
            f"confidence={data.get('confidence')}, Feld={field}, Fenster={days} Tage",
        )

    # Map field names to data keys
    field_map = {
        'protein': 'protein_avg',
        'fat': 'fat_avg',
        'carb': 'carbs_avg',
        'kcal': 'kcal_avg'
    }
    data_key = field_map.get(field, f'{field}_avg')
    value = data.get(data_key, 0)

    if field == 'kcal':
        return f"{int(value)} kcal/Tag (Ø {days} Tage)"
    else:
        return f"{int(value)}g/Tag (Ø {days} Tage)"


def get_caliper_summary(profile_id: str) -> str:
    """
    Get latest caliper measurements summary.

    Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_body_composition_data(profile_id)

    if data['confidence'] == 'insufficient':
        return "keine Caliper-Messungen"

    method = data.get('method', 'unbekannt')
    return f"{data['body_fat_pct']:.1f}% ({method} am {data['date']})"


def get_circ_summary(profile_id: str) -> str:
    """
    Get latest circumference measurements summary with age annotations.

    Phase 0c: Refactored to use data_layer.body_metrics.get_circumference_summary_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_circumference_summary_data(profile_id)

    if data['confidence'] == 'insufficient':
        return "keine Umfangsmessungen"

    parts = []
    for measurement in data['measurements']:
        age_days = measurement['age_days']

        # Format age annotation
        if age_days == 0:
            age_str = "heute"
        elif age_days == 1:
            age_str = "gestern"
        elif age_days <= 7:
            age_str = f"vor {age_days} Tagen"
        elif age_days <= 30:
            weeks = age_days // 7
            age_str = f"vor {weeks} Woche{'n' if weeks > 1 else ''}"
        else:
            months = age_days // 30
            age_str = f"vor {months} Monat{'en' if months > 1 else ''}"

        parts.append(f"{measurement['point']} {measurement['value']:.1f}cm ({age_str})")

    return ', '.join(parts) if parts else "keine Umfangsmessungen"


def get_goal_weight(profile_id: str) -> str:
    """Zielgewicht aus profiles.goal_weight (Layer 1: get_profile_goal_weight_data)."""
    data = get_profile_goal_weight_data(profile_id)
    g = data.get("goal_weight_kg")
    return f"{g:.1f}" if g is not None else "nicht gesetzt"


def get_goal_bf_pct(profile_id: str) -> str:
    """Ziel-KFA aus profiles.goal_bf_pct (Layer 1: get_profile_goal_bf_pct_data)."""
    data = get_profile_goal_bf_pct_data(profile_id)
    g = data.get("goal_bf_pct")
    return f"{g:.1f}" if g is not None else "nicht gesetzt"


def get_nutrition_days(profile_id: str, days: int = 30) -> str:
    """
    Get number of days with nutrition data.

    Phase 0c: Refactored to use data_layer.nutrition_metrics.get_nutrition_days_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_nutrition_days_data(profile_id, days)
    return str(data['days_with_data'])


def get_protein_ziel_low(profile_id: str) -> str:
    """
    Calculate lower protein target based on current weight (1.6g/kg).

    Phase 0c: Refactored to use data_layer.nutrition_metrics.get_protein_targets_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_protein_targets_data(profile_id)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Proteinziel unten nicht ermittelbar",
            f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)",
        )

    return f"{int(data['protein_target_low'])}"


def get_protein_ziel_high(profile_id: str) -> str:
    """
    Calculate upper protein target based on current weight (2.2g/kg).

    Phase 0c: Refactored to use data_layer.nutrition_metrics.get_protein_targets_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_protein_targets_data(profile_id)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Proteinziel oben nicht ermittelbar",
            f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)",
        )

    return f"{int(data['protein_target_high'])}"


def get_activity_summary(profile_id: str, days: int = 14) -> str:
    """
    Get activity summary for recent period.

    Phase 0c: Refactored to use data_layer.activity_metrics.get_activity_summary_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_activity_summary_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return f"Keine Aktivitäten in den letzten {days} Tagen"

    return f"{data['activity_count']} Einheiten in {days} Tagen (Ø {data['avg_duration_min']} min/Einheit, {data['total_kcal']} kcal gesamt)"


def calculate_age(dob) -> str:
    """Calculate age from date of birth (accepts date object or string)."""
    if not dob:
        return "unbekannt"
    try:
        # Handle both datetime.date objects and strings
        if isinstance(dob, str):
            birth = datetime.strptime(dob, '%Y-%m-%d').date()
        else:
            birth = dob  # Already a date object from PostgreSQL

        today = datetime.now().date()
        age = today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
        return str(age)
    except Exception as e:
        return "unbekannt"


def get_profile_name(profile_id: str) -> str:
    """Profil-Platzhalter: Anzeigename (profiles.name)."""
    return get_profile_data(profile_id).get('name', 'Nutzer')


def get_profile_age_display(profile_id: str) -> str:
    """Profil-Platzhalter: Alter aus Geburtsdatum."""
    return calculate_age(get_profile_data(profile_id).get('dob'))


def get_profile_height_display(profile_id: str) -> str:
    """Profil-Platzhalter: Körpergröße (cm) als String."""
    return str(get_profile_data(profile_id).get('height', 'unbekannt'))


def get_profile_geschlecht_display(profile_id: str) -> str:
    """Profil-Platzhalter: Geschlecht aus profiles.sex (m/w)."""
    return 'männlich' if get_profile_data(profile_id).get('sex') == 'm' else 'weiblich'


def get_datum_heute(_profile_id: str) -> str:
    """Zeitraum-Platzhalter: heutiges Datum (dd.mm.yyyy)."""
    return datetime.now().strftime('%d.%m.%Y')


def get_zeitraum_label_7d(_profile_id: str) -> str:
    return 'letzte 7 Tage'


def get_zeitraum_label_30d(_profile_id: str) -> str:
    return 'letzte 30 Tage'


def get_zeitraum_label_90d(_profile_id: str) -> str:
    return 'letzte 90 Tage'


def get_activity_detail(profile_id: str, days: int = 14) -> str:
    """
    Get detailed activity log for analysis.

    Phase 0c: Refactored to use data_layer.activity_metrics.get_activity_detail_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_activity_detail_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return f"Keine Aktivitäten in den letzten {days} Tagen"

    # Format as readable list (max 20 entries to avoid token bloat)
    lines = []
    for activity in data['activities'][:20]:
        hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else ""
        lines.append(
            f"{activity['date']}: {activity['activity_type']} "
            f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})"
        )

    return '\n'.join(lines)


def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
    """
    Get training type distribution.

    Phase 0c: Refactored to use data_layer.activity_metrics.get_training_type_distribution_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_training_type_distribution_data(profile_id, days)

    if data['confidence'] == 'insufficient' or not data['distribution']:
        return "Keine kategorisierten Trainings"

    # Format top 3 categories with percentages
    parts = [
        f"{dist['category']}: {int(dist['percentage'])}%"
        for dist in data['distribution'][:3]
    ]
    return ", ".join(parts)


def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
    """
    Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
    """
    data = get_training_frequency_by_type_data(profile_id, days)
    if data["confidence"] == "insufficient" or not data["by_type"]:
        return f"Keine Trainingsdaten in den letzten {days} Tagen."

    def _f(x, nd=1):
        if x is None:
            return "—"
        if isinstance(x, float):
            return f"{x:.{nd}f}"
        return str(x)

    lines = [
        f"**Trainings-Häufigkeit & Intensität** (letzte {days} Tage, nach `activity_type`)",
        "",
        "| Art | n | Ø/Woche | Ø min | Ø kcal | Ø HF | HF max | Ø RPE | kcal/min |",
        "|-----|--:|--------:|------:|-------:|-----:|-------:|------:|---------:|",
    ]
    for x in data["by_type"]:
        lines.append(
            "| {name} | {n} | {pw} | {dm} | {kc} | {ha} | {hm} | {rp} | {kpm} |".format(
                name=str(x["activity_type"]).replace("|", "/"),
                n=x["session_count"],
                pw=_f(x["sessions_per_week"], 2),
                dm=_f(x["avg_duration_min"], 1),
                kc=_f(x["avg_kcal_active"], 0),
                ha=_f(x["avg_hr_avg"], 0),
                hm=_f(x["avg_hr_max"], 0),
                rp=_f(x["avg_rpe"], 1),
                kpm=_f(x["avg_kcal_per_min"], 2),
            )
        )
    lines.append("")
    lines.append(
        "_Intensität: kcal/min nur bei gesetzter Dauer & kcal; HF aus Import/Gerät; RPE optional._"
    )
    return "\n".join(lines)


def get_training_inter_session_gap_md(profile_id: str, days: int = 28) -> str:
    """Kurztext: median/mittlere Stunden zwischen aufeinanderfolgenden Einheiten."""
    d = get_training_inter_session_gap_data(profile_id, days)
    if d["confidence"] == "insufficient" or d.get("gaps_count", 0) < 1:
        return "Zu wenige Trainings für eine Pausen-Analyse (mindestens 2 Einheiten im Zeitraum)."
    return (
        f"**Pause zwischen Trainings** (letzte {days} Tage): Median **{d['gap_hours_median']} h**, "
        f"Mittel **{d['gap_hours_mean']} h**, kürzeste Lücke **{d['gap_hours_min']} h** "
        f"({d['gaps_count']} Intervalle). "
        "Sortierung nach Datum/Uhrzeit (fehlende Uhrzeit → 12:00)."
    )


def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str:
    """
    Calculate average sleep duration in hours.

    Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_duration_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_sleep_duration_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Schlafdauer nicht ermittelbar",
            f"confidence={data.get('confidence')}, "
            f"nächte_mit_wert={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)} "
            f"(Quellen: sleep_segments und/oder sleep_log.duration_minutes)",
        )

    return f"{data['avg_duration_hours']:.1f}h"


def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
    """
    Calculate average sleep quality (Deep+REM %).

    Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_quality_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_sleep_quality_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Schlafqualität (Deep+REM-Anteil) nicht ermittelbar",
            f"confidence={data.get('confidence')}, "
            f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)} "
            f"(Quellen: sleep_segments oder Spalten deep/rem/light/awake_minutes)",
        )

    return f"{data['quality_score']:.0f}% (Deep+REM)"


def get_rest_days_count(profile_id: str, days: int = 30) -> str:
    """
    Count rest days in the given period.

    Phase 0c: Refactored to use data_layer.recovery_metrics.get_rest_days_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_rest_days_data(profile_id, days)
    return f"{data['total_rest_days']} Ruhetage"


def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str:
    """
    Calculate average resting heart rate.

    Phase 0c: Refactored to use data_layer.health_metrics.get_resting_heart_rate_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_resting_heart_rate_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "Ruhepuls-Schnitt nicht ermittelbar",
            f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)",
        )

    return f"{data['avg_rhr']} bpm"


def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str:
    """
    Calculate average heart rate variability.

    Phase 0c: Refactored to use data_layer.health_metrics.get_heart_rate_variability_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_heart_rate_variability_data(profile_id, days)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "HRV-Schnitt nicht ermittelbar",
            f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)",
        )

    return f"{data['avg_hrv']} ms"


def get_vitals_vo2_max(profile_id: str) -> str:
    """
    Get latest VO2 Max value.

    Phase 0c: Refactored to use data_layer.health_metrics.get_vo2_max_data()
    This function now only FORMATS the data for AI consumption.
    """
    data = get_vo2_max_data(profile_id)

    if data['confidence'] == 'insufficient':
        return pv_unavailable(
            "VO2max nicht ermittelbar",
            f"confidence={data.get('confidence')} (vitals_baseline)",
        )

    return f"{data['vo2_max']:.1f} ml/kg/min"


# Begründungen wenn Layer-2-Berechnung None liefert (für KI / Export)
_DEFAULT_NUMERIC_UNAVAILABLE = (
    "Numerische Berechnung liefert keinen Wert (Daten unzureichend oder Schwellen nicht erreicht)"
)
_DEFAULT_STR_UNAVAILABLE = (
    "Kein Wert ermittelbar (Daten unzureichend oder Schwellen nicht erreicht)"
)
_DEFAULT_JSON_UNAVAILABLE = (
    "Keine strukturierten JSON-Daten ermittelbar (Berechnung liefert None)"
)

_SAFE_INT_NONE_REASON: Dict[str, str] = {
    "goal_progress_score": (
        "Aggregierter Ziel-Fortschritt nicht berechenbar; Alternativen: {{active_goals_md}}, "
        "{{data_quality_score}}"
    ),
    "body_progress_score": "Körper-Fortschritts-Score nicht berechenbar",
    "nutrition_score": (
        "Ernährungs-Score nicht berechenbar (z. B. keine gewichteten Ernährungs-Fokusbereiche oder zu wenig Log-Daten)"
    ),
    "activity_score": (
        "Aktivitäts-Score nicht berechenbar (z. B. Score-Schwellen oder fehlende abilities-Zuordnung in activity_log)"
    ),
    "recovery_score_v2": "Recovery-Score v2 nicht berechenbar (Schlaf/Vitals/Last)",
    "data_quality_score": "Datenqualitäts-Score nicht berechenbar",
    "top_goal_progress_pct": (
        "Fortschritt % des Top-Ziels nicht ermittelbar (progress_pct fehlt oder Ziel nicht quantifizierbar); "
        "Alternative: {{active_goals_json}}"
    ),
    "top_focus_area_progress": "Fortschritt % des Top-Fokusbereichs nicht ermittelbar",
    "focus_cat_körper_progress": "Kategorie-Fortschritt „Körper“ nicht berechenbar",
    "focus_cat_ernährung_progress": "Kategorie-Fortschritt „Ernährung“ nicht berechenbar",
    "focus_cat_aktivität_progress": "Kategorie-Fortschritt „Aktivität“ nicht berechenbar",
    "focus_cat_recovery_progress": "Kategorie-Fortschritt „Recovery“ nicht berechenbar",
    "focus_cat_vitalwerte_progress": "Kategorie-Fortschritt „Vitalwerte“ nicht berechenbar",
    "focus_cat_mental_progress": "Kategorie-Fortschritt „Mental“ nicht berechenbar",
    "focus_cat_lebensstil_progress": "Kategorie-Fortschritt „Lebensstil“ nicht berechenbar",
    "training_minutes_week": "Trainingsminuten/Woche nicht berechenbar",
    "training_frequency_7d": "Trainingseinheiten (7 Tage) nicht berechenbar",
    "quality_sessions_pct": "Anteil Qualitätssessions nicht berechenbar",
    "ability_balance_strength": (
        "Fähigkeiten-Balance Kraft nicht berechenbar (zu wenig abilities-Daten in activity_log)"
    ),
    "ability_balance_endurance": (
        "Fähigkeiten-Balance Ausdauer nicht berechenbar (abilities in activity_log)"
    ),
    "ability_balance_mental": (
        "Fähigkeiten-Balance Mental nicht berechenbar (abilities in activity_log)"
    ),
    "ability_balance_coordination": (
        "Fähigkeiten-Balance Koordination nicht berechenbar (abilities in activity_log)"
    ),
    "ability_balance_mobility": (
        "Fähigkeiten-Balance Mobilität nicht berechenbar (abilities in activity_log)"
    ),
    "proxy_internal_load_7d": "Interne Last (7 Tage) nicht berechenbar",
    "strain_score": "Strain-Score nicht berechenbar",
    "rest_day_compliance": "Ruhetag-Compliance nicht berechenbar",
    "protein_adequacy_28d": "Protein-Adequacy (28 Tage) nicht berechenbar",
    "macro_consistency_score": "Makro-Konsistenz-Score nicht berechenbar",
    "recent_load_balance_3d": "Load-Balance (3 Tage) nicht berechenbar",
    "sleep_quality_7d": "Schlafqualität 7 Tage nicht berechenbar",
}

_SAFE_FLOAT_NONE_REASON: Dict[str, str] = {
    "weight_7d_median": (
        "Gewichts-Median 7 Tage: mindestens 4 Messungen im Fenster erforderlich"
    ),
    "weight_28d_slope": (
        "Gewichts-Trend 28 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)"
    ),
    "weight_90d_slope": (
        "Gewichts-Trend 90 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)"
    ),
    "fm_28d_change": "Fettmasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)",
    "lbm_28d_change": "Magermasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)",
    "waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)",
    "hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar",
    "chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar",
    "arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar",
    "thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar",
    "waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar",
    "energy_balance_7d": (
        "Energiebilanz 7 Tage nicht berechenbar (Intake oder TDEE/Gewicht fehlt)"
    ),
    "protein_g_per_kg": "Protein g/kg nicht berechenbar",
    "monotony_score": "Monotonie-Score nicht berechenbar",
    "vo2max_trend_28d": "VO2max-Trend 28 Tage nicht berechenbar",
    "hrv_vs_baseline_pct": "HRV vs. Baseline nicht berechenbar (Baseline/Historie)",
    "rhr_vs_baseline_pct": "Ruhepuls vs. Baseline nicht berechenbar",
    "sleep_avg_duration_7d": "Schlafdauer 7 Tage nicht berechenbar (duration_minutes in sleep_log)",
    "sleep_debt_hours": "Schlafschuld nicht berechenbar (mindestens ~10 Nächte mit Dauer)",
    "sleep_regularity_proxy": "Schlaf-Regularität nicht berechenbar",
    "focus_cat_körper_weight": "Kategorie-Gewichtung „Körper“ nicht berechenbar",
    "focus_cat_ernährung_weight": "Kategorie-Gewichtung „Ernährung“ nicht berechenbar",
    "focus_cat_aktivität_weight": "Kategorie-Gewichtung „Aktivität“ nicht berechenbar",
    "focus_cat_recovery_weight": "Kategorie-Gewichtung „Recovery“ nicht berechenbar",
    "focus_cat_vitalwerte_weight": "Kategorie-Gewichtung „Vitalwerte“ nicht berechenbar",
    "focus_cat_mental_weight": "Kategorie-Gewichtung „Mental“ nicht berechenbar",
    "focus_cat_lebensstil_weight": "Kategorie-Gewichtung „Lebensstil“ nicht berechenbar",
}

_SAFE_STR_NONE_REASON: Dict[str, str] = {
    "top_goal_name": "Kein priorisiertes Ziel ermittelbar",
    "top_goal_status": "Status des Top-Ziels nicht ermittelbar",
    "top_focus_area_name": "Kein Top-Fokusbereich ermittelbar",
    "recomposition_quadrant": "Rekompositions-Quadrant nicht berechenbar (FM/LBM-Serie)",
    "energy_deficit_surplus": "Defizit/Überschuss-Status nicht berechenbar",
    "protein_days_in_target": "Protein-Tage im Ziel nicht berechenbar",
    "intake_volatility": "Intake-Volatilität nicht berechenbar",
    "active_goals_md": "Aktive Ziele (Markdown) nicht darstellbar",
    "focus_areas_weighted_md": "Fokusbereiche (Markdown) nicht darstellbar",
    "top_3_focus_areas": "Top-3-Fokusbereiche nicht darstellbar",
    "top_3_goals_behind_schedule": "Ziele „hinter Zeitplan“ nicht darstellbar",
    "top_3_goals_on_track": "Ziele „im Plan“ nicht darstellbar",
}

_SAFE_JSON_NONE_REASON: Dict[str, str] = {
    "training_sessions_recent_json": "Keine Session-Daten für JSON-Zeitfenster",
    "correlation_energy_weight_lag": (
        "Korrelation Energiebilanz zu Gewicht: zu wenige gekoppelte Datenpunkte"
    ),
    "correlation_protein_lbm": (
        "Korrelation Protein zu Magermasse: zu wenige gekoppelte Datenpunkte"
    ),
    "correlation_load_hrv": (
        "Korrelation Trainingslast zu HRV: zu wenige gekoppelte Datenpunkte"
    ),
    "correlation_load_rhr": (
        "Korrelation Trainingslast zu Ruhepuls: zu wenige gekoppelte Datenpunkte"
    ),
    "correlation_sleep_recovery": (
        "Korrelation Schlaf zu Recovery: zu wenige gekoppelte Datenpunkte"
    ),
    "plateau_detected": "Plateau-Erkennung: keine auswertbare Serie",
    "top_drivers": "Top-Treiber: keine auswertbare Korrelationsbasis",
    "active_goals_json": "Aktive Ziele als JSON nicht ermittelbar",
    "focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar",
    "focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar",
}


# ── Phase 0b Calculation Engine Integration ──────────────────────────────────

def _safe_int(func_name: str, profile_id: str) -> str:
    """
    Safely call calculation function and return integer value or fallback.

    Args:
        func_name: Name of the calculation function (e.g., 'goal_progress_score')
        profile_id: Profile ID

    Returns:
        String representation of integer value oder pv_unavailable-Text mit Grund
    """
    import traceback
    try:
        # Import calculations dynamically to avoid circular imports
        from data_layer import body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, scores
        from data_layer import correlations as correlation_metrics

        # Map function names to actual functions
        func_map = {
            'goal_progress_score': scores.calculate_goal_progress_score,
            'body_progress_score': body_metrics.calculate_body_progress_score,
            'nutrition_score': nutrition_metrics.calculate_nutrition_score,
            'activity_score': activity_metrics.calculate_activity_score,
            'recovery_score_v2': recovery_metrics.calculate_recovery_score_v2,
            'data_quality_score': scores.calculate_data_quality_score,
            'top_goal_progress_pct': lambda pid: scores.get_top_priority_goal(pid)['progress_pct'] if scores.get_top_priority_goal(pid) else None,
            'top_focus_area_progress': lambda pid: scores.get_top_focus_area(pid)['progress'] if scores.get_top_focus_area(pid) else None,
            'focus_cat_körper_progress': lambda pid: scores.calculate_category_progress(pid, 'körper'),
            'focus_cat_ernährung_progress': lambda pid: scores.calculate_category_progress(pid, 'ernährung'),
            'focus_cat_aktivität_progress': lambda pid: scores.calculate_category_progress(pid, 'aktivität'),
            'focus_cat_recovery_progress': lambda pid: scores.calculate_category_progress(pid, 'recovery'),
            'focus_cat_vitalwerte_progress': lambda pid: scores.calculate_category_progress(pid, 'vitalwerte'),
            'focus_cat_mental_progress': lambda pid: scores.calculate_category_progress(pid, 'mental'),
            'focus_cat_lebensstil_progress': lambda pid: scores.calculate_category_progress(pid, 'lebensstil'),
            'training_minutes_week': activity_metrics.calculate_training_minutes_week,
            'training_frequency_7d': activity_metrics.calculate_training_frequency_7d,
            'quality_sessions_pct': activity_metrics.calculate_quality_sessions_pct,
            'ability_balance_strength': activity_metrics.calculate_ability_balance_strength,
            'ability_balance_endurance': activity_metrics.calculate_ability_balance_endurance,
            'ability_balance_mental': activity_metrics.calculate_ability_balance_mental,
            'ability_balance_coordination': activity_metrics.calculate_ability_balance_coordination,
            'ability_balance_mobility': activity_metrics.calculate_ability_balance_mobility,
            'proxy_internal_load_7d': activity_metrics.calculate_proxy_internal_load_7d,
            'strain_score': activity_metrics.calculate_strain_score,
            'rest_day_compliance': activity_metrics.calculate_rest_day_compliance,
            'protein_adequacy_28d': nutrition_metrics.calculate_protein_adequacy_28d,
            'macro_consistency_score': nutrition_metrics.calculate_macro_consistency_score,
            'recent_load_balance_3d': recovery_metrics.calculate_recent_load_balance_3d,
            'sleep_quality_7d': recovery_metrics.calculate_sleep_quality_7d,
        }

        func = func_map.get(func_name)
        if not func:
            return pv_unavailable(
                "Ungültiger Platzhalter (keine numerische Berechnung registriert)",
                func_name,
            )

        result = func(profile_id)
        if result is None:
            return pv_unavailable(
                _SAFE_INT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE),
            )
        return str(int(result))
    except Exception as e:
        print(f"[ERROR] _safe_int({func_name}, {profile_id}): {type(e).__name__}: {e}")
        traceback.print_exc()
        return pv_unavailable(
            "Berechnungsfehler bei numerischem Platzhalter",
            f"{type(e).__name__}: {e}",
        )


def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str:
    """
    Safely call calculation function and return float value or fallback.

    Args:
        func_name: Name of the calculation function
        profile_id: Profile ID
        decimals: Number of decimal places

    Returns:
        String representation of float value oder pv_unavailable-Text mit Grund
    """
    import traceback
    try:
        from data_layer import body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, scores

        func_map = {
            'weight_7d_median': body_metrics.calculate_weight_7d_median,
            'weight_28d_slope': body_metrics.calculate_weight_28d_slope,
            'weight_90d_slope': body_metrics.calculate_weight_90d_slope,
            'fm_28d_change': body_metrics.calculate_fm_28d_change,
            'lbm_28d_change': body_metrics.calculate_lbm_28d_change,
            'waist_28d_delta': body_metrics.calculate_waist_28d_delta,
            'hip_28d_delta': body_metrics.calculate_hip_28d_delta,
            'chest_28d_delta': body_metrics.calculate_chest_28d_delta,
            'arm_28d_delta': body_metrics.calculate_arm_28d_delta,
            'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta,
            'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio,
            'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d,
            'protein_g_per_kg': nutrition_metrics.calculate_protein_g_per_kg,
            'monotony_score': activity_metrics.calculate_monotony_score,
            'vo2max_trend_28d': activity_metrics.calculate_vo2max_trend_28d,
            'hrv_vs_baseline_pct': recovery_metrics.calculate_hrv_vs_baseline_pct,
            'rhr_vs_baseline_pct': recovery_metrics.calculate_rhr_vs_baseline_pct,
            'sleep_avg_duration_7d': recovery_metrics.calculate_sleep_avg_duration_7d,
            'sleep_debt_hours': recovery_metrics.calculate_sleep_debt_hours,
            'sleep_regularity_proxy': recovery_metrics.calculate_sleep_regularity_proxy,
            'focus_cat_körper_weight': lambda pid: scores.calculate_category_weight(pid, 'körper'),
            'focus_cat_ernährung_weight': lambda pid: scores.calculate_category_weight(pid, 'ernährung'),
            'focus_cat_aktivität_weight': lambda pid: scores.calculate_category_weight(pid, 'aktivität'),
            'focus_cat_recovery_weight': lambda pid: scores.calculate_category_weight(pid, 'recovery'),
            'focus_cat_vitalwerte_weight': lambda pid: scores.calculate_category_weight(pid, 'vitalwerte'),
            'focus_cat_mental_weight': lambda pid: scores.calculate_category_weight(pid, 'mental'),
            'focus_cat_lebensstil_weight': lambda pid: scores.calculate_category_weight(pid, 'lebensstil'),
        }

        func = func_map.get(func_name)
        if not func:
            return pv_unavailable(
                "Ungültiger Platzhalter (keine Float-Berechnung registriert)",
                func_name,
            )

        result = func(profile_id)
        if result is None:
            return pv_unavailable(
                _SAFE_FLOAT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE),
            )
        return f"{result:.{decimals}f}"
    except Exception as e:
        print(f"[ERROR] _safe_float({func_name}, {profile_id}): {type(e).__name__}: {e}")
        traceback.print_exc()
        return pv_unavailable(
            "Berechnungsfehler bei Float-Platzhalter",
            f"{type(e).__name__}: {e}",
        )


def _safe_str(func_name: str, profile_id: str) -> str:
    """
    Safely call calculation function and return string value or fallback.
    """
    import traceback
    try:
        from data_layer import body_metrics, nutrition_metrics, activity_metrics, scores
        from data_layer import correlations as correlation_metrics

        func_map = {
            'top_goal_name': lambda pid: (scores.get_top_priority_goal(pid).get('name') or scores.get_top_priority_goal(pid).get('goal_type')) if scores.get_top_priority_goal(pid) else None,
            'top_goal_status': lambda pid: scores.get_top_priority_goal(pid)['status'] if scores.get_top_priority_goal(pid) else None,
            'top_focus_area_name': lambda pid: scores.get_top_focus_area(pid)['label'] if scores.get_top_focus_area(pid) else None,
            'recomposition_quadrant': body_metrics.calculate_recomposition_quadrant,
            'energy_deficit_surplus': nutrition_metrics.calculate_energy_deficit_surplus,
            'protein_days_in_target': nutrition_metrics.calculate_protein_days_in_target,
            'intake_volatility': nutrition_metrics.calculate_intake_volatility,
            'active_goals_md': lambda pid: _format_goals_as_markdown(pid),
            'focus_areas_weighted_md': lambda pid: _format_focus_areas_as_markdown(pid),
            'top_3_focus_areas': lambda pid: _format_top_focus_areas(pid),
            'top_3_goals_behind_schedule': lambda pid: _format_goals_behind(pid),
            'top_3_goals_on_track': lambda pid: _format_goals_on_track(pid),
        }

        func = func_map.get(func_name)
        if not func:
            return pv_unavailable(
                "Ungültiger Platzhalter (keine String-Berechnung registriert)",
                func_name,
            )

        result = func(profile_id)
        if result is None:
            return pv_unavailable(
                _SAFE_STR_NONE_REASON.get(func_name, _DEFAULT_STR_UNAVAILABLE),
            )
        return str(result)
    except Exception as e:
        print(f"[ERROR] _safe_str({func_name}, {profile_id}): {type(e).__name__}: {e}")
        traceback.print_exc()
        return pv_unavailable(
            "Berechnungsfehler bei Text-Platzhalter",
            f"{type(e).__name__}: {e}",
        )


def _safe_json(func_name: str, profile_id: str) -> str:
    """
    Safely call calculation function and return JSON string or fallback.
    """
    import traceback
    try:
        import json
        from data_layer import scores
        from data_layer import correlations as correlation_metrics

        func_map = {
            'training_sessions_recent_json': get_training_sessions_recent_weeks_data,
            'correlation_energy_weight_lag': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'energy', 'weight'),
            'correlation_protein_lbm': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'protein', 'lbm'),
            'correlation_load_hrv': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'hrv'),
            'correlation_load_rhr': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'rhr'),
            'correlation_sleep_recovery': correlation_metrics.calculate_correlation_sleep_recovery,
            'plateau_detected': correlation_metrics.calculate_plateau_detected,
            'top_drivers': correlation_metrics.calculate_top_drivers,
            'active_goals_json': lambda pid: _get_active_goals_json(pid),
            'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid),
            'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False),
        }

        func = func_map.get(func_name)
        if not func:
            return pv_unavailable_json(
                "Ungültiger Platzhalter (keine JSON-Berechnung registriert)",
                func_name,
            )

        result = func(profile_id)
        if result is None:
            return pv_unavailable_json(
                _SAFE_JSON_NONE_REASON.get(func_name, _DEFAULT_JSON_UNAVAILABLE),
            )

        # If already string, return it; otherwise convert to JSON
        if isinstance(result, str):
            return result
        else:
            return json.dumps(result, ensure_ascii=False, default=str)
    except Exception as e:
        print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}")
        traceback.print_exc()
        return pv_unavailable_json(
            "Berechnungsfehler bei JSON-Platzhalter",
            f"{type(e).__name__}: {e}",
        )


def _get_active_goals_json(profile_id: str) -> str:
    """Get active goals as JSON string"""
    import json
    try:
        from goal_utils import get_active_goals
        goals = get_active_goals(profile_id)
        return json.dumps(goals, default=str)
    except Exception:
        return '[]'


def _get_focus_areas_weighted_json(profile_id: str) -> str:
    """Get focus areas with weights as JSON string"""
    import json
    try:
        from calculations.scores import get_user_focus_weights
        from goal_utils import get_db, get_cursor

        weights = get_user_focus_weights(profile_id)

        # Get focus area details
        with get_db() as conn:
            cur = get_cursor(conn)
            cur.execute("""
                SELECT key, name_de, name_en, category
                FROM focus_area_definitions
                WHERE is_active = true
            """)
            definitions = {row['key']: row for row in cur.fetchall()}

        # Build weighted list
        result = []
        for area_key, weight in weights.items():
            if weight > 0 and area_key in definitions:
                area = definitions[area_key]
                result.append({
                    'key': area_key,
                    'name': area['name_de'],
                    'category': area['category'],
                    'weight': weight
                })

        # Sort by weight descending
        result.sort(key=lambda x: x['weight'], reverse=True)

        return json.dumps(result, default=str)
    except Exception:
        return '[]'


def _format_goals_as_markdown(profile_id: str) -> str:
    """Format goals as markdown table"""
    try:
        from goal_utils import get_active_goals

        goals = get_active_goals(profile_id)
        if not goals:
            return 'Keine Ziele definiert'

        # Build markdown table
        lines = ['| Ziel | Aktuell | Ziel | Progress | Status |', '|------|---------|------|----------|--------|']

        for goal in goals:
            name = goal.get('name') or goal.get('goal_type', 'Unbekannt')
            current = goal.get('current_value')
            target = goal.get('target_value')
            start = goal.get('start_value')

            # Calculate progress if possible
            progress_str = '-'
            if None not in [current, target, start]:
                try:
                    current_f = float(current)
                    target_f = float(target)
                    start_f = float(start)

                    if target_f == start_f:
                        progress_pct = 100 if current_f == target_f else 0
                    else:
                        progress_pct = ((current_f - start_f) / (target_f - start_f)) * 100
                        progress_pct = max(0, min(100, progress_pct))

                    progress_str = f"{int(progress_pct)}%"
                except (ValueError, ZeroDivisionError):
                    progress_str = '-'

            current_str = f"{current}" if current is not None else '-'
            target_str = f"{target}" if target is not None else '-'
            status = '🎯' if goal.get('is_primary') else '○'

            lines.append(f"| {name} | {current_str} | {target_str} | {progress_str} | {status} |")

        return '\n'.join(lines)
    except Exception:
        return 'Keine Ziele definiert'


def _format_focus_areas_as_markdown(profile_id: str) -> str:
    """Format focus areas as markdown"""
    try:
        import json
        weighted_json = _get_focus_areas_weighted_json(profile_id)
        areas = json.loads(weighted_json)

        if not areas:
            return 'Keine Focus Areas aktiv'

        # Build markdown list
        lines = []
        for area in areas:
            name = area.get('name', 'Unbekannt')
            weight = area.get('weight', 0)
            lines.append(f"- **{name}**: {weight}%")

        return '\n'.join(lines)
    except Exception:
        return 'Keine Focus Areas aktiv'


def _format_top_focus_areas(profile_id: str, n: int = 3) -> str:
    """Format top N focus areas as text"""
    try:
        import json
        weighted_json = _get_focus_areas_weighted_json(profile_id)
        areas = json.loads(weighted_json)

        if not areas:
            return 'Keine Focus Areas definiert'

        # Sort by weight descending and take top N
        sorted_areas = sorted(areas, key=lambda x: x.get('weight', 0), reverse=True)[:n]

        lines = []
        for i, area in enumerate(sorted_areas, 1):
            name = area.get('name', 'Unbekannt')
            weight = area.get('weight', 0)
            lines.append(f"{i}. {name} ({weight}%)")

        return ', '.join(lines)
    except Exception as e:
        return pv_unavailable(
            "Top-Fokusbereiche nicht darstellbar",
            f"{type(e).__name__}: {e}",
        )


def _format_goals_behind(profile_id: str, n: int = 3) -> str:
    """
    Format top N goals behind schedule (based on time deviation).

    Compares actual progress vs. expected progress based on elapsed time.
    Negative deviation = behind schedule.
    """
    try:
        from goal_utils import get_active_goals
        from datetime import date

        goals = get_active_goals(profile_id)

        if not goals:
            return 'Keine Ziele definiert'

        today = date.today()
        goals_with_deviation = []


        for g in goals:
            goal_name = g.get('name') or g.get('goal_type', 'Unknown')
            current = g.get('current_value')
            target = g.get('target_value')
            start = g.get('start_value')
            start_date = g.get('start_date')
            target_date = g.get('target_date')


            # Skip if missing required values
            if None in [current, target, start]:
                continue

            # Skip if no target_date (can't calculate time-based progress)
            if not target_date:
                continue

            try:
                current = float(current)
                target = float(target)
                start = float(start)

                # Calculate actual progress percentage
                if target == start:
                    actual_progress_pct = 100 if current == target else 0
                else:
                    actual_progress_pct = ((current - start) / (target - start)) * 100
                    actual_progress_pct = max(0, min(100, actual_progress_pct))

                # Calculate expected progress based on time
                if start_date:
                    # Use start_date if available
                    start_dt = start_date if isinstance(start_date, date) else date.fromisoformat(str(start_date))
                else:
                    # Fallback: assume start date = created_at date
                    created_at = g.get('created_at')
                    if created_at:
                        start_dt = date.fromisoformat(str(created_at).split('T')[0])
                    else:
                        continue  # Can't calculate without start date

                target_dt = target_date if isinstance(target_date, date) else date.fromisoformat(str(target_date))

                # Calculate time progress
                total_days = (target_dt - start_dt).days
                elapsed_days = (today - start_dt).days

                if total_days <= 0:
                    continue  # Invalid date range

                expected_progress_pct = (elapsed_days / total_days) * 100
                expected_progress_pct = max(0, min(100, expected_progress_pct))

                # Calculate deviation (negative = behind schedule)
                deviation = actual_progress_pct - expected_progress_pct

                g['_actual_progress'] = int(actual_progress_pct)
                g['_expected_progress'] = int(expected_progress_pct)
                g['_deviation'] = int(deviation)
                goals_with_deviation.append(g)

            except (ValueError, ZeroDivisionError, TypeError) as e:
                continue

        # Also process goals WITHOUT target_date (simple progress)
        goals_without_date = []

        for g in goals:
            if g.get('target_date'):
                continue  # Already processed above

            goal_name = g.get('name') or g.get('goal_type', 'Unknown')
            current = g.get('current_value')
            target = g.get('target_value')
            start = g.get('start_value')

            if None in [current, target, start]:
                continue

            try:
                current = float(current)
                target = float(target)
                start = float(start)

                if target == start:
                    progress_pct = 100 if current == target else 0
                else:
                    progress_pct = ((current - start) / (target - start)) * 100
                    progress_pct = max(0, min(100, progress_pct))

                g['_simple_progress'] = int(progress_pct)
                goals_without_date.append(g)
            except (ValueError, ZeroDivisionError, TypeError) as e:
                continue

        # Combine: Goals with negative deviation + Goals without date with low progress
        behind_with_date = [g for g in goals_with_deviation if g['_deviation'] < 0]
        behind_without_date = [g for g in goals_without_date if g['_simple_progress'] < 50]


        # Create combined list with sort keys
        combined = []
        for g in behind_with_date:
            combined.append({
                'goal': g,
                'sort_key': g['_deviation'],  # Negative deviation (worst first)
                'has_date': True
            })
        for g in behind_without_date:
            # Map progress to deviation-like scale: 0% = -100, 50% = -50
            combined.append({
                'goal': g,
                'sort_key': g['_simple_progress'] - 100,  # Convert to negative scale
                'has_date': False
            })

        if not combined:
            return 'Alle Ziele im Zeitplan oder erreicht'

        # Sort by sort_key (most negative first)
        sorted_combined = sorted(combined, key=lambda x: x['sort_key'])[:n]

        lines = []
        for item in sorted_combined:
            g = item['goal']
            name = g.get('name') or g.get('goal_type', 'Unbekannt')

            if item['has_date']:
                actual = g['_actual_progress']
                expected = g['_expected_progress']
                deviation = g['_deviation']
                lines.append(f"{name} ({actual}% statt {expected}%, {deviation}%)")
            else:
                progress = g['_simple_progress']
                lines.append(f"{name} ({progress}% erreicht)")

        return ', '.join(lines)
    except Exception as e:
        print(f"[ERROR] _format_goals_behind: {e}")
        import traceback
        traceback.print_exc()
        return pv_unavailable(
            "Ziele „hinter Zeitplan“ nicht darstellbar",
            f"{type(e).__name__}: {e}",
        )


def _format_goals_on_track(profile_id: str, n: int = 3) -> str:
    """
    Format top N goals ahead of schedule (based on time deviation).

    Compares actual progress vs. expected progress based on elapsed time.
    Positive deviation = ahead of schedule / on track.
    """
    try:
        from goal_utils import get_active_goals
        from datetime import date

        goals = get_active_goals(profile_id)

        if not goals:
            return 'Keine Ziele definiert'

        today = date.today()
        goals_with_deviation = []


        for g in goals:
            goal_name = g.get('name') or g.get('goal_type', 'Unknown')
            current = g.get('current_value')
            target = g.get('target_value')
            start = g.get('start_value')
            start_date = g.get('start_date')
            target_date = g.get('target_date')


            # Skip if missing required values
            if None in [current, target, start]:
                continue

            # Skip if no target_date
            if not target_date:
                continue

            try:
                current = float(current)
                target = float(target)
                start = float(start)

                # Calculate actual progress percentage
                if target == start:
                    actual_progress_pct = 100 if current == target else 0
                else:
                    actual_progress_pct = ((current - start) / (target - start)) * 100
                    actual_progress_pct = max(0, min(100, actual_progress_pct))

                # Calculate expected progress based on time
                if start_date:
                    start_dt = start_date if isinstance(start_date, date) else date.fromisoformat(str(start_date))
                else:
                    created_at = g.get('created_at')
                    if created_at:
                        start_dt = date.fromisoformat(str(created_at).split('T')[0])
                    else:
                        continue

                target_dt = target_date if isinstance(target_date, date) else date.fromisoformat(str(target_date))

                # Calculate time progress
                total_days = (target_dt - start_dt).days
                elapsed_days = (today - start_dt).days

                if total_days <= 0:
                    continue

                expected_progress_pct = (elapsed_days / total_days) * 100
                expected_progress_pct = max(0, min(100, expected_progress_pct))

                # Calculate deviation (positive = ahead of schedule)
                deviation = actual_progress_pct - expected_progress_pct

                g['_actual_progress'] = int(actual_progress_pct)
                g['_expected_progress'] = int(expected_progress_pct)
                g['_deviation'] = int(deviation)
                goals_with_deviation.append(g)

            except (ValueError, ZeroDivisionError, TypeError) as e:
                continue

        # Also process goals WITHOUT target_date (simple progress)
        goals_without_date = []

        for g in goals:
            if g.get('target_date'):
                continue  # Already processed above

            goal_name = g.get('name') or g.get('goal_type', 'Unknown')
            current = g.get('current_value')
            target = g.get('target_value')
            start = g.get('start_value')

            if None in [current, target, start]:
                continue

            try:
                current = float(current)
                target = float(target)
                start = float(start)

                if target == start:
                    progress_pct = 100 if current == target else 0
                else:
                    progress_pct = ((current - start) / (target - start)) * 100
                    progress_pct = max(0, min(100, progress_pct))

                g['_simple_progress'] = int(progress_pct)
                goals_without_date.append(g)
            except (ValueError, ZeroDivisionError, TypeError) as e:
                continue

        # Combine: Goals with positive deviation + Goals without date with high progress
        ahead_with_date = [g for g in goals_with_deviation if g['_deviation'] >= 0]
        ahead_without_date = [g for g in goals_without_date if g['_simple_progress'] >= 50]


        # Create combined list with sort keys
        combined = []
        for g in ahead_with_date:
            combined.append({
                'goal': g,
                'sort_key': g['_deviation'],  # Positive deviation (best first)
                'has_date': True
            })
        for g in ahead_without_date:
            # Map progress to deviation-like scale: 50% = 0, 100% = +50
            combined.append({
                'goal': g,
                'sort_key': g['_simple_progress'] - 50,  # Convert to positive scale
                'has_date': False
            })

        if not combined:
            return 'Keine Ziele im Zeitplan'

        # Sort by sort_key descending (most positive first)
        sorted_combined = sorted(combined, key=lambda x: x['sort_key'], reverse=True)[:n]

        lines = []
        for item in sorted_combined:
            g = item['goal']
            name = g.get('name') or g.get('goal_type', 'Unbekannt')

            if item['has_date']:
                actual = g['_actual_progress']
                deviation = g['_deviation']
                lines.append(f"{name} ({actual}%, +{deviation}% voraus)")
            else:
                progress = g['_simple_progress']
                lines.append(f"{name} ({progress}% erreicht)")

        return ', '.join(lines)
    except Exception as e:
        print(f"[ERROR] _format_goals_on_track: {e}")
        import traceback
        traceback.print_exc()
        return pv_unavailable(
            "Ziele „im Plan“ nicht darstellbar",
            f"{type(e).__name__}: {e}",
        )


# ── Placeholder Registry ──────────────────────────────────────────────────────

PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
    # Profil
    '{{name}}': get_profile_name,
    '{{age}}': get_profile_age_display,
    '{{height}}': get_profile_height_display,
    '{{geschlecht}}': get_profile_geschlecht_display,

    # Körper (21 Registry-Keys: body_metrics + body_extras — alles hier gebündelt)
    '{{weight_aktuell}}': get_latest_weight,
    '{{weight_trend}}': get_weight_trend,
    '{{kf_aktuell}}': get_latest_bf,
    '{{bmi}}': lambda pid: calculate_bmi(pid),
    '{{caliper_summary}}': get_caliper_summary,
    '{{circ_summary}}': get_circ_summary,
    '{{goal_weight}}': get_goal_weight,
    '{{goal_bf_pct}}': get_goal_bf_pct,
    '{{weight_7d_median}}': lambda pid: _safe_float('weight_7d_median', pid),
    '{{weight_28d_slope}}': lambda pid: _safe_float('weight_28d_slope', pid, decimals=4),
    '{{weight_90d_slope}}': lambda pid: _safe_float('weight_90d_slope', pid, decimals=4),
    '{{fm_28d_change}}': lambda pid: _safe_float('fm_28d_change', pid),
    '{{lbm_28d_change}}': lambda pid: _safe_float('lbm_28d_change', pid),
    '{{waist_28d_delta}}': lambda pid: _safe_float('waist_28d_delta', pid),
    '{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid),
    '{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid),
    '{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
    '{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid),
    '{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3),
    '{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
    '{{body_progress_score}}': lambda pid: _safe_int('body_progress_score', pid),

    # Ernährung (15 Registry-Keys — gebündelt; nutrition_score siehe hier, nicht unter Meta Scores)
    '{{kcal_avg}}': lambda pid: get_nutrition_avg(pid, 'kcal', 30),
    '{{protein_avg}}': lambda pid: get_nutrition_avg(pid, 'protein', 30),
    '{{carb_avg}}': lambda pid: get_nutrition_avg(pid, 'carb', 30),
    '{{fat_avg}}': lambda pid: get_nutrition_avg(pid, 'fat', 30),
    '{{nutrition_days}}': lambda pid: get_nutrition_days(pid, 30),
    '{{protein_ziel_low}}': get_protein_ziel_low,
    '{{protein_ziel_high}}': get_protein_ziel_high,
    '{{energy_balance_7d}}': lambda pid: _safe_float('energy_balance_7d', pid, decimals=0),
    '{{energy_deficit_surplus}}': lambda pid: _safe_str('energy_deficit_surplus', pid),
    '{{protein_g_per_kg}}': lambda pid: _safe_float('protein_g_per_kg', pid),
    '{{protein_days_in_target}}': lambda pid: _safe_str('protein_days_in_target', pid),
    '{{protein_adequacy_28d}}': lambda pid: _safe_int('protein_adequacy_28d', pid),
    '{{macro_consistency_score}}': lambda pid: _safe_int('macro_consistency_score', pid),
    '{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
    '{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),

    # Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores)
    '{{activity_summary}}': get_activity_summary,
    '{{activity_detail}}': get_activity_detail,
    '{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
    '{{training_minutes_week}}': lambda pid: _safe_int('training_minutes_week', pid),
    '{{training_frequency_7d}}': lambda pid: _safe_int('training_frequency_7d', pid),
    '{{quality_sessions_pct}}': lambda pid: _safe_int('quality_sessions_pct', pid),
    '{{ability_balance_strength}}': lambda pid: _safe_int('ability_balance_strength', pid),
    '{{ability_balance_endurance}}': lambda pid: _safe_int('ability_balance_endurance', pid),
    '{{ability_balance_mental}}': lambda pid: _safe_int('ability_balance_mental', pid),
    '{{ability_balance_coordination}}': lambda pid: _safe_int('ability_balance_coordination', pid),
    '{{ability_balance_mobility}}': lambda pid: _safe_int('ability_balance_mobility', pid),
    '{{proxy_internal_load_7d}}': lambda pid: _safe_int('proxy_internal_load_7d', pid),
    '{{monotony_score}}': lambda pid: _safe_float('monotony_score', pid),
    '{{strain_score}}': lambda pid: _safe_int('strain_score', pid),
    '{{rest_day_compliance}}': lambda pid: _safe_int('rest_day_compliance', pid),
    '{{vo2max_trend_28d}}': lambda pid: _safe_float('vo2max_trend_28d', pid),
    '{{activity_score}}': lambda pid: _safe_int('activity_score', pid),
    '{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
    '{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
    '{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),

    # Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
    '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
    '{{sleep_avg_quality}}': lambda pid: get_sleep_avg_quality(pid, 7),
    '{{rest_days_count}}': lambda pid: get_rest_days_count(pid, 30),
    '{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid),
    '{{sleep_avg_duration_7d}}': lambda pid: _safe_float('sleep_avg_duration_7d', pid),
    '{{sleep_debt_hours}}': lambda pid: _safe_float('sleep_debt_hours', pid),
    '{{sleep_regularity_proxy}}': lambda pid: _safe_float('sleep_regularity_proxy', pid),
    '{{recent_load_balance_3d}}': lambda pid: _safe_int('recent_load_balance_3d', pid),
    '{{sleep_quality_7d}}': lambda pid: _safe_int('sleep_quality_7d', pid),
    '{{correlation_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid),

    # Vitalwerte (5 Registry-Keys: Mittelwerte + vs. Baseline)
    '{{vitals_avg_hr}}': lambda pid: get_vitals_avg_hr(pid, 7),
    '{{vitals_avg_hrv}}': lambda pid: get_vitals_avg_hrv(pid, 7),
    '{{vitals_vo2_max}}': get_vitals_vo2_max,
    '{{hrv_vs_baseline_pct}}': lambda pid: _safe_float('hrv_vs_baseline_pct', pid),
    '{{rhr_vs_baseline_pct}}': lambda pid: _safe_float('rhr_vs_baseline_pct', pid),

    # Zeitraum
    '{{datum_heute}}': get_datum_heute,
    '{{zeitraum_7d}}': get_zeitraum_label_7d,
    '{{zeitraum_30d}}': get_zeitraum_label_30d,
    '{{zeitraum_90d}}': get_zeitraum_label_90d,

    # ========================================================================
    # PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0)
    # ========================================================================

    # --- Meta Scores (Ebene 1; recovery_score → Schlaf & Erholung) ---
    '{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', pid),
    '{{data_quality_score}}': lambda pid: _safe_int('data_quality_score', pid),

    # --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) ---
    '{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid),
    '{{top_goal_progress_pct}}': lambda pid: _safe_int('top_goal_progress_pct', pid),
    '{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid),
    '{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid),
    '{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid),

    # --- Category Scores (Ebene 3: 7 Kategorien) ---
    '{{focus_cat_körper_progress}}': lambda pid: _safe_int('focus_cat_körper_progress', pid),
    '{{focus_cat_körper_weight}}': lambda pid: _safe_float('focus_cat_körper_weight', pid),
    '{{focus_cat_ernährung_progress}}': lambda pid: _safe_int('focus_cat_ernährung_progress', pid),
    '{{focus_cat_ernährung_weight}}': lambda pid: _safe_float('focus_cat_ernährung_weight', pid),
    '{{focus_cat_aktivität_progress}}': lambda pid: _safe_int('focus_cat_aktivität_progress', pid),
    '{{focus_cat_aktivität_weight}}': lambda pid: _safe_float('focus_cat_aktivität_weight', pid),
    '{{focus_cat_recovery_progress}}': lambda pid: _safe_int('focus_cat_recovery_progress', pid),
    '{{focus_cat_recovery_weight}}': lambda pid: _safe_float('focus_cat_recovery_weight', pid),
    '{{focus_cat_vitalwerte_progress}}': lambda pid: _safe_int('focus_cat_vitalwerte_progress', pid),
    '{{focus_cat_vitalwerte_weight}}': lambda pid: _safe_float('focus_cat_vitalwerte_weight', pid),
    '{{focus_cat_mental_progress}}': lambda pid: _safe_int('focus_cat_mental_progress', pid),
    '{{focus_cat_mental_weight}}': lambda pid: _safe_float('focus_cat_mental_weight', pid),
    '{{focus_cat_lebensstil_progress}}': lambda pid: _safe_int('focus_cat_lebensstil_progress', pid),
    '{{focus_cat_lebensstil_weight}}': lambda pid: _safe_float('focus_cat_lebensstil_weight', pid),

    # --- Correlation Metrics (C1-C7) ---
    '{{correlation_energy_weight_lag}}': lambda pid: _safe_json('correlation_energy_weight_lag', pid),
    '{{correlation_protein_lbm}}': lambda pid: _safe_json('correlation_protein_lbm', pid),
    '{{correlation_load_hrv}}': lambda pid: _safe_json('correlation_load_hrv', pid),
    '{{correlation_load_rhr}}': lambda pid: _safe_json('correlation_load_rhr', pid),
    '{{plateau_detected}}': lambda pid: _safe_json('plateau_detected', pid),
    '{{top_drivers}}': lambda pid: _safe_json('top_drivers', pid),

    # --- JSON/Markdown Structured Data (Ebene 5) ---
    '{{active_goals_json}}': lambda pid: _safe_json('active_goals_json', pid),
    '{{active_goals_md}}': lambda pid: _safe_str('active_goals_md', pid),
    '{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid),
    '{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid),
    '{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid),
    '{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid),
    '{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid),
    '{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid),
}


def calculate_bmi(profile_id: str) -> str:
    """BMI für Prompts; Berechnung in data_layer.body_metrics.get_bmi_data."""
    data = get_bmi_data(profile_id)
    bmi = data.get("bmi")
    if bmi is None:
        return pv_unavailable(
            "BMI nicht berechenbar",
            f"confidence={data.get('confidence')}; benötigt Profil-Größe (cm) und letztes Gewicht "
            f"(weight_log); height_cm={data.get('height_cm')}, weight_kg={data.get('weight_kg')}",
        )
    return f"{bmi:.1f}"


# ── Public API ────────────────────────────────────────────────────────────────

def resolve_placeholders(template: str, profile_id: str) -> str:
    """
    Replace all {{placeholders}} in template with actual user data.

    Unterstützt Modifier wie bei der Prompt-Pipeline:
    - {{fat_avg}} — nur Wert
    - {{fat_avg|d}} — Wert — description (kurz, token-sparend)
    - {{fat_avg|x}} — nur Erklärung (business_meaning / semantic_contract, ggf. Score-Skala), ohne Wert
    - {{fat_avg|d,x}} — Wert — description — Erklärung

    Args:
        template: Prompt template with placeholders
        profile_id: User profile ID

    Returns:
        Resolved template with placeholders replaced by values
    """

    def _repl(match: re.Match) -> str:
        key = match.group(1)
        modifiers_raw = (match.group(2) or "").strip()
        mods = {x.strip().lower() for x in modifiers_raw.split(",") if x.strip()}
        ph = f"{{{{{key}}}}}"
        resolver = PLACEHOLDER_MAP.get(ph)
        if not resolver:
            return match.group(0)
        try:
            value = str(resolver(profile_id))
        except Exception:
            return f"[Fehler: {ph}]"

        want_d = "d" in mods
        want_x = "x" in mods

        if want_x and not want_d:
            expl = _explain_for_registry_key(key)
            return expl if expl else ""

        if not want_d and not want_x:
            return value

        parts: List[str] = [value]
        if want_d:
            desc = _description_for_registry_key(key)
            if desc:
                parts.append(desc)
        if want_x:
            expl = _explain_for_registry_key(key)
            if expl:
                parts.append(expl)
        return " — ".join(parts)

    return _PLACEHOLDER_TOKEN_RE.sub(_repl, template)


def get_unknown_placeholders(template: str) -> List[str]:
    """
    Find all placeholders in template that are not in PLACEHOLDER_MAP.

    Args:
        template: Prompt template

    Returns:
        List of unknown placeholder names (without {{}})
    """
    found = _PLACEHOLDER_TOKEN_RE.findall(template)
    known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()}
    unknown = [key for key, _ in found if key not in known_names]

    return list(set(unknown))  # Remove duplicates


def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[str, List[str]]:
    """
    Get available placeholders, optionally filtered by categories.

    Args:
        categories: Optional list of categories to filter (körper, ernährung, training, etc.)

    Returns:
        Dict mapping category to list of placeholders
    """
    placeholder_categories = {
        'profil': [
            '{{name}}', '{{age}}', '{{height}}', '{{geschlecht}}'
        ],
        'körper': [
            '{{weight_aktuell}}', '{{weight_trend}}', '{{kf_aktuell}}', '{{bmi}}',
            '{{caliper_summary}}', '{{circ_summary}}',
            '{{goal_weight}}', '{{goal_bf_pct}}',
            '{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}',
            '{{fm_28d_change}}', '{{lbm_28d_change}}',
            '{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}',
            '{{arm_28d_delta}}', '{{thigh_28d_delta}}',
            '{{waist_hip_ratio}}', '{{recomposition_quadrant}}',
            '{{body_progress_score}}',
        ],
        'ernährung': [
            '{{kcal_avg}}', '{{protein_avg}}', '{{carb_avg}}', '{{fat_avg}}',
            '{{nutrition_days}}', '{{protein_ziel_low}}', '{{protein_ziel_high}}',
            '{{energy_balance_7d}}', '{{energy_deficit_surplus}}',
            '{{protein_g_per_kg}}', '{{protein_days_in_target}}', '{{protein_adequacy_28d}}',
            '{{macro_consistency_score}}', '{{intake_volatility}}', '{{nutrition_score}}',
        ],
        'training': [
            '{{activity_summary}}', '{{activity_detail}}', '{{trainingstyp_verteilung}}',
            '{{training_minutes_week}}', '{{training_frequency_7d}}', '{{quality_sessions_pct}}',
            '{{ability_balance_strength}}', '{{ability_balance_endurance}}', '{{ability_balance_mental}}',
            '{{ability_balance_coordination}}', '{{ability_balance_mobility}}',
            '{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
            '{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
            '{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
        ],
        'schlaf': [
            '{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
            '{{recovery_score}}',
            '{{sleep_avg_duration_7d}}', '{{sleep_debt_hours}}', '{{sleep_regularity_proxy}}',
            '{{recent_load_balance_3d}}', '{{sleep_quality_7d}}',
            '{{correlation_sleep_recovery}}',
        ],
        'vitalwerte': [
            '{{vitals_avg_hr}}', '{{vitals_avg_hrv}}', '{{vitals_vo2_max}}',
            '{{hrv_vs_baseline_pct}}', '{{rhr_vs_baseline_pct}}',
        ],
        'zeitraum': [
            '{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
        ],
        'phase0b_meta': [
            '{{goal_progress_score}}', '{{data_quality_score}}',
        ],
        'ziele_fokus': [
            '{{top_goal_name}}', '{{top_goal_progress_pct}}', '{{top_goal_status}}',
            '{{top_focus_area_name}}', '{{top_focus_area_progress}}',
            '{{focus_cat_körper_progress}}', '{{focus_cat_körper_weight}}',
            '{{focus_cat_ernährung_progress}}', '{{focus_cat_ernährung_weight}}',
            '{{focus_cat_aktivität_progress}}', '{{focus_cat_aktivität_weight}}',
            '{{focus_cat_recovery_progress}}', '{{focus_cat_recovery_weight}}',
            '{{focus_cat_vitalwerte_progress}}', '{{focus_cat_vitalwerte_weight}}',
            '{{focus_cat_mental_progress}}', '{{focus_cat_mental_weight}}',
            '{{focus_cat_lebensstil_progress}}', '{{focus_cat_lebensstil_weight}}',
            '{{active_goals_json}}', '{{active_goals_md}}',
            '{{focus_areas_weighted_json}}', '{{focus_areas_weighted_md}}', '{{focus_area_weights_json}}',
            '{{top_3_focus_areas}}', '{{top_3_goals_behind_schedule}}', '{{top_3_goals_on_track}}',
        ],
        'korrelationen': [
            '{{correlation_energy_weight_lag}}', '{{correlation_protein_lbm}}',
            '{{correlation_load_hrv}}', '{{correlation_load_rhr}}',
            '{{plateau_detected}}', '{{top_drivers}}',
        ],
    }

    if not categories:
        return placeholder_categories

    # Filter to requested categories
    return {k: v for k, v in placeholder_categories.items() if k in categories}


def get_placeholder_example_values(profile_id: str) -> Dict[str, str]:
    """
    Get example values for all placeholders using real user data.

    Args:
        profile_id: User profile ID

    Returns:
        Dict mapping placeholder to example value
    """
    examples = {}

    for placeholder, resolver in PLACEHOLDER_MAP.items():
        try:
            examples[placeholder] = resolver(profile_id)
        except Exception as e:
            examples[placeholder] = f"[Fehler: {str(e)}]"

    return examples


def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]:
    """
    Get grouped placeholder catalog with descriptions and example values.

    Uses the Placeholder Registry as single source of truth.
    Falls back to hardcoded legacy placeholders for non-registry items.

    Args:
        profile_id: User profile ID

    Returns:
        Dict mapping category to list of {key, description, example}
    """
    from placeholder_registry import get_registry

    catalog = {}

    # Get all registered placeholders from Registry
    registry = get_registry()
    all_registered = registry.get_all()

    # Group registry placeholders by category
    for key, metadata in all_registered.items():
        category = metadata.category
        if category not in catalog:
            catalog[category] = []

        # Try to resolve value
        try:
            if metadata._resolver_func:
                example = metadata._resolver_func(profile_id)
            else:
                # Fallback to PLACEHOLDER_MAP
                placeholder = f'{{{{{key}}}}}'
                resolver = PLACEHOLDER_MAP.get(placeholder)
                if resolver:
                    example = resolver(profile_id)
                else:
                    example = '[Nicht implementiert]'
        except Exception as e:
            example = '[Nicht verfügbar]'

        catalog[category].append({
            'key': key,
            'description': metadata.description,
            'example': str(example),
            'ai_caption': build_ai_placeholder_caption(metadata),
        })

    # Legacy placeholders (not in registry yet)
    legacy_placeholders: Dict[str, List[Tuple[str, str]]] = {}

    # Add legacy placeholders (skip if already in registry)
    for category, items in legacy_placeholders.items():
        if category not in catalog:
            catalog[category] = []

        for key, description in items:
            # Skip if already added from registry
            if any(p['key'] == key for p in catalog[category]):
                continue

            placeholder = f'{{{{{key}}}}}'
            resolver = PLACEHOLDER_MAP.get(placeholder)
            if resolver:
                try:
                    example = resolver(profile_id)
                except Exception:
                    example = '[Nicht verfügbar]'
            else:
                example = '[Nicht implementiert]'

            catalog[category].append({
                'key': key,
                'description': description,
                'example': str(example),
                'ai_caption': '',
            })

    # Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet
    # This ensures PlaceholderPicker shows all 111+ placeholders, not just registry ones
    all_categorized_keys = set()
    for items in catalog.values():
        all_categorized_keys.update(p['key'] for p in items)

    sonstige_category = 'Sonstiges'
    if sonstige_category not in catalog:
        catalog[sonstige_category] = []

    for placeholder, resolver in PLACEHOLDER_MAP.items():
        # Extract key from {{key}}
        key = placeholder.replace('{{', '').replace('}}', '')

        # Skip if already categorized
        if key in all_categorized_keys:
            continue

        try:
            example = resolver(profile_id)
        except Exception:
            example = '[Nicht verfügbar]'

        catalog[sonstige_category].append({
            'key': key,
            'description': f'Platzhalter: {key}',  # Generic description
            'example': str(example),
            'ai_caption': f'Platzhalter {key} (noch ohne erweiterte Registry-Beschreibung).',
        })

    return catalog
