#!/usr/bin/env python3
"""WODWatts calculator v0.1 - estimate mechanical work (J) and average power (W)
for mixed-modal workouts (CrossFit, strength & conditioning).

Model: every rep = sum of (mass x g x vertical rise). Body distances are fractions
of height H (Drillis & Contini); body-segment masses are fractions of body mass M
(Clauser et al. 1969). Only upward (concentric) work is counted.

Usage:
    python3 wodwatts.py workout.json          # score a workout
    python3 wodwatts.py --list                # list movements
    python3 wodwatts.py --selftest            # check reference values

Workout JSON:
{
  "athlete": {"height_cm": 175, "weight_kg": 68},      # or height_in / weight_lb
  "time": "5:00",                                      # or "time_seconds": 300
  "rounds": 1,                                         # optional, multiplies items
  "items": [
    {"movement": "thruster", "reps": [21, 15, 9], "load_kg": 43}, # or load_lb
    {"movement": "pull-up", "reps": [21, 15, 9]},
    {"movement": "row", "distance_m": 500, "time": "1:45"},
    {"movement": "run", "distance_m": 400},
    {"movement": "box jump", "reps": 20, "box_in": 24}
  ]
}
"""
import json
import sys

G = 9.81
VERSION = "0.1"

# --- Anthropometric constants -------------------------------------------------
# Distances: fraction of height H
D_ARM = 0.186 + 0.146          # 0.332  upper arm + forearm: press / pull-up stroke
D_SQUAT = 0.530 - 0.285        # 0.245  hip travel in a full squat
D_TORSO = 0.470 - 0.130        # 0.340  hip to chin
H_RACK = 0.818                 # shoulder / front-rack height
H_OVERHEAD = H_RACK + D_ARM    # 1.150  overhead lockout
H_HANG = 0.40                  # ASSUMPTION: bar height in hang position
H_KNUCKLE = 0.43               # ASSUMPTION: bar height at deadlift lockout
H_KB_LOW = 0.30                # ASSUMPTION: kettlebell low point in a swing
BAR_FLOOR_M = 0.225            # bar centre on 450 mm bumper plates (metres)
IMPLEMENT_FLOOR_M = 0.15       # ASSUMPTION: ball / dumbbell centre on floor (metres)

# Masses: fraction of body mass M (Clauser et al. 1969, approximate)
SEG = {"head": 0.073, "trunk": 0.507, "upper_arm": 0.026, "forearm": 0.016,
       "hand": 0.007, "thigh": 0.103, "shank": 0.043, "foot": 0.015}
F_ABOVE_KNEE = 1 - 2 * (SEG["shank"] + SEG["foot"])                       # 0.884
F_UPPER = SEG["head"] + SEG["trunk"] + 2 * (SEG["upper_arm"] + SEG["forearm"] + SEG["hand"])  # 0.678
F_LEGS = 2 * (SEG["thigh"] + SEG["shank"] + SEG["foot"])                   # 0.322
F_PUSHUP = 0.75                # ASSUMPTION: effective load at hands in a push-up

# Other assumptions
HINGE_RISE = 0.10              # ASSUMPTION: body rise standing up from a floor-pull start (x H)
SWING_HINGE_RISE = 0.08        # ASSUMPTION: upper-body rise in a KB swing (x H)
DU_HEIGHT_M = 0.12             # ASSUMPTION: double-under jump height
SU_HEIGHT_M = 0.05             # ASSUMPTION: single-under jump height
BURPEE_RISE = 0.45             # floor-to-standing centre-of-mass rise (x H)
BURPEE_JUMP_M = 0.10           # ASSUMPTION: burpee hop
C_RUN = 4.0                    # metabolic cost of running, J/kg/m (~1 kcal/kg/km)
ETA = 0.25                     # muscular efficiency: metabolic = mechanical / ETA


# --- Primitives ----------------------------------------------------------------
# A component is a function f(H, M, L, p) -> joules for one rep.
def load(frm, to):
    """External load rises from height `frm` to `to` (callables of H, p, in metres)."""
    return lambda H, M, L, p: L * G * (to(H, p) - frm(H, p))


def body(frac, dist):
    """Fraction `frac` of body mass rises `dist(H, p)` metres."""
    return lambda H, M, L, p: frac * M * G * dist(H, p)


def xH(f):
    return lambda H, p: f * H


def m(v):
    return lambda H, p: v


