"""
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).

Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
"""
from datetime import date, timedelta
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query

from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
    assert_valid_governance_visibility,
    can_manage_club_org,
    is_platform_admin,
    library_content_visible_to_profile,
)
from routers.training_modules import load_training_module_for_apply

router = APIRouter(prefix="/api", tags=["training_planning"])


def _has_planning_role(role: Optional[str]) -> bool:
    """Kann Trainingseinheiten/Vorlagen anlegen (bis Governance: auch einfacher Account)."""
    return role in ("admin", "superadmin", "trainer", "user")


def _optional_positive_int(val, field_name: str) -> Optional[int]:
    if val is None or val == "":
        return None
    try:
        i = int(val)
    except (TypeError, ValueError):
        raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
    if i < 1:
        raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
    return i


def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
    if not variant_id:
        return
    if not exercise_id:
        raise HTTPException(
            status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
        )
    cur.execute(
        "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
        (variant_id, exercise_id),
    )
    if not cur.fetchone():
        raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung")


def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
    cur.execute(
        "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
        (group_id,),
    )
    group = cur.fetchone()
    if not group:
        raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
    co_trainers = group["co_trainer_ids"] or []
    if not _has_planning_role(role):
        raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
    if role not in ["admin", "superadmin"]:
        if group["trainer_id"] != profile_id and profile_id not in co_trainers:
            if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
                raise HTTPException(
                    status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
                )


def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
    cur.execute(
        """
        SELECT 1 FROM club_members
        WHERE club_id = %s AND profile_id = %s AND status = 'active'
        LIMIT 1
        """,
        (club_id, profile_id),
    )
    return cur.fetchone() is not None


def _caller_may_assign_session_trainers(
    cur,
    group_row: Dict[str, Any],
    profile_id: int,
    role: str,
    unit_created_by: Optional[int],
) -> bool:
    if is_platform_admin(role):
        return True
    cid = group_row.get("club_id")
    if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
        return True
    if unit_created_by is not None and unit_created_by == profile_id:
        return True
    if group_row.get("trainer_id") == profile_id:
        return True
    co = group_row.get("co_trainer_ids") or []
    return profile_id in co


def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
    """Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
    unit_asst = unit_row.get("assistant_trainer_profile_ids")
    if unit_asst is not None:
        src = unit_asst
    else:
        src = unit_row.get("co_trainer_ids") or []
    seen: set = set()
    out: List[int] = []
    for x in src:
        try:
            i = int(x)
        except (TypeError, ValueError):
            continue
        if i not in seen:
            seen.add(i)
            out.append(i)
    return sorted(out)


def effective_co_trainer_profile_ids_for_merge(
    unit_assistant: Any, group_co: Any
) -> List[int]:
    """Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
    if unit_assistant is not None:
        src = unit_assistant
    else:
        src = group_co or []
    seen: set = set()
    out: List[int] = []
    for x in src:
        try:
            i = int(x)
        except (TypeError, ValueError):
            continue
        if i not in seen:
            seen.add(i)
            out.append(i)
    return sorted(out)


def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
    cur.execute(
        """
        SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
            tu.lead_trainer_profile_id,
            tu.assistant_trainer_profile_ids,
            tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
            fwp.created_by AS framework_created_by
        FROM training_units tu
        LEFT JOIN training_groups tg ON tu.group_id = tg.id
        LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id
        LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id
        WHERE tu.id = %s
        """,
        (unit_id,),
    )
    row = cur.fetchone()
    if not row:
        raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
    return r2d(row)


def _assert_training_unit_permission(
    cur, unit_row: Dict[str, Any], profile_id: int, role: str
) -> None:
    if unit_row.get("framework_slot_id"):
        if role in ["admin", "superadmin"]:
            return
        if unit_row.get("created_by") == profile_id:
            return
        fw_by = unit_row.get("framework_created_by")
        if fw_by is not None and fw_by == profile_id:
            return
        raise HTTPException(status_code=403, detail="Keine Berechtigung")

    co_eff = _effective_co_trainer_ids_for_row(unit_row)
    if role in ["admin", "superadmin"]:
        return
    gcid = unit_row.get("group_club_id")
    if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
        return
    if (
        unit_row["created_by"] != profile_id
        and unit_row["trainer_id"] != profile_id
        and profile_id not in co_eff
        and unit_row.get("lead_trainer_profile_id") != profile_id
    ):
        raise HTTPException(status_code=403, detail="Keine Berechtigung")


def _assert_delete_training_unit(
    cur,
    role: str,
    created_by: int,
    profile_id: int,
    group_club_id: Optional[int],
) -> None:
    if role in ["admin", "superadmin"]:
        return
    if created_by == profile_id:
        return
    if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
        return
    raise HTTPException(status_code=403, detail="Keine Berechtigung")


def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
    """Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
    if role in ("admin", "superadmin"):
        return
    if can_manage_club_org(cur, profile_id, club_id, role):
        return
    cur.execute(
        """
        SELECT 1 FROM club_members
        WHERE club_id = %s AND profile_id = %s AND status = 'active'
        LIMIT 1
        """,
        (club_id, profile_id),
    )
    if cur.fetchone():
        return
    cur.execute(
        """
        SELECT 1 FROM training_groups g
        WHERE g.club_id = %s AND g.status = 'active'
          AND (
            g.trainer_id = %s
            OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int))
          )
        LIMIT 1
        """,
        (club_id, profile_id, profile_id),
    )
    if not cur.fetchone():
        raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein")


def _normalize_lead_trainer_profile_id(
    cur,
    group_id: int,
    raw_lead: Any,
    profile_id: int,
    role: str,
    unit_created_by: Optional[int],
) -> Optional[int]:
    """NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug."""
    if raw_lead is None:
        return None
    if raw_lead in ("", []):
        return None
    try:
        nid = int(raw_lead)
    except (TypeError, ValueError):
        raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
    if nid < 1:
        raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
    cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
    if not cur.fetchone():
        raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")

    cur.execute(
        "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
        (group_id,),
    )
    gr = cur.fetchone()
    if not gr:
        raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
    grd = dict(gr)
    cid = grd.get("club_id")
    if cid is None:
        raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
    club_i = int(cid)

    if is_platform_admin(role):
        return nid

    eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
    for x in grd.get("co_trainer_ids") or []:
        try:
            eligible.add(int(x))
        except (TypeError, ValueError):
            continue

    if nid == profile_id:
        if not _profile_active_in_club(cur, club_i, profile_id):
            raise HTTPException(
                status_code=403,
                detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
            )
        return nid

    if nid not in eligible:
        if not _profile_active_in_club(cur, club_i, nid):
            raise HTTPException(
                status_code=400,
                detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
            )
        if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
            raise HTTPException(
                status_code=403,
                detail="Keine Berechtigung, die Leitung zuzuweisen",
            )
        return nid

    if nid != profile_id and not _caller_may_assign_session_trainers(
        cur, grd, profile_id, role, unit_created_by
    ):
        raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
    return nid


def _normalize_assistant_trainer_profile_ids(
    cur,
    group_id: int,
    raw_val: Any,
    profile_id: int,
    role: str,
    unit_created_by: Optional[int],
    lead_nid: Optional[int],
) -> Any:
    """
    None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
    Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
    """
    if raw_val is None:
        return None
    if not isinstance(raw_val, list):
        raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")

    ids_in: List[int] = []
    for x in raw_val:
        try:
            i = int(x)
        except (TypeError, ValueError):
            raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
        if i < 1:
            raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
        ids_in.append(i)
    uniq = sorted(set(ids_in))

    cur.execute(
        "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
        (group_id,),
    )
    gr = cur.fetchone()
    if not gr:
        raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
    grd = dict(gr)
    cid = grd.get("club_id")
    if cid is None:
        raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
    club_i = int(cid)

    if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
        cur, grd, profile_id, role, unit_created_by
    ):
        raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")

    eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
    for x in grd.get("co_trainer_ids") or []:
        try:
            eligible.add(int(x))
        except (TypeError, ValueError):
            continue

    eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)

    for nid in uniq:
        cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
        if not cur.fetchone():
            raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
        if eff_lead is not None and nid == eff_lead:
            raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
        if is_platform_admin(role):
            continue
        if nid in eligible:
            continue
        if not _profile_active_in_club(cur, club_i, nid):
            raise HTTPException(
                status_code=400,
                detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
            )
    return uniq

# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
_ORIGIN_LINEAGE_JOIN = """
            LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
            LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id