ZERO = m(0.0)
SQUAT_BODY = body(F_ABOVE_KNEE, xH(D_SQUAT))
HINGE_BODY = body(F_ABOVE_KNEE, xH(HINGE_RISE))
SWING_BODY = body(F_UPPER, xH(SWING_HINGE_RISE))

# --- Movement library ------------------------------------------------------------
# Each entry: list of components and/or names of other movements (composites).
# "needs_load": True means the movement requires load_kg / load_lb.
LIB = {
    # Barbell
    "back squat":        {"parts": [load(ZERO, xH(D_SQUAT)), SQUAT_BODY], "needs_load": True},
    "front squat":       {"parts": [load(ZERO, xH(D_SQUAT)), SQUAT_BODY], "needs_load": True},
    "overhead squat":    {"parts": [load(ZERO, xH(D_SQUAT)), SQUAT_BODY], "needs_load": True},
    "strict press":      {"parts": [load(ZERO, xH(D_ARM))], "needs_load": True},
    "push press":        {"parts": ["strict press"], "needs_load": True},
    "push jerk":         {"parts": ["strict press"], "needs_load": True},
    "split jerk":        {"parts": ["strict press"], "needs_load": True},
    "bench press":       {"parts": [load(ZERO, xH(0.25))], "needs_load": True},
    "deadlift":          {"parts": [load(m(BAR_FLOOR_M), xH(H_KNUCKLE)), HINGE_BODY], "needs_load": True},
    "power clean":       {"parts": [load(m(BAR_FLOOR_M), xH(H_RACK)), HINGE_BODY], "needs_load": True},
    "squat clean":       {"parts": ["power clean", SQUAT_BODY], "needs_load": True},
    "hang power clean":  {"parts": [load(xH(H_HANG), xH(H_RACK))], "needs_load": True},
    "hang squat clean":  {"parts": ["hang power clean", SQUAT_BODY], "needs_load": True},
    "power snatch":      {"parts": [load(m(BAR_FLOOR_M), xH(H_OVERHEAD)), HINGE_BODY], "needs_load": True},
    "squat snatch":      {"parts": ["power snatch", SQUAT_BODY], "needs_load": True},
    "hang power snatch": {"parts": [load(xH(H_HANG), xH(H_OVERHEAD))], "needs_load": True},
    "hang squat snatch": {"parts": ["hang power snatch", SQUAT_BODY], "needs_load": True},
    "clean and jerk":    {"parts": ["squat clean", "split jerk"], "needs_load": True},
    "thruster":          {"parts": ["front squat", "strict press"], "needs_load": True},
    "sumo deadlift high pull": {"parts": [load(m(BAR_FLOOR_M), xH(H_RACK)), HINGE_BODY], "needs_load": True},
    # Gymnastics / bodyweight
    "air squat":         {"parts": [SQUAT_BODY]},
    "walking lunge":     {"parts": [SQUAT_BODY]},
    "pull-up":           {"parts": [body(1.0, xH(D_ARM))]},
    "jumping pull-up":   {"parts": ["pull-up"]},
    "chest-to-bar pull-up": {"parts": [body(1.0, xH(0.38))]},
    "muscle-up":         {"parts": [body(1.0, xH(D_ARM + D_TORSO))]},
    "ring dip":          {"parts": [body(1.0, xH(D_ARM))]},
    "handstand push-up": {"parts": [body(1.0, xH(D_ARM))]},
    "push-up":           {"parts": [body(F_PUSHUP, xH(D_ARM))]},
    "sit-up":            {"parts": [body(F_UPPER, xH(0.22))]},
    "ghd sit-up":        {"parts": [body(F_UPPER, xH(0.30))]},
    "knees-to-elbows":   {"parts": [body(F_LEGS, xH(0.25))]},
    "toes-to-bar":       {"parts": [body(F_LEGS, xH(0.30))]},
    "box jump":          {"parts": [body(1.0, lambda H, p: p["box_m"])], "params": {"box_m": 0.508}},
    "box step-up":       {"parts": [body(1.0, lambda H, p: p["box_m"])], "params": {"box_m": 0.508}},
    "burpee":            {"parts": ["push-up", body(1.0, xH(BURPEE_RISE)), body(1.0, m(BURPEE_JUMP_M))]},
    "rope climb":        {"parts": [body(1.0, lambda H, p: p["rope_m"])], "params": {"rope_m": 4.6}},
    "double-under":      {"parts": [body(1.0, m(DU_HEIGHT_M))]},
    "single-under":      {"parts": [body(1.0, m(SU_HEIGHT_M))]},
    # Implements (load = ball / kettlebell / dumbbell)
    "wall ball":         {"parts": [SQUAT_BODY, load(xH(H_RACK - D_SQUAT), lambda H, p: p["target_m"])],
                          "needs_load": True, "params": {"target_m": 3.05}},
    "ball slam":         {"parts": [SQUAT_BODY, load(m(IMPLEMENT_FLOOR_M), xH(H_OVERHEAD))], "needs_load": True},
    "med-ball clean":    {"parts": [SQUAT_BODY, load(m(IMPLEMENT_FLOOR_M), xH(H_RACK))], "needs_load": True},
    "kettlebell swing":  {"parts": [load(xH(H_KB_LOW), xH(H_RACK)), SWING_BODY], "needs_load": True},
    "american kettlebell swing": {"parts": [load(xH(H_KB_LOW), xH(H_OVERHEAD)), SWING_BODY], "needs_load": True},
    "dumbbell snatch":   {"parts": [load(m(IMPLEMENT_FLOOR_M), xH(H_OVERHEAD)), HINGE_BODY], "needs_load": True},
    "goblet squat":      {"parts": ["front squat"], "needs_load": True},
}
MONO = {"row", "ski", "bike", "run"}

ALIASES = {
    "pullup": "pull-up", "pull up": "pull-up", "pullups": "pull-up", "pull-ups": "pull-up",
    "c2b": "chest-to-bar pull-up", "chest to bar": "chest-to-bar pull-up",
    "pushup": "push-up", "push up": "push-up", "push-ups": "push-up",
    "hspu": "handstand push-up", "handstand pushup": "handstand push-up",
    "situp": "sit-up", "sit up": "sit-up", "abmat sit-up": "sit-up",
    "ghd": "ghd sit-up", "t2b": "toes-to-bar", "toes to bar": "toes-to-bar",
    "k2e": "knees-to-elbows", "knees to elbows": "knees-to-elbows",
    "du": "double-under", "dus": "double-under", "double under": "double-under",
    "double-unders": "double-under", "double unders": "double-under",
    "single under": "single-under", "single-unders": "single-under",
    "mu": "muscle-up", "bar muscle-up": "muscle-up", "ring muscle-up": "muscle-up",
    "muscle up": "muscle-up", "dip": "ring dip", "bar dip": "ring dip",
    "squat": "back squat", "ohs": "overhead squat", "press": "strict press",
    "shoulder press": "strict press", "jerk": "split jerk", "clean": "squat clean",
    "snatch": "squat snatch", "hpc": "hang power clean", "hps": "hang power snatch",
    "c&j": "clean and jerk", "clean & jerk": "clean and jerk", "dl": "deadlift",
    "sdhp": "sumo deadlift high pull", "wallball": "wall ball", "wall-ball": "wall ball",
    "wall ball shot": "wall ball", "kb swing": "kettlebell swing",
    "russian kettlebell swing": "kettlebell swing", "american kb swing": "american kettlebell swing",
    "db snatch": "dumbbell snatch", "lunge": "walking lunge", "lunges": "walking lunge",
    "burpees": "burpee", "box jumps": "box jump", "box jump over": "box jump",
    "rowing": "row", "rower": "row", "skierg": "ski", "ski erg": "ski", "bikeerg": "bike",
    "assault bike": "bike", "echo bike": "bike", "air bike": "bike", "running": "run",
}


def norm(name):
    n = " ".join(name.lower().replace("_", " ").split())
    if n in LIB or n in MONO:
        return n
    if n in ALIASES:
        return ALIASES[n]
    if n.endswith("s") and n[:-1] in LIB:
        return n[:-1]
    if n.endswith("s") and n[:-1] in ALIASES:
        return ALIASES[n[:-1]]
    raise KeyError(f"Unknown movement '{name}'. Use --list to see supported movements.")


def rep_joules(name, H, M, L=0.0, params=None, _seen=()):
    """Joules for one rep of `name`."""
    if name in _seen:
        raise ValueError(f"Cycle in movement definition: {name}")
    entry = LIB[name]
    p = dict(entry.get("params", {}))
    p.update(params or {})
    total = 0.0
    for part in entry["parts"]:
        if isinstance(part, str):
            total += rep_joules(part, H, M, L, p, _seen + (name,))
        else:
            total += part(H, M, L, p)
    return total