"""
_ORIGIN_LINEAGE_FIELDS = """
                origin_fp.id AS origin_framework_program_id,
                origin_fp.title AS origin_framework_program_title,
                COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title,
                origin_slot.sort_order AS origin_framework_slot_sort_order
"""


def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
    """Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
    if raw_val is None or raw_val == "":
        return None
    try:
        i = int(raw_val)
    except (TypeError, ValueError):
        return None
    if i < 1:
        return None
    return i


def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
    cur.execute(
        """
        SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
        FROM training_unit_sections
        WHERE training_unit_id = %s
        ORDER BY order_index
        """,
        (unit_id,),
    )
    secs = []
    for sec_row in cur.fetchall():
        sec = r2d(sec_row)
        cur.execute(
            """
            SELECT tusi.*,
                e.title AS exercise_title,
                e.summary AS exercise_summary,
                (
                    SELECT fa.name FROM exercise_focus_areas efa
                    JOIN focus_areas fa ON fa.id = efa.focus_area_id
                    WHERE efa.exercise_id = e.id
                    ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
                    LIMIT 1
                ) AS exercise_focus_area,
                ev.variant_name AS exercise_variant_name,
                tm.title AS source_module_title
            FROM training_unit_section_items tusi
            LEFT JOIN exercises e ON tusi.exercise_id = e.id
            LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
            LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
            WHERE tusi.section_id = %s
            ORDER BY tusi.order_index
            """,
            (sec["id"],),
        )
        sec["items"] = [r2d(r) for r in cur.fetchall()]
        secs.append(sec)
    return secs


def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
    """Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
    secs = _fetch_sections(cur, unit_id)
    out: List[Dict[str, Any]] = []
    for sec in secs:
        items_clean: List[Dict[str, Any]] = []
        for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
            itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
            oix = it.get("order_index")
            if itype == "note":
                note_item = {
                    "item_type": "note",
                    "order_index": oix,
                    "note_body": it.get("note_body") or "",
                }
                sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
                if sm is not None:
                    note_item["source_training_module_id"] = sm
                items_clean.append(note_item)
                continue
            if itype != "exercise" or not it.get("exercise_id"):
                continue
            ex_item = {
                "item_type": "exercise",
                "order_index": oix,
                "exercise_id": it["exercise_id"],
                "exercise_variant_id": it.get("exercise_variant_id"),
                "planned_duration_min": it.get("planned_duration_min"),
                "actual_duration_min": it.get("actual_duration_min"),
                "notes": it.get("notes"),
                "modifications": it.get("modifications"),
            }
            sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
            if sm is not None:
                ex_item["source_training_module_id"] = sm
            items_clean.append(ex_item)
        out.append(
            {
                "title": sec.get("title"),
                "order_index": sec.get("order_index"),
                "guidance_notes": sec.get("guidance_notes"),
                "items": items_clean,
            }
        )
    return out


def _copy_blueprint_into_scheduled_unit(
    cur,
    blueprint_unit_id: int,
    group_id: int,
    planned_date: str,
    profile_id: int,
    origin_framework_slot_id: Optional[int],
) -> int:
    cur.execute(
        """
        INSERT INTO training_units (
            group_id,
            planned_date,
            planned_time_start,
            planned_time_end,
            planned_focus,
            actual_date,
            actual_time_start,
            actual_time_end,
            attendance_count,
            status,
            notes,
            trainer_notes,
            created_by,
            plan_template_id,
            origin_framework_slot_id,
            framework_slot_id
        )
        SELECT
            %s,
            %s,
            planned_time_start,
            planned_time_end,
            planned_focus,
            NULL::DATE,
            NULL::TIME WITHOUT TIME ZONE,
            NULL::TIME WITHOUT TIME ZONE,
            NULL::INT,
            COALESCE(status, 'planned'),
            notes,
            trainer_notes,
            %s,
            NULL::INT,
            %s,
            NULL::INT
        FROM training_units
        WHERE id = %s
          AND framework_slot_id IS NOT NULL
        RETURNING id
        """,
        (
            group_id,
            planned_date,
            profile_id,
            origin_framework_slot_id,
            blueprint_unit_id,
        ),
    )
    row = cur.fetchone()
    if not row:
        raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
    nu = row["id"]
    cloned = _sections_clone_payload(cur, blueprint_unit_id)
    _replace_unit_sections(cur, nu, cloned)
    return nu


def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
    flat: List[Dict[str, Any]] = []
    for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)):
        for item in sorted(sec.get("items", []), key=lambda i: i.get("order_index", 0)):
            if item.get("item_type") == "exercise":
                flat.append(item)
    unit["exercises"] = flat


def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
    uid = unit["id"]
    unit["sections"] = _fetch_sections(cur, uid)
    _flatten_exercises_from_sections(unit)
    return unit


def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
    cur.execute(
        """
        SELECT id FROM training_unit_sections
        WHERE training_unit_id = %s AND order_index = %s
        """,
        (unit_id, section_order_index),
    )
    r = cur.fetchone()
    if not r:
        raise HTTPException(
            status_code=400, detail="Abschnitt für diese Reihenfolge nicht gefunden"
        )
    return int(r["id"])


def _append_copied_module_items_to_section(
    cur,
    section_id: int,
    module_items: List[Dict[str, Any]],
    source_training_module_id: int,
) -> None:
    """Hängt kopierte Modul‑Items ans Ende eines Abschnitts (section_order_index in API)."""
    cur.execute(
        """
        SELECT COALESCE(MAX(order_index), -1) AS mo
        FROM training_unit_section_items
        WHERE section_id = %s
        """,
        (section_id,),
    )
    row = cur.fetchone()
    start = int(row["mo"]) + 1 if row and row["mo"] is not None else 0

    for i, mi in enumerate(module_items):
        oi = start + i
        itype = mi.get("item_type")
        if itype == "note":
            body = mi.get("note_body")
            if body is None:
                body = ""
            cur.execute(
                """
                INSERT INTO training_unit_section_items (
                    section_id, order_index, item_type,
                    exercise_id, exercise_variant_id,
                    planned_duration_min, actual_duration_min,
                    notes, modifications, note_body, source_training_module_id
                ) VALUES (%s, %s, 'note',
                    NULL, NULL, NULL, NULL, NULL, NULL, %s, %s)
                """,
                (section_id, oi, body, source_training_module_id),
            )
            continue

        eid = mi.get("exercise_id")
        if not eid:
            continue
        eid = int(eid)
        vid = mi.get("exercise_variant_id")
        if vid is not None:
            vid = int(vid)
        else:
            vid = None
        _validate_variant_for_exercise(cur, eid, vid)
        cur.execute(
            """
            INSERT INTO training_unit_section_items (
                section_id, order_index, item_type,
                exercise_id, exercise_variant_id,
                planned_duration_min, actual_duration_min,
                notes, modifications, note_body, source_training_module_id
            ) VALUES (%s, %s, 'exercise',
                %s, %s, %s, NULL, %s, NULL, NULL, %s)
            """,
            (
                section_id,
                oi,
                eid,
                vid,
                mi.get("planned_duration_min"),
                mi.get("notes"),
                source_training_module_id,
            ),
        )


def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0):
    if items_in is None:
        items_in = []
    for i, raw in enumerate(items_in):
        itype = raw.get("item_type")
        if not itype:
            itype = "exercise" if raw.get("exercise_id") else "note"
        order_ix = raw.get("order_index")
        if order_ix is None:
            order_ix = start_order + i
        if itype == "note":
            body = raw.get("note_body")
            if body is None:
                body = ""
            src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
            cur.execute(
                """
                INSERT INTO training_unit_section_items (
                    section_id, order_index, item_type,
                    exercise_id, exercise_variant_id,
                    planned_duration_min, actual_duration_min,
                    notes, modifications, note_body, source_training_module_id
                ) VALUES (%s, %s, 'note',
                    NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
                )
                """,
                (section_id, order_ix, body, src_mod),
            )
            continue

        eid = raw.get("exercise_id")
        if not eid:
            continue
        eid = int(eid)
        vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
        _validate_variant_for_exercise(cur, eid, vid)
        src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
        cur.execute(
            """
            INSERT INTO training_unit_section_items (
                section_id, order_index, item_type,
                exercise_id, exercise_variant_id,
                planned_duration_min, actual_duration_min,
                notes, modifications, note_body, source_training_module_id
            ) VALUES (%s, %s, 'exercise',
                %s, %s, %s, %s, %s, %s, NULL, %s
            )
            """,
            (
                section_id,
                order_ix,
                eid,
                vid,
                raw.get("planned_duration_min"),
                raw.get("actual_duration_min"),
                raw.get("notes"),
                raw.get("modifications"),
                src_mod,
            ),
        )


def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
    cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
    for si, sec in enumerate(sections_in):
        title = (sec.get("title") or "").strip() or "Abschnitt"
        order_ix = sec.get("order_index")
        if order_ix is None:
            order_ix = si
        src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
        cur.execute(
            """
            INSERT INTO training_unit_sections (
                training_unit_id, order_index, title, guidance_notes, source_template_section_id
            ) VALUES (%s, %s, %s, %s, %s)
            RETURNING id
            """,
            (
                unit_id,
                order_ix,
                title,
                sec.get("guidance_notes"),
                src_tsec,
            ),
        )
        sid = cur.fetchone()["id"]
        _insert_section_items(cur, sid, sec.get("items"))


def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
    cur.execute(
        """
        SELECT DISTINCT tusi.exercise_id
        FROM training_unit_section_items tusi
        INNER JOIN training_unit_sections tus ON tusi.section_id = tus.id
        WHERE tus.training_unit_id = %s
          AND tusi.item_type = 'exercise'
          AND tusi.exercise_id IS NOT NULL
        """,
        (unit_id,),
    )
    rows = cur.fetchall() or []
    out: List[int] = []
    for r in rows:
        try:
            out.append(int(r["exercise_id"]))
        except (TypeError, ValueError, KeyError):
            continue
    return out


def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]:
    """Nur echte Gruppentermine (keine Rahmen-Blueprints ohne Gruppe)."""
    cur.execute(
        """
        SELECT tg.club_id
        FROM training_units tu
        INNER JOIN training_groups tg ON tu.group_id = tg.id
        WHERE tu.id = %s AND tu.framework_slot_id IS NULL
        """,
        (unit_id,),
    )
    r = cur.fetchone()
    if not r or r.get("club_id") is None:
        return None
    return int(r["club_id"])


def _exercise_needs_club_visibility_for_target(ex: Dict[str, Any], target_club_id: int) -> bool:
    """Übung für Mitglieder des Ziel-Vereins in der Durchführung sichtbar machen (Dashboard/Queue)."""
    if str(ex.get("status") or "").strip().lower() == "archived":
        return False
    vis = (ex.get("visibility") or "private").strip().lower()
    if vis == "official":
        return False
    if vis == "private":
        return True
    if vis == "club":
        raw = ex.get("club_id")
        if raw is None:
            return True
        try:
            return int(raw) != int(target_club_id)
        except (TypeError, ValueError):
            return True
    return False


def _caller_may_promote_exercise_to_club(
    cur,
    exercise_created_by: Optional[int],
    profile_id: int,
    role: str,
    target_club_id: int,
) -> bool:
    if is_platform_admin(role):
        return True
    if exercise_created_by is not None and int(exercise_created_by) == profile_id:
        return True
    if can_manage_club_org(cur, profile_id, target_club_id, role):
        return True
    return False


def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, role: str) -> None:
    """
    Private Übungen in der Einheit auf visibility=club (Verein der Trainingsgruppe) setzen,
    damit andere Trainer und Mitglieder sie in der Durchführung sehen.
    """
    target_club_id = _group_club_id_for_scheduled_unit(cur, unit_id)
    if not target_club_id:
        return
    if not (
        is_platform_admin(role)
        or _profile_active_in_club(cur, target_club_id, profile_id)
        or can_manage_club_org(cur, profile_id, target_club_id, role)
    ):
        return

    for eid in _distinct_exercise_ids_in_unit(cur, unit_id):
        cur.execute(
            """
            SELECT id, created_by, visibility, club_id, COALESCE(status, '') AS status
            FROM exercises WHERE id = %s
            """,
            (eid,),
        )
        row = cur.fetchone()
        if not row:
            continue
        if str(row.get("status") or "").strip().lower() == "archived":
            continue
        vis = (row.get("visibility") or "private").strip().lower()
        if vis == "official":
            continue
        if vis == "club":
            continue
        if vis != "private":
            continue
        cb = row.get("created_by")
        if not _caller_may_promote_exercise_to_club(cur, cb, profile_id, role, target_club_id):
            continue
        cur.execute(
            """
            UPDATE exercises
            SET visibility = 'club', club_id = %s, updated_at = NOW()
            WHERE id = %s AND LOWER(COALESCE(visibility, 'private')) = 'private'
            """,
            (target_club_id, eid),
        )


def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
    if not exercises_in:
        return
    cur.execute(
        """
        INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
        VALUES (%s, 0, %s, NULL)
        RETURNING id
        """,
        (unit_id, "Übungen"),
    )
    sid = cur.fetchone()["id"]
    slot = 0
    filtered: List[Dict[str, Any]] = []
    for ex in exercises_in:
        eid = ex.get("exercise_id")
        if not eid:
            continue
        eid = int(eid)
        vid = _optional_positive_int(ex.get("exercise_variant_id"), "exercise_variant_id")
        _validate_variant_for_exercise(cur, eid, vid)
        filtered.append(
            {
                "item_type": "exercise",
                "order_index": slot,
                "exercise_id": eid,
                "exercise_variant_id": vid,
                "planned_duration_min": ex.get("planned_duration_min"),
                "actual_duration_min": ex.get("actual_duration_min"),
                "notes": ex.get("notes"),
                "modifications": ex.get("modifications"),
            }
        )
        slot += 1
    _insert_section_items(cur, sid, filtered, start_order=0)


def _instantiate_from_template(cur, unit_id: int, template_id: int):
    cur.execute(
        """
        SELECT id, title, guidance_text
        FROM training_plan_template_sections
        WHERE template_id = %s
        ORDER BY order_index
        """,
        (template_id,),
    )
    rows = cur.fetchall()
    for row in rows:
        r = r2d(row)
        cur.execute(
            """
            INSERT INTO training_unit_sections (
                training_unit_id, order_index, title, guidance_notes, source_template_section_id
            ) VALUES (%s, (
                SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
                WHERE u2.training_unit_id = %s
            ), %s, %s, %s)
            """,
            (unit_id, unit_id, r["title"], r["guidance_text"], r["id"]),
        )

    # Fallback: keine Sektionen in Vorlage → ein leerer Block
    if not rows:
        cur.execute(
            """
            INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
            SELECT %s, 0, %s, NULL
            WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s)
            """,
            (unit_id, "Hauptteil", unit_id),
        )


def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
    cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
    r = cur.fetchone()
    if not r:
        raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
    return r2d(r)


def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
    if is_platform_admin(role):
        return
    if not library_content_visible_to_profile(
        cur,
        profile_id,
        row.get("visibility") or "club",
        row.get("club_id"),
        row.get("created_by"),
        role,
    ):
        raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage")


def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
    if is_platform_admin(role):
        return
    if row.get("created_by") != profile_id:
        raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern")


def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
    """Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
    row = _fetch_training_plan_template_row(cur, tid)
    _template_assert_readable(cur, row, profile_id, role)
    return row