# --- Input helpers ----------------------------------------------------------------
def seconds(v):
    if v is None:
        return None
    if isinstance(v, (int, float)):
        return float(v)
    parts = [float(x) for x in str(v).split(":")]
    s = 0.0
    for x in parts:
        s = s * 60 + x
    return s


def athlete_hm(a):
    if "height_m" in a:
        H = float(a["height_m"])
    elif "height_cm" in a:
        H = float(a["height_cm"]) / 100
    elif "height_in" in a:
        H = float(a["height_in"]) * 0.0254
    else:
        raise ValueError("athlete needs height_m, height_cm or height_in")
    if "weight_kg" in a:
        M = float(a["weight_kg"])
    elif "weight_lb" in a:
        M = float(a["weight_lb"]) * 0.45359237
    else:
        raise ValueError("athlete needs weight_kg or weight_lb")
    return H, M


def item_load(it):
    if "load_kg" in it:
        return float(it["load_kg"])
    if "load_lb" in it:
        return float(it["load_lb"]) * 0.45359237
    return 0.0


def item_params(it):
    p = {}
    if "box_in" in it:
        p["box_m"] = float(it["box_in"]) * 0.0254
    if "box_m" in it:
        p["box_m"] = float(it["box_m"])
    if "target_ft" in it:
        p["target_m"] = float(it["target_ft"]) * 0.3048
    if "target_m" in it:
        p["target_m"] = float(it["target_m"])
    if "rope_ft" in it:
        p["rope_m"] = float(it["rope_ft"]) * 0.3048
    if "rope_m" in it:
        p["rope_m"] = float(it["rope_m"])
    return p


def total_reps(r):
    if isinstance(r, list):
        return sum(float(x) for x in r)
    return float(r)


def row_watts_from_pace(distance_m, t):
    pace = t / distance_m                      # seconds per metre
    return 2.80 / pace ** 3                    # Concept2 pace-to-watts formula


def mono_joules(name, it, M):
    t = seconds(it.get("time", it.get("time_seconds")))
    if "watts" in it and t:
        return float(it["watts"]) * t, f"{it['watts']} W x {t:.0f} s (machine)"
    if "calories" in it and t and name in ("row", "ski"):
        kcal_h = float(it["calories"]) / (t / 3600)
        w = max(0.0, (kcal_h - 300) / (4 * 0.8604))   # Concept2 calorie formula
        return w * t, f"{it['calories']} cal in {t:.0f} s -> {w:.0f} W (Concept2 formula)"
    if name in ("row", "ski") and "distance_m" in it and t:
        w = row_watts_from_pace(float(it["distance_m"]), t)
        return w * t, f"{it['distance_m']} m in {t:.0f} s -> {w:.0f} W (Concept2 pace formula)"
    if name == "run" and "distance_m" in it:
        d = float(it["distance_m"])
        j = ETA * C_RUN * M * d + M * G * float(it.get("elevation_m", 0))
        return j, f"{d:.0f} m run (estimate: {ETA} x {C_RUN} J/kg/m)"
    if "calories" in it:
        # Generic machine calories (bike etc.): treat as metabolic kcal, convert at ETA.
        j = float(it["calories"]) * 4184 * ETA
        return j, f"{it['calories']} cal x 4184 x {ETA} (estimate; machine formulas vary)"
    raise ValueError(f"{name}: give watts+time, calories, or distance_m (+time for row/ski)")


# --- Workout scoring ---------------------------------------------------------------
def score(workout):
    H, M = athlete_hm(workout["athlete"])
    rounds = float(workout.get("rounds", 1))
    t_total = seconds(workout.get("time", workout.get("time_seconds")))
    rows, total = [], 0.0
    for it in workout["items"]:
        name = norm(it["movement"])
        if name in MONO:
            j, note = mono_joules(name, it, M)
            j *= rounds
            reps = None
        else:
            L = item_load(it)
            if LIB[name].get("needs_load") and L == 0:
                raise ValueError(f"{it['movement']}: needs load_kg or load_lb")
            per = rep_joules(name, H, M, L, item_params(it))
            reps = total_reps(it.get("reps", 1)) * rounds
            j = per * reps
            note = f"{per:.0f} J/rep"
        rows.append({"movement": name, "reps": reps, "joules": j, "note": note})
        total += j
    out = {"version": VERSION, "height_m": round(H, 3), "body_mass_kg": round(M, 1),
           "items": rows, "total_kj": total / 1000,
           "metabolic_kcal_estimate": total / ETA / 4184}
    if t_total:
        w = total / t_total
        out.update({"time_s": t_total, "avg_watts": w, "watts_per_kg": w / M,
                    "bulb_100w_minutes": total / 100 / 60})
    return out