# ── Vorlagen ────────────────────────────────────────────────────────────


@router.get("/training-plan-templates")
def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role
    with get_db() as conn:
        cur = get_cursor(conn)
        vis_clause, vis_params = library_content_visibility_sql(
            alias="t",
            profile_id=profile_id,
            role=role,
            effective_club_id=tenant.effective_club_id,
        )
        cur.execute(
            f"""
                SELECT t.*,
                    (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
                    AS sections_count
                FROM training_plan_templates t
                WHERE ({vis_clause})
                ORDER BY t.updated_at DESC NULLS LAST, t.name
                """,
            vis_params,
        )
        return [r2d(r) for r in cur.fetchall()]


@router.get("/training-plan-templates/{template_id}")
def get_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role
    with get_db() as conn:
        cur = get_cursor(conn)
        row = _template_access(cur, template_id, profile_id, role)
        cur.execute(
            """
            SELECT *
            FROM training_plan_template_sections
            WHERE template_id = %s
            ORDER BY order_index
            """,
            (template_id,),
        )
        row["sections"] = [r2d(r) for r in cur.fetchall()]
        return row


@router.post("/training-plan-templates")
def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role
    if not _has_planning_role(role):
        raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
    name = (data.get("name") or "").strip()
    if not name:
        raise HTTPException(status_code=400, detail="name ist Pflicht")
    vis_raw = data.get("visibility")
    visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
    club_id = data.get("club_id")
    if club_id in ("", []):
        club_id = None
    if visibility == "club" and club_id is None:
        club_id = tenant.effective_club_id
    sections_in = data.get("sections") or []

    with get_db() as conn:
        cur = get_cursor(conn)
        assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
        cur.execute(
            """
            INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
            VALUES (%s, %s, %s, %s, %s)
            RETURNING id
            """,
            (club_id, profile_id, name, data.get("description"), visibility),
        )
        tid = cur.fetchone()["id"]
        for si, sec in enumerate(sections_in):
            title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
            order_ix = sec.get("order_index")
            if order_ix is None:
                order_ix = si
            cur.execute(
                """
                INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
                VALUES (%s, %s, %s, %s)
                """,
                (tid, order_ix, title, sec.get("guidance_text")),
            )
        conn.commit()
    return get_training_plan_template(tid, tenant)


@router.put("/training-plan-templates/{template_id}")
def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role
    with get_db() as conn:
        cur = get_cursor(conn)
        row_prev = _fetch_training_plan_template_row(cur, template_id)
        _template_assert_writable(cur, row_prev, profile_id, role)
        merged_vis = row_prev.get("visibility") or "club"
        merged_club = row_prev.get("club_id")
        if "visibility" in data:
            v_in = data.get("visibility")
            if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
                raise HTTPException(status_code=400, detail="visibility ungültig")
            merged_vis = v_in
        if "club_id" in data:
            merged_club = data.get("club_id")
            if merged_club in ("", []):
                merged_club = None
        if "visibility" in data or "club_id" in data:
            assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
        fields = []
        params: List[Any] = []
        if "name" in data:
            name = data.get("name")
            name = name.strip() if isinstance(name, str) else ""
            if not name:
                raise HTTPException(status_code=400, detail="name ist Pflicht")
            fields.append("name = %s")
            params.append(name)
        if "description" in data:
            fields.append("description = %s")
            params.append(data.get("description"))
        if "club_id" in data:
            fields.append("club_id = %s")
            params.append(merged_club)
        if "visibility" in data:
            fields.append("visibility = %s")
            params.append(merged_vis)
        fields.append("updated_at = NOW()")
        params.append(template_id)
        cur.execute(
            f"""
            UPDATE training_plan_templates SET {", ".join(fields)}
            WHERE id = %s
            """,
            tuple(params),
        )
        if "sections" in data:
            cur.execute(
                "DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
            )
            sections_in = data["sections"] or []
            for si, sec in enumerate(sections_in):
                title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
                order_ix = sec.get("order_index")
                if order_ix is None:
                    order_ix = si
                cur.execute(
                    """
                    INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
                    VALUES (%s, %s, %s, %s)
                    """,
                    (template_id, order_ix, title, sec.get("guidance_text")),
                )
        conn.commit()
    return get_training_plan_template(template_id, tenant)


@router.delete("/training-plan-templates/{template_id}")
def delete_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role
    with get_db() as conn:
        cur = get_cursor(conn)
        row_del = _fetch_training_plan_template_row(cur, template_id)
        _template_assert_writable(cur, row_del, profile_id, role)
        cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
        conn.commit()
    return {"ok": True}


# ── Einheiten ─────────────────────────────────────────────────────────────


@router.get("/training-units")
def list_training_units(
    group_id: Optional[int] = Query(default=None),
    club_id: Optional[int] = Query(default=None),
    start_date: Optional[str] = Query(default=None),
    end_date: Optional[str] = Query(default=None),
    status: Optional[str] = Query(default=None),
    assigned_to_me: bool = Query(default=False),
    debrief_pending: bool = Query(
        default=False,
        description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)",
    ),
    sort: str = Query(default="desc"),
    limit: Optional[int] = Query(default=None),
    tenant: TenantContext = Depends(get_tenant_context),
):
    profile_id = tenant.profile_id
    role = tenant.global_role

    gid = _optional_positive_int(group_id, "group_id") if group_id else None
    cid = _optional_positive_int(club_id, "club_id") if club_id else None
    if gid and cid:
        raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")

    with get_db() as conn:
        cur = get_cursor(conn)

        if cid and role not in ["admin", "superadmin"]:
            _assert_club_visible_for_trainer(cur, cid, profile_id, role)

        if gid and role not in ["admin", "superadmin"]:
            cur.execute(
                "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
                (gid,),
            )
            gr = cur.fetchone()
            if not gr:
                raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
            gd = dict(gr)
            cob = gd.get("co_trainer_ids") or []
            ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
            ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
            ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
            if not (ok_staff or ok_org or ok_member):
                raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")

        order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
        lim: Optional[int] = None
        if limit is not None:
            try:
                lim = int(limit)
            except (TypeError, ValueError):
                raise HTTPException(status_code=400, detail="limit ungültig")
            if lim < 1:
                raise HTTPException(status_code=400, detail="limit ungültig")
            lim = min(lim, 250)

        query = """
            SELECT tu.*,
                tg.name as group_name,
                tg.weekday as group_weekday,
                tg.club_id AS group_club_id,
                c.name as club_name,
                p.name as trainer_name,
                p.name as creator_name,
                COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
                COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
                    AS effective_assistant_trainer_profile_ids,
                leadp.name AS lead_trainer_name
        """
        query += "," + _ORIGIN_LINEAGE_FIELDS
        query += """
            FROM training_units tu
            LEFT JOIN training_groups tg ON tu.group_id = tg.id
            LEFT JOIN clubs c ON tg.club_id = c.id
            LEFT JOIN profiles p ON tu.created_by = p.id
            LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
        """
        query += _ORIGIN_LINEAGE_JOIN

        where = []
        params = []

        skip_involvement_filter = role in ("admin", "superadmin")
        if not skip_involvement_filter and cid is not None:
            if can_manage_club_org(cur, profile_id, cid, role):
                skip_involvement_filter = True
        if not skip_involvement_filter and gid is not None:
            cur.execute(
                "SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
                (gid,),
            )
            gcx = cur.fetchone()
            if gcx and gcx.get("club_id") is not None:
                if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
                    skip_involvement_filter = True

        if not skip_involvement_filter:
            where.append(
                "(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
                "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
                "@> jsonb_build_array(%s::int))"
            )
            params.extend([profile_id, profile_id, profile_id, profile_id])

        where.append("tu.framework_slot_id IS NULL")

        if gid:
            where.append("tu.group_id = %s")
            params.append(gid)

        if cid:
            where.append("tg.club_id = %s")
            params.append(cid)

        if assigned_to_me:
            where.append(
                "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
                "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
                "@> jsonb_build_array(%s::int))"
            )
            params.extend([profile_id, profile_id])

        if start_date:
            where.append("tu.planned_date >= %s")
            params.append(start_date)

        if end_date:
            where.append("tu.planned_date <= %s")
            params.append(end_date)

        if debrief_pending:
            where.append("tu.status = %s")
            params.append("completed")
            where.append("tu.debrief_completed_at IS NULL")
        elif status:
            where.append("tu.status = %s")
            params.append(status)

        if where:
            query += " WHERE " + " AND ".join(where)

        query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
        if lim is not None:
            query += " LIMIT %s"
            params.append(lim)

        cur.execute(query, params)
        rows = cur.fetchall()
        return [r2d(r) for r in rows]