def report(o):
    lines = [f"WODWatts v{o['version']}  |  athlete {o['height_m']} m, {o['body_mass_kg']} kg", ""]
    lines.append(f"{'Movement':28}{'Reps':>7}{'kJ':>9}{'%':>6}   Basis")
    tot = o["total_kj"] * 1000
    for r in o["items"]:
        reps = "" if r["reps"] is None else f"{r['reps']:.0f}"
        pct = 100 * r["joules"] / tot if tot else 0
        lines.append(f"{r['movement']:28}{reps:>7}{r['joules']/1000:>9.1f}{pct:>6.0f}   {r['note']}")
    lines.append("")
    lines.append(f"Total work: {o['total_kj']:.1f} kJ (mechanical)")
    if "avg_watts" in o:
        mm, ss = divmod(int(round(o["time_s"])), 60)
        lines.append(f"Time: {mm}:{ss:02d}   Average power: {o['avg_watts']:.0f} W   "
                     f"({o['watts_per_kg']:.2f} W/kg)")
        lines.append(f"Same energy would run a 100 W bulb for {o['bulb_100w_minutes']:.1f} min")
    lines.append(f"Estimated metabolic energy: {o['metabolic_kcal_estimate']:.0f} kcal")
    return "\n".join(lines)


# --- Self-test against the spec's reference values --------------------------------------
def selftest():
    H, M = 1.75, 68.0
    cases = [
        ("air squat", 0, {}, 253), ("pull-up", 0, {}, 388), ("muscle-up", 0, {}, 784),
        ("burpee", 0, {}, 883), ("sit-up", 0, {}, 174), ("knees-to-elbows", 0, {}, 94),
        ("double-under", 0, {}, 80), ("box jump", 0, {"box_m": 0.508}, 339),
        ("thruster", 43.1, {}, 680), ("deadlift", 100, {}, 621), ("power clean", 60, {}, 813),
        ("squat clean", 60, {}, 1066), ("wall ball", 9, {}, 434),
        ("kettlebell swing", 24, {}, 277), ("american kettlebell swing", 24, {}, 414),
    ]
    ok = True
    for name, L, p, exp in cases:
        got = rep_joules(name, H, M, L, p)
        good = abs(got - exp) / exp < 0.01
        ok &= good
        print(f"{'OK ' if good else 'BAD'} {name:28} {got:8.1f}  expected {exp}")
    fran = score({"athlete": {"height_m": H, "weight_kg": M}, "time": "5:00", "items": [
        {"movement": "thruster", "reps": [21, 15, 9], "load_kg": 43.1},
        {"movement": "pull-up", "reps": [21, 15, 9]}]})
    row = score({"athlete": {"height_m": H, "weight_kg": M}, "time": "8:00",
                 "items": [{"movement": "row", "distance_m": 2000, "time": "8:00"}]})
    run = score({"athlete": {"height_m": H, "weight_kg": M}, "time": "25:00",
                 "items": [{"movement": "run", "distance_m": 5000}]})
    for label, got, exp in [("Fran W", fran["avg_watts"], 160), ("Fran kJ", fran["total_kj"], 48.0),
                            ("Row 2k W", row["avg_watts"], 203), ("Run 5k W", run["avg_watts"], 227)]:
        good = abs(got - exp) / exp < 0.01
        ok &= good
        print(f"{'OK ' if good else 'BAD'} {label:28} {got:8.1f}  expected {exp}")
    print("ALL PASS" if ok else "FAILURES")
    return ok


if __name__ == "__main__":
    args = sys.argv[1:]
    if not args or args[0] in ("-h", "--help"):
        print(__doc__)
    elif args[0] == "--list":
        for k in sorted(LIB):
            print(k + ("  (needs load)" if LIB[k].get("needs_load") else ""))
        for k in sorted(MONO):
            print(k + "  (monostructural)")
    elif args[0] == "--selftest":
        sys.exit(0 if selftest() else 1)
    else:
        src = sys.stdin.read() if args[0] == "-" else open(args[0]).read()
        result = score(json.loads(src))
        if "--json" in args:
            print(json.dumps(result, indent=2))
        else:
            print(report(result))