@router.get("/training-units/exercises-club-visibility-queue")
def exercises_club_visibility_queue(
    start_date: Optional[str] = Query(default=None),
    end_date: Optional[str] = Query(default=None),
    assigned_to_me: bool = Query(default=True),
    limit_units: int = Query(default=80, ge=1, le=150),
    tenant: TenantContext = Depends(get_tenant_context),
):
    """
    Übungen in deinen Trainingseinheiten (Zeitfenster), die für den jeweiligen Verein der Gruppe
    noch nicht vereinsweit sichtbar sind — für Dashboard & Freigabe-Workflow.
    """
    profile_id = tenant.profile_id
    role = tenant.global_role

    if start_date is None:
        start_date = (date.today() - timedelta(days=45)).isoformat()
    if end_date is None:
        end_date = (date.today() + timedelta(days=365)).isoformat()

    units = list_training_units(
        group_id=None,
        club_id=None,
        start_date=start_date,
        end_date=end_date,
        status=None,
        assigned_to_me=assigned_to_me,
        debrief_pending=False,
        sort="asc",
        limit=limit_units,
        tenant=tenant,
    )
    unit_ids = [int(u["id"]) for u in units if u.get("id") is not None]
    if not unit_ids:
        return {"items": []}

    placeholders = ",".join(["%s"] * len(unit_ids))
    items: List[Dict[str, Any]] = []

    with get_db() as conn:
        cur = get_cursor(conn)
        cur.execute(
            f"""
            SELECT DISTINCT tu.id AS unit_id,
                tu.planned_date,
                tg.name AS group_name,
                tg.club_id AS target_club_id,
                c.name AS target_club_name,
                tusi.exercise_id AS exercise_id
            FROM training_units tu
            INNER JOIN training_groups tg ON tu.group_id = tg.id
            LEFT JOIN clubs c ON c.id = tg.club_id
            INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
            INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
            WHERE tu.id IN ({placeholders})
              AND tu.framework_slot_id IS NULL
              AND tusi.item_type = 'exercise'
              AND tusi.exercise_id IS NOT NULL
            """,
            tuple(unit_ids),
        )
        pairs = [r2d(r) for r in cur.fetchall()]
        if not pairs:
            return {"items": []}

        ex_ids = sorted(
            {int(p["exercise_id"]) for p in pairs if p.get("exercise_id") is not None}
        )
        if not ex_ids:
            return {"items": []}

        exercises_map: Dict[int, Dict[str, Any]] = {}
        ph = ",".join(["%s"] * len(ex_ids))
        cur.execute(
            f"""
            SELECT id, title, visibility, club_id, created_by, status
            FROM exercises
            WHERE id IN ({ph})
            """,
            tuple(ex_ids),
        )
        for r in cur.fetchall():
            d = r2d(r)
            exercises_map[int(d["id"])] = d

        agg: Dict[tuple, Dict[str, Any]] = {}
        for p in pairs:
            try:
                ex_id = int(p["exercise_id"])
            except (TypeError, ValueError):
                continue
            tc_raw = p.get("target_club_id")
            if tc_raw is None:
                continue
            tc = int(tc_raw)
            key = (ex_id, tc)
            if key not in agg:
                agg[key] = {
                    "exercise_id": ex_id,
                    "target_club_id": tc,
                    "target_club_name": (p.get("target_club_name") or "").strip(),
                    "units": [],
                }
            uid = p.get("unit_id")
            if uid is None:
                continue
            agg[key]["units"].append(
                {
                    "id": int(uid),
                    "planned_date": str(p["planned_date"]) if p.get("planned_date") is not None else "",
                    "group_name": (p.get("group_name") or "").strip(),
                }
            )

        for _key, blob in agg.items():
            ex_id = blob["exercise_id"]
            tc = blob["target_club_id"]
            ex = exercises_map.get(ex_id)
            if not ex:
                continue
            if not _exercise_needs_club_visibility_for_target(ex, tc):
                continue
            uniq_units = {u["id"]: u for u in blob["units"]}.values()
            ulist = sorted(
                uniq_units,
                key=lambda x: (x.get("planned_date") or "", x.get("id")),
            )
            cb = ex.get("created_by")
            cb_int = int(cb) if cb is not None else None
            can_promote = _caller_may_promote_exercise_to_club(cur, cb_int, profile_id, role, tc)
            vis = (ex.get("visibility") or "private").strip().lower()
            st = (ex.get("status") or "draft").strip().lower()
            ecid = ex.get("club_id")
            items.append(
                {
                    "exercise_id": ex_id,
                    "title": (ex.get("title") or f"Übung #{ex_id}").strip() or f"Übung #{ex_id}",
                    "visibility": vis,
                    "status": st,
                    "club_id": int(ecid) if ecid is not None else None,
                    "created_by": cb_int,
                    "target_club_id": tc,
                    "target_club_name": blob.get("target_club_name") or "",
                    "can_promote": can_promote,
                    "units": ulist,
                }
            )

    items.sort(
        key=lambda x: (
            (x["units"][0].get("planned_date") if x["units"] else ""),
            x["title"],
        )
    )
    return {"items": items}


@router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role

    with get_db() as conn:
        cur = get_cursor(conn)

        cur.execute(
            """
            SELECT tu.*,
                tg.name as group_name,
                tg.weekday as group_weekday,
                tg.time_start as group_time_start,
                tg.time_end as group_time_end,
                tg.location as group_location,
                c.name as club_name,
                p.name as trainer_name,
                p.name as creator_name,
                tg.trainer_id AS trainer_id,
                tg.co_trainer_ids AS co_trainer_ids,
                tg.club_id AS group_club_id,
                COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
                COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
                    AS effective_assistant_trainer_profile_ids,
                leadp.name AS lead_trainer_name,
            """ + _ORIGIN_LINEAGE_FIELDS.strip() + """
            FROM training_units tu
            LEFT JOIN training_groups tg ON tu.group_id = tg.id
            LEFT JOIN clubs c ON tg.club_id = c.id
            LEFT JOIN profiles p ON tu.created_by = p.id
            LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
            """ + _ORIGIN_LINEAGE_JOIN.strip() + """
            WHERE tu.id = %s
            """,
            (unit_id,),
        )

        unit = cur.fetchone()
        if not unit:
            raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")

        unit = r2d(unit)

        if unit.get("framework_slot_id"):
            if role not in ["admin", "superadmin"]:
                cur.execute(
                    """
                    SELECT fp.created_by FROM training_framework_slots s
                    JOIN training_framework_programs fp ON fp.id = s.framework_program_id
                    WHERE s.id = %s
                    """,
                    (unit["framework_slot_id"],),
                )
                fr = cur.fetchone()
                cb = fr["created_by"] if fr else None
                if unit["created_by"] != profile_id and cb != profile_id:
                    raise HTTPException(status_code=403, detail="Keine Berechtigung")
        else:
            if not unit.get("group_id"):
                raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
            _assert_training_unit_permission(cur, unit, profile_id, role)

        _hydrate_training_unit_payload(cur, unit)
        return unit


@router.post("/training-units/{unit_id}/apply-training-module")
def apply_training_module_to_training_unit(
    unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
):
    """Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar)."""
    profile_id = tenant.profile_id
    role = tenant.global_role
    if not _has_planning_role(role):
        raise HTTPException(status_code=403, detail="Nur Trainer dürfen Module übernehmen")

    module_id_raw = data.get("module_id")
    if module_id_raw is None or module_id_raw == "":
        raise HTTPException(status_code=400, detail="module_id ist Pflicht")
    try:
        module_id = int(module_id_raw)
    except (TypeError, ValueError):
        raise HTTPException(status_code=400, detail="module_id ungültig")

    soy = data.get("section_order_index")
    try:
        section_order_index = int(soy)
    except (TypeError, ValueError):
        raise HTTPException(status_code=400, detail="section_order_index ist Pflicht (Ganzzahl)")
    if section_order_index < 0:
        raise HTTPException(status_code=400, detail="section_order_index ungültig")

    with get_db() as conn:
        cur = get_cursor(conn)
        unit_row = _training_unit_guard_row(cur, unit_id)
        _assert_training_unit_permission(cur, unit_row, profile_id, role)

        section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index)
        mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role)
        _append_copied_module_items_to_section(cur, section_id, mod_items, src_mid)
        _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
        conn.commit()

    return get_training_unit(unit_id, tenant)


@router.post("/training-units")
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role

    group_id = data.get("group_id")
    planned_date = data.get("planned_date")

    if not group_id or not planned_date:
        raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")

    plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id")

    with get_db() as conn:
        cur = get_cursor(conn)

        _can_access_group_for_create(cur, group_id, profile_id, role)

        tpl_id_safe = None
        if plan_template_id:
            _template_access(cur, plan_template_id, profile_id, role)
            tpl_id_safe = plan_template_id

        cur.execute(
            "SELECT trainer_id FROM training_groups WHERE id = %s",
            (int(group_id),),
        )
        g0 = cur.fetchone()
        default_group_trainer = g0["trainer_id"] if g0 else None

        lead_ins: Optional[int] = None
        if "lead_trainer_profile_id" in data:
            lead_ins = _normalize_lead_trainer_profile_id(
                cur,
                int(group_id),
                data.get("lead_trainer_profile_id"),
                profile_id,
                role,
                profile_id,
            )
        assistant_val: Any = None
        assistant_set = False
        if "assistant_trainer_profile_ids" in data:
            assistant_set = True
            eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
            assistant_val = _normalize_assistant_trainer_profile_ids(
                cur,
                int(group_id),
                data.get("assistant_trainer_profile_ids"),
                profile_id,
                role,
                profile_id,
                eff_lead_for_co,
            )

        base_params = (
            group_id,
            planned_date,
            data.get("planned_time_start"),
            data.get("planned_time_end"),
            data.get("planned_focus"),
            data.get("status", "planned"),
            data.get("notes"),
            data.get("trainer_notes"),
            profile_id,
            tpl_id_safe,
            lead_ins,
        )
        if assistant_set:
            cur.execute(
                """
                INSERT INTO training_units (
                    group_id, planned_date, planned_time_start, planned_time_end,
                    planned_focus, status, notes, trainer_notes, created_by,
                    plan_template_id,
                    lead_trainer_profile_id,
                    assistant_trainer_profile_ids
                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                RETURNING id
                """,
                base_params + (assistant_val,),
            )
        else:
            cur.execute(
                """
                INSERT INTO training_units (
                    group_id, planned_date, planned_time_start, planned_time_end,
                    planned_focus, status, notes, trainer_notes, created_by,
                    plan_template_id,
                    lead_trainer_profile_id
                ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                RETURNING id
                """,
                base_params,
            )

        unit_id = cur.fetchone()["id"]

        sections_in = data.get("sections")
        exercises_in = data.get("exercises")

        if sections_in is not None:
            _replace_unit_sections(cur, unit_id, sections_in)
        elif tpl_id_safe:
            _instantiate_from_template(cur, unit_id, tpl_id_safe)
        elif exercises_in is not None:
            _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)

        _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)

        conn.commit()

    return get_training_unit(unit_id, tenant)


@router.put("/training-units/{unit_id}")
def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role

    with get_db() as conn:
        cur = get_cursor(conn)

        unit_row = _training_unit_guard_row(cur, unit_id)
        _assert_training_unit_permission(cur, unit_row, profile_id, role)

        is_blueprint = unit_row.get("framework_slot_id") is not None

        tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None
        tpl_id_val = None
        if tpl_upd not in (None, ""):
            tid = _optional_positive_int(tpl_upd, "plan_template_id")
            if tid:
                _template_access(cur, tid, profile_id, role)
                tpl_id_val = tid

        trainer_notes_val = None
        if "trainer_notes" not in data:
            cur.execute(
                "SELECT trainer_notes FROM training_units WHERE id = %s",
                (unit_id,),
            )
            row_tn = cur.fetchone()
            trainer_notes_val = row_tn["trainer_notes"] if row_tn else None
        else:
            trainer_notes_val = data.get("trainer_notes")

        if is_blueprint:
            if data.get("reset_from_template"):
                raise HTTPException(
                    status_code=400,
                    detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden",
                )
            if tpl_upd not in (None, ""):
                raise HTTPException(
                    status_code=400,
                    detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig",
                )
            blueprint_fields = []
            blueprint_params: List[Any] = []
            if "planned_focus" in data:
                blueprint_fields.append("planned_focus = %s")
                blueprint_params.append(data.get("planned_focus"))
            if "planned_time_start" in data:
                blueprint_fields.append("planned_time_start = %s")
                blueprint_params.append(data.get("planned_time_start"))
            if "planned_time_end" in data:
                blueprint_fields.append("planned_time_end = %s")
                blueprint_params.append(data.get("planned_time_end"))
            if "notes" in data:
                blueprint_fields.append("notes = %s")
                blueprint_params.append(data.get("notes"))
            blueprint_fields.append("trainer_notes = %s")
            blueprint_params.append(trainer_notes_val)
            blueprint_params.append(unit_id)
            cur.execute(
                f"""
                UPDATE training_units SET
                    {", ".join(blueprint_fields)},
                    updated_at = NOW()
                WHERE id = %s
                """,
                tuple(blueprint_params),
            )
        else:
            cur_lead = unit_row.get("lead_trainer_profile_id")
            base_tr = unit_row.get("trainer_id")
            lead_sql = ""
            lead_params: List[Any] = []
            assist_sql = ""
            assist_params: List[Any] = []
            nl: Optional[int]
            if "lead_trainer_profile_id" in data:
                nl = _normalize_lead_trainer_profile_id(
                    cur,
                    unit_row["group_id"],
                    data.get("lead_trainer_profile_id"),
                    profile_id,
                    role,
                    unit_row.get("created_by"),
                )
                lead_sql = ", lead_trainer_profile_id = %s"
                lead_params.append(nl)
                eff_lead_for_co = nl if nl is not None else base_tr
            else:
                nl = cur_lead if cur_lead is not None else base_tr
                eff_lead_for_co = nl

            if "assistant_trainer_profile_ids" in data:
                na = _normalize_assistant_trainer_profile_ids(
                    cur,
                    unit_row["group_id"],
                    data.get("assistant_trainer_profile_ids"),
                    profile_id,
                    role,
                    unit_row.get("created_by"),
                    eff_lead_for_co,
                )
                assist_sql = ", assistant_trainer_profile_ids = %s"
                assist_params.append(na)

            debrief_frag = ""
            if "debrief_completed" in data and not is_blueprint:
                if data.get("debrief_completed") is True:
                    debrief_frag = ", debrief_completed_at = NOW()"
                else:
                    debrief_frag = ", debrief_completed_at = NULL"

            cur.execute(
                f"""
                UPDATE training_units SET
                    planned_date = COALESCE(%s, planned_date),
                    planned_time_start = %s,
                    planned_time_end = %s,
                    planned_focus = %s,
                    actual_date = %s,
                    actual_time_start = %s,
                    actual_time_end = %s,
                    attendance_count = %s,
                    status = %s,
                    notes = %s,
                    trainer_notes = %s,
                    plan_template_id = COALESCE(%s, plan_template_id),
                    updated_at = NOW()
                    {lead_sql}
                    {assist_sql}
                    {debrief_frag}
                WHERE id = %s
                """,
                (
                    data.get("planned_date"),
                    data.get("planned_time_start"),
                    data.get("planned_time_end"),
                    data.get("planned_focus"),
                    data.get("actual_date"),
                    data.get("actual_time_start"),
                    data.get("actual_time_end"),
                    data.get("attendance_count"),
                    data.get("status"),
                    data.get("notes"),
                    trainer_notes_val,
                    tpl_id_val,
                )
                + tuple(lead_params)
                + tuple(assist_params)
                + (unit_id,),
            )

        content_handled = False
        if not is_blueprint and data.get("reset_from_template"):
            tid = tpl_id_val or unit_row.get("plan_template_id")
            if not tid:
                raise HTTPException(
                    status_code=400,
                    detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
                )
            _template_access(cur, tid, profile_id, role)
            cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
            cur.execute(
                "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
            )
            _instantiate_from_template(cur, unit_id, tid)
            content_handled = True

        if not content_handled and "sections" in data:
            _replace_unit_sections(cur, unit_id, data["sections"] or [])
        elif not content_handled and "exercises" in data:
            cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
            _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])

        if content_handled or "sections" in data or "exercises" in data:
            _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)

        conn.commit()

    return get_training_unit(unit_id, tenant)


@router.delete("/training-units/{unit_id}")
def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id
    role = tenant.global_role

    with get_db() as conn:
        cur = get_cursor(conn)

        cur.execute(
            """
            SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
            FROM training_units tu
            LEFT JOIN training_groups tg ON tu.group_id = tg.id
            WHERE tu.id = %s
            """,
            (unit_id,),
        )

        unit = cur.fetchone()

        if not unit:
            raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")

        if unit.get("framework_slot_id"):
            raise HTTPException(
                status_code=400,
                detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
            )

        _assert_delete_training_unit(
            cur,
            role,
            unit["created_by"],
            profile_id,
            unit.get("group_club_id"),
        )

        cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
        conn.commit()

    return {"ok": True}


@router.post("/training-units/from-framework-slot")
def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
    profile_id = tenant.profile_id
    role = tenant.global_role

    if not _has_planning_role(role):
        raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen")

    raw_sid = data.get("framework_slot_id")
    try:
        slot_id = int(raw_sid)
    except (TypeError, ValueError):
        raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
    if slot_id < 1:
        raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")

    group_id = data.get("group_id")
    planned_date = data.get("planned_date")
    if not group_id or not planned_date:
        raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")

    with get_db() as conn:
        cur = get_cursor(conn)

        cur.execute(
            """
            SELECT fp.created_by FROM training_framework_slots s
            JOIN training_framework_programs fp ON fp.id = s.framework_program_id
            WHERE s.id = %s
            """,
            (slot_id,),
        )
        fw_row = cur.fetchone()
        if not fw_row:
            raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden")

        if role not in ["admin", "superadmin"]:
            if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id:
                raise HTTPException(
                    status_code=403,
                    detail="Keine Berechtigung für dieses Rahmenprogramm",
                )

        cur.execute(
            "SELECT id FROM training_units WHERE framework_slot_id = %s",
            (slot_id,),
        )
        blueprint = cur.fetchone()
        if not blueprint:
            raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot")

        _can_access_group_for_create(cur, int(group_id), profile_id, role)

        new_id = _copy_blueprint_into_scheduled_unit(
            cur,
            int(blueprint["id"]),
            int(group_id),
            str(planned_date),
            profile_id,
            slot_id,
        )

        _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role)

        conn.commit()

    return get_training_unit(new_id, tenant)


@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
    profile_id = tenant.profile_id

    group_id = data.get("group_id")
    planned_date = data.get("planned_date")
    plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id")

    if not group_id or not planned_date:
        raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")

    with get_db() as conn:
        cur = get_cursor(conn)

        cur.execute(
            """
            SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids
            FROM training_groups
            WHERE id = %s
            """,
            (group_id,),
        )

        group = cur.fetchone()

        if not group:
            raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")

        role = tenant.global_role
        co_trainers = group["co_trainer_ids"] or []

        if not _has_planning_role(role):
            raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")

        if role not in ["admin", "superadmin"]:
            if group["trainer_id"] != profile_id and profile_id not in co_trainers:
                raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")

        tpl_id_safe = None
        if plan_template_id:
            _template_access(cur, plan_template_id, profile_id, role)
            tpl_id_safe = plan_template_id

        cur.execute(
            """
            INSERT INTO training_units (
                group_id, planned_date,
                planned_time_start, planned_time_end,
                status, created_by, plan_template_id
            ) VALUES (%s, %s, %s, %s, %s, %s, %s)
            RETURNING id
            """,
            (
                group_id,
                planned_date,
                group["time_start"],
                group["time_end"],
                "planned",
                profile_id,
                tpl_id_safe,
            ),
        )

        unit_id = cur.fetchone()["id"]

        if tpl_id_safe:
            _instantiate_from_template(cur, unit_id, tpl_id_safe)
            _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)

        conn.commit()

    return get_training_unit(unit_id, tenant)

