"""
Frittstående rundeføring med detaljert statistikk (ADR-033).

Eid av en BRUKER (`app_user.id`), ikke en organisasjon -- INGEN RLS på
`round`/`round_participant`/`round_hole`/`personal_course*` (Beslutning A).
Samme mønster som personlig profil/HCP-historikk/sekundær e-post: kun
`plain_connection()`, autorisasjon håndheves eksplisitt her med
`WHERE owner_user_id = $1` (eller via round_id -> owner-kjeden).

Banedata (Beslutning C): offisielle teeoff-baner slås opp LIVE ved hver
runde-opprettelse/deltaker-tilføyelse (ingen lokal kopi) via samme
`teeoff_client` som den org-scopede import-flyten i courses.py bruker --
men her INGEN `course`/`hole`/`tee`-rader skrives, kun et navn-snapshot
på selve runden og et rating-snapshot per deltaker.

v1-avgrensning (eksplisitt notert i ADR-033, ikke løst her): en deltaker
med `user_id` satt får IKKE egen tilgang til runden -- derfor støtter
DENNE runden av API-et kun GJEST-deltakere (fritekstnavn), ikke ekte
kontokobling. `round_participant.user_id`-kolonnen brukes fortsatt for
runde-EIEREN (alltid en ekte konto), bare ikke for andre i flighten ennå.

HCP: to atskilte tall (ADR-038). `app_user.handicap_index` er MANUELT
satt, uendret betydning (brukes til Course Handicap i enhver runde med
mindre eksplisitt overstyrt). `app_user.computed_handicap_index` er det
FAKTISKE, automatisk beregnede WHS Handicap Index (Rule 5.2) --
oppdateres av `_recompute_computed_handicap_index()` ved fullføring, ved
eksklusjons-endring på en allerede fullført runde, og ved fjerning/
sletting av en tellende deltaker/runde. Kun frittstående runder teller
med i v1 (se `_gather_qualifying_differentials` for skjøtepunktet mot en
fremtidig turnering-kilde, ADR-038 Beslutning E).
"""

from __future__ import annotations

import math
import uuid
from datetime import date, datetime, timedelta
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, EmailStr, Field

from .. import teeoff_client
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
from ..realtime import broadcast_round_update, live_sockets_for_round
from .notifications import create_notification
from handicap_engine import (
    HoleResult,
    adjusted_gross_score,
    allocate_over_played_holes,
    allocate_strokes_by_index,
    apply_index_caps,
    compute_match_state,
    compute_skins_detail,
    course_handicap,
    course_handicap_raw,
    handicap_index_from_differentials,
    low_handicap_index,
    match_play_strokes,
    round_counts_for_handicap,
    round_half_up,
    score_differential,
)

_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}

# Samme faste kategori-sett som friends.py sin Category (ADR-036 fase 1),
# duplisert her fremfor importert for å unngå en tvers-router-avhengighet
# for én enkelt type -- samme pragmatiske valg som skjemaets doble CHECK.
_FriendCategory = Literal[
    "spouse", "close_family", "extended_family", "close_friends",
    "golf_friends", "colleagues", "business", "classmates",
    "acquaintances", "other",
]

router = APIRouter(tags=["rounds"])


# ---------------------------------------------------------------------------
# Global banekatalog for egendefinerte (ikke-teeoff) baner
# ---------------------------------------------------------------------------

class PersonalCourseHoleIn(BaseModel):
    hole_number: int = Field(ge=1, le=18)
    par: int = Field(ge=3, le=6)
    stroke_index: int = Field(ge=1, le=18)


class PersonalCourseTeeRatingIn(BaseModel):
    gender: Literal["m", "f"]
    course_rating: float
    slope_rating: int = Field(ge=55, le=155)
    par: int


class PersonalCourseTeeIn(BaseModel):
    name: str
    ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)


class PersonalCourseCreate(BaseModel):
    name: str
    holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
    tees: list[PersonalCourseTeeIn] = Field(min_length=1)


class PersonalCourseOut(BaseModel):
    id: str
    name: str


class TeeOption(BaseModel):
    name: str
    genders: list[Literal["m", "f"]]


class PersonalCourseDetail(BaseModel):
    id: str
    name: str
    tees: list[TeeOption]


@router.get("/personal-courses", response_model=list[PersonalCourseOut])
async def search_personal_courses(
    q: str = "",
    user: CurrentUser = Depends(get_current_user),
) -> list[PersonalCourseOut]:
    async with plain_connection() as conn:
        rows = await conn.fetch(
            "SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20",
            f"%{q.strip()}%",
        )
    return [PersonalCourseOut(**dict(r)) for r in rows]


@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
async def get_personal_course(
    personal_course_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseDetail:
    async with plain_connection() as conn:
        course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id)
        if course_row is None:
            raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
        tee_rows = await conn.fetch(
            """
            SELECT t.name AS tee_name, r.gender
            FROM personal_course_tee t
            JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
            WHERE t.personal_course_id = $1
            ORDER BY t.name
            """,
            personal_course_id,
        )
    tees: dict[str, list[str]] = {}
    for r in tee_rows:
        tees.setdefault(r["tee_name"], []).append(r["gender"])
    return PersonalCourseDetail(
        id=course_row["id"],
        name=course_row["name"],
        tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
    )


@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
async def create_personal_course(
    body: PersonalCourseCreate,
    user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
    numbers = sorted(h.hole_number for h in body.holes)
    indexes = sorted(h.stroke_index for h in body.holes)
    if numbers != list(range(1, 19)) or indexes != list(range(1, 19)):
        raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")

    async with plain_connection() as conn, translate_db_errors():
        async with conn.transaction():
            course_row = await conn.fetchrow(
                "INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name",
                body.name.strip(),
                user.user_id,
            )
            for h in body.holes:
                await conn.execute(
                    "INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
                    course_row["id"],
                    h.hole_number,
                    h.par,
                    h.stroke_index,
                )
            for t in body.tees:
                tee_row = await conn.fetchrow(
                    "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
                    course_row["id"],
                    t.name.strip(),
                )
                for r in t.ratings:
                    await conn.execute(
                        """
                        INSERT INTO personal_course_tee_rating
                            (personal_course_tee_id, gender, course_rating, slope_rating, par)
                        VALUES ($1, $2, $3, $4, $5)
                        """,
                        tee_row["id"],
                        r.gender,
                        r.course_rating,
                        r.slope_rating,
                        r.par,
                    )
    return PersonalCourseOut(**dict(course_row))


# ---------------------------------------------------------------------------
# Offisiell bane fra teeoff (ADR-019/ADR-033 Beslutning C) -- IKKE org-scopet,
# ulikt courses.py sine tilsvarende endepunkter (frittstående runder har
# ingen organisasjon). Rent lese-søk, ingen import/persistering av banen.
# ---------------------------------------------------------------------------

class OfficialFacility(BaseModel):
    slug: str
    name: str
    city: str | None
    county: str | None


class OfficialCourseOption(BaseModel):
    teeoff_course_id: int
    name: str
    is_main_course: bool
    tees: list[TeeOption]


class OfficialFacilityDetail(BaseModel):
    slug: str
    name: str
    courses: list[OfficialCourseOption]


@router.get("/rounds/official-search", response_model=list[OfficialFacility])
async def search_official_courses_for_round(
    q: str = "",
    user: CurrentUser = Depends(get_current_user),
) -> list[OfficialFacility]:
    try:
        facilities = await teeoff_client.search_facilities(q)
    except teeoff_client.TeeoffUnavailableError:
        raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
    return [
        OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"))
        for f in facilities
    ]


class NearbyFacility(OfficialFacility):
    distance_km: float


def _haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    r = 6371.0
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    dphi = math.radians(lat2 - lat1)
    dlambda = math.radians(lng2 - lng1)
    a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
    return 2 * r * math.asin(math.sqrt(a))


# MERK: MÅ registreres FØR /rounds/official-search/{slug} under, ellers
# tolker FastAPI "nearby" som en ugyldig slug-verdi til den ruten (samme
# lærdom som ADR-020s "by-code" måtte registreres før {tournament_id}).
@router.get("/rounds/official-search/nearby", response_model=list[NearbyFacility])
async def nearby_official_courses_for_round(
    lat: float = Query(...),
    lng: float = Query(...),
    limit: int = Query(default=5, ge=1, le=20),
    user: CurrentUser = Depends(get_current_user),
) -> list[NearbyFacility]:
    try:
        facilities = await teeoff_client.search_facilities("")
    except teeoff_client.TeeoffUnavailableError:
        raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
    with_coords = [f for f in facilities if f.get("lat") is not None and f.get("lng") is not None]
    scored = sorted(with_coords, key=lambda f: _haversine_km(lat, lng, f["lat"], f["lng"]))
    return [
        NearbyFacility(
            slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"),
            distance_km=round(_haversine_km(lat, lng, f["lat"], f["lng"]), 1),
        )
        for f in scored[:limit]
    ]


@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
async def get_official_facility_for_round(
    slug: str,
    user: CurrentUser = Depends(get_current_user),
) -> OfficialFacilityDetail:
    try:
        facility = await teeoff_client.get_facility(slug)
    except teeoff_client.TeeoffNotFoundError:
        raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
    except teeoff_client.TeeoffUnavailableError:
        raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")

    courses: list[OfficialCourseOption] = []
    for c in facility.get("courses", []):
        if len(c.get("holes") or []) != 18:
            continue
        tees: list[TeeOption] = []
        for t in c.get("tees") or []:
            genders: list[str] = []
            if t.get("cr_men") is not None and t.get("slope_men") is not None:
                genders.append("m")
            if t.get("cr_women") is not None and t.get("slope_women") is not None:
                genders.append("f")
            if genders:
                tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
        courses.append(
            OfficialCourseOption(
                teeoff_course_id=c["id"],
                name=c["name"],
                is_main_course=bool(c.get("is_main_course")),
                tees=tees,
            )
        )
    return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)


# ---------------------------------------------------------------------------
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
# ---------------------------------------------------------------------------
#
# Returnerer (par_per_hole, stroke_index_per_hole, tee_names) og en
# funksjon for å slå opp (course_rating, slope_rating, par) for en gitt
# tee+kjønn -- felles for BÅDE teeoff (live) og personal_course (lagret).

class _ResolvedCourse:
    def __init__(self, holes: list[tuple[int, int, int]], course_name: str):
        # holes: liste av (hole_number, par, stroke_index), sortert 1..18
        self.holes = holes
        self.course_name = course_name
        self._ratings: dict[tuple[str, str], tuple[float, int, int]] = {}

    def add_rating(self, tee_name: str, gender: str, course_rating: float, slope_rating: int, par: int) -> None:
        self._ratings[(tee_name, gender)] = (course_rating, slope_rating, par)

    def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
        return self._ratings.get((tee_name, gender))

    def tee_options(self) -> list["TeeOption"]:
        names: dict[str, list[Literal["m", "f"]]] = {}
        for tee_name, gender in self._ratings:
            names.setdefault(tee_name, []).append(gender)  # type: ignore[arg-type]
        return [TeeOption(name=n, genders=sorted(g)) for n, g in names.items()]


async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
    try:
        facility = await teeoff_client.get_facility(facility_slug)
    except teeoff_client.TeeoffNotFoundError:
        raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
    except teeoff_client.TeeoffUnavailableError:
        raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")

    course_data = next((c for c in facility.get("courses", []) if c.get("id") == teeoff_course_id), None)
    if course_data is None:
        raise app_error(404, "NOT_FOUND", "Banen finnes ikke på dette anlegget i teeoff.")

    raw_holes = course_data.get("holes") or []
    if len(raw_holes) != 18:
        raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull i teeoff ennå.")
    for h in raw_holes:
        if h.get("par") is None or h.get("hcp_index") is None:
            raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.")

    # Samme "kun ett banenavn -- hopp over duplikat anleggsnavn"-regel som
    # `courses.py` sin `import_official_course` (2026-07-25).
    if len(facility.get("courses", [])) == 1:
        course_name = facility.get("name") or "Bane"
    else:
        course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}"
    resolved = _ResolvedCourse(
        holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes),
        course_name=course_name,
    )
    for t in course_data.get("tees") or []:
        name = t.get("name") or "Tee"
        if t.get("cr_men") is not None and t.get("slope_men") is not None:
            resolved.add_rating(name, "m", float(t["cr_men"]), int(t["slope_men"]), sum(h[1] for h in resolved.holes))
        if t.get("cr_women") is not None and t.get("slope_women") is not None:
            resolved.add_rating(name, "f", float(t["cr_women"]), int(t["slope_women"]), sum(h[1] for h in resolved.holes))
    return resolved


async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
    async with plain_connection() as conn:
        course_row = await conn.fetchrow("SELECT name FROM personal_course WHERE id = $1", personal_course_id)
        if course_row is None:
            raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
        hole_rows = await conn.fetch(
            "SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1",
            personal_course_id,
        )
        tee_rows = await conn.fetch(
            """
            SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
            FROM personal_course_tee t
            JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
            WHERE t.personal_course_id = $1
            """,
            personal_course_id,
        )
    resolved = _ResolvedCourse(
        holes=sorted((h["hole_number"], h["par"], h["stroke_index"]) for h in hole_rows),
        course_name=course_row["name"],
    )
    for t in tee_rows:
        resolved.add_rating(t["tee_name"], t["gender"], float(t["course_rating"]), t["slope_rating"], t["par"])
    return resolved


async def _resolve_round_course(round_row) -> _ResolvedCourse:
    if round_row["course_source"] == "teeoff":
        return await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
    return await _resolve_personal_course(round_row["personal_course_id"])


# ---------------------------------------------------------------------------
# Sider og match-play-håndtering (ADR-039) -- porterer det allerede
# bevisste, produksjonskjørte mønsteret fra `app/handicap.py` sin
# `compute_and_store_side_handicaps`/`relative_strokes_for_match`
# (org-scopede turnering-matcher) til frittstående runder. SAMME motor
# (handicap_engine.py), samme allowance-familie -- ingen ny regnelogikk
# for match/fourball/foursome/greensome/scramble, kun andre tabeller.
# Ingen allowance_override-støtte i v1 (kun DEFAULT_MATCHPLAY_ALLOWANCES).
# ---------------------------------------------------------------------------

async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> None:
    """Beregn og lagre round_participant.playing_handicap for begge sider.

    Rører ALDRI course_handicap_snapshot (den absolutte, individuelle WHS-
    verdien -- brukt uendret av slagspill-pipelinen) -- playing_handicap er
    en egen, ny kolonne kun for den format-relative match-play-enheten.
    """
    engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
    config = parse_allowance_config(engine_format, None)
    expected = _SIDE_PLAYER_COUNT[play_format]

    sides = await conn.fetch("SELECT id::text AS id FROM round_side WHERE round_id = $1", round_id)
    for side in sides:
        participants = await conn.fetch(
            "SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_side_id = $1",
            side["id"],
        )
        if len(participants) != expected or any(p["course_handicap_snapshot"] is None for p in participants):
            continue  # ikke komplett ennå, eller mangler HCP-sporing for noen

        if not config.use_handicap:
            for p in participants:
                await conn.execute("UPDATE round_participant SET playing_handicap = 0 WHERE id = $1", p["id"])
            continue

        course_handicaps = [float(p["course_handicap_snapshot"]) for p in participants]
        if engine_format in SIDE_IS_UNIT:
            playing = config.strategy.playing_handicap(course_handicaps)
            for p in participants:
                await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
        else:
            for p, ch in zip(participants, course_handicaps):
                playing = config.strategy.playing_handicap([ch])
                await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])


async def _relative_strokes_for_round(conn, round_id: str, play_format: str) -> dict[str, int]:
    """Relative slag per scoringsenhet (porterer relative_strokes_for_match).

    Nøkkel = round_participant_id for match/fourball (fourball: begge sidene
    samlet i ÉN liste før match_play_strokes, jf. R&A-regelen om at laveste
    av alle fire spiller av scratch). Nøkkel = round_side_id for foursome/
    greensome/scramble. Returnerer {} hvis playing_handicap ikke er klart
    for én eller flere enheter ennå.
    """
    engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
    config = parse_allowance_config(engine_format, None)

    if engine_format in SIDE_IS_UNIT:
        rows = await conn.fetch(
            """
            SELECT DISTINCT ON (round_side_id) round_side_id::text AS unit, playing_handicap
            FROM round_participant WHERE round_id = $1 AND round_side_id IS NOT NULL
            ORDER BY round_side_id
            """,
            round_id,
        )
    else:
        rows = await conn.fetch(
            "SELECT id::text AS unit, playing_handicap FROM round_participant "
            "WHERE round_id = $1 AND round_side_id IS NOT NULL",
            round_id,
        )

    if not rows or any(r["playing_handicap"] is None for r in rows):
        return {}

    absolutes = [r["playing_handicap"] for r in rows]
    relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
    return {r["unit"]: v for r, v in zip(rows, relative)}


async def _check_side_capacity(
    conn, side_id: str, play_format: str, exclude_participant_id: str | None = None
) -> None:
    """Avvis tydelig FØR en deltaker tildeles en side som allerede har
    forventet antall spillere for formatet (2026-07-28 oppfølging) -- i
    stedet for å oppdage overtallet først ved fullføring. `exclude_
    participant_id` unngår at en deltaker som allerede ER på siden (f.eks.
    en no-op-reassignment til samme side) teller seg selv ut av plass."""
    expected = _SIDE_PLAYER_COUNT[play_format]
    current_count = await conn.fetchval(
        "SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
        side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
    )
    if current_count >= expected:
        raise app_error(
            409, "SIDE_FULL",
            f"Denne siden har allerede {expected} spiller(e) -- maks for {play_format}.",
        )


async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[bool, str | None]:
    """Er rundens deltaker-/side-oppsett komplett for FORMATET (2026-07-28
    oppfølging): match/skins/fourball/foursome/greensome/scramble krever
    flere spillere enn slagspill -- ingenting fanget dette opp før nå.
    Returnerer (komplett, forklarende melding hvis ikke)."""
    if play_format == "stroke":
        return True, None

    if play_format == "skins":
        total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id)
        if total < _SKINS_MIN_PARTICIPANTS:
            return False, f"Skins krever minst {_SKINS_MIN_PARTICIPANTS} spillere -- runden har {total}."
        return True, None

    # To-sidede formater (match/fourball/foursome/greensome/scramble_*).
    expected = _SIDE_PLAYER_COUNT[play_format]
    sides = await conn.fetch("SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id)
    if len(sides) != 2:
        return False, "Denne spilleformen krever nøyaktig to sider -- opprett begge før runden fullføres."

    unassigned = await conn.fetchval(
        "SELECT count(*) FROM round_participant WHERE round_id = $1 AND round_side_id IS NULL", round_id
    )
    if unassigned > 0:
        return False, f"{unassigned} deltaker(e) er ikke tildelt noen side ennå."

    for side in sides:
        count = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_side_id = $1", side["id"])
        if count != expected:
            label = side["label"] or "En side"
            return False, f"{label} har {count} spiller(e), men trenger nøyaktig {expected}."
    return True, None


# ---------------------------------------------------------------------------
# Runder
# ---------------------------------------------------------------------------

# Statistikknivå per deltaker (2026-07-24): kun slag er strengt tatt
# nødvendig for resultat/HCP -- putter og "flere detaljer" er valgfritt,
# av som default. GIR krever putts og vises derfor ikke i strokes_only.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]

# ADR-039: formater som bruker et to-siders-konsept (Beslutning A). "match"
# er rundens eget navn for singles match play -- mappes til motorens eget
# Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap
# kalles, ikke noe sted ellers.
_TWO_SIDED_FORMATS = {"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"}


def _viewer_match_status_text(lead: int, holes_remaining: int, is_closed: bool) -> str:
    """Kort, VIEWER-relativ matchstatus (2026-07-29, dashbord-rundeboks-
    runden) -- positivt lead betyr alltid "spørrende bruker leder", uansett
    hvilken side de faktisk sitter på. Speiler MatchState.describe() i
    handicap_engine.py, men uten bokstavelig "(A)"/"(B)" og med et eksplisitt
    Vunnet/Tapt-prefiks for et avgjort resultat (siden "2&1" alene ikke
    sier hvem sin side det gjaldt når det ikke lenger er en ren leder-visning)."""
    margin = abs(lead)
    if is_closed:
        suffix = f"{margin}&{holes_remaining}" if holes_remaining > 0 else f"{margin} UP"
        return f"Vunnet {suffix}" if lead > 0 else f"Tapt {suffix}"
    if lead == 0:
        return "AS"
    return f"{margin} UP" if lead > 0 else f"{margin} DOWN"
_ROUND_FORMAT_TO_ENGINE_FORMAT = {
    "match": "singles",
    "fourball": "fourball",
    "foursome": "foursome",
    "greensome": "greensome",
    "scramble_2": "scramble_2",
    "scramble_4": "scramble_4",
}
# Antall SPILLERE forventet per side -- IKKE det samme som app.handicap sin
# FORMAT_UNIT_SIZE (som er HCP-BEREGNINGS-enheten: fourball beregnes
# individuelt der, men har likevel 2 spillere per side for selve
# matchresultat-sammenligningen).
_SIDE_PLAYER_COUNT = {"match": 1, "fourball": 2, "foursome": 2, "greensome": 2, "scramble_2": 2, "scramble_4": 4}
# Bekreftet av bruker (2026-07-28): 2 spillere gjør skins i praksis
# identisk med en vanlig match -- 3+ er der en oppsamlet, uavgjort pott
# faktisk gir mening som eget spillformat.
_SKINS_MIN_PARTICIPANTS = 3


class RoundCreate(BaseModel):
    course_source: Literal["teeoff", "custom"]
    teeoff_facility_slug: str | None = None
    teeoff_course_id: int | None = None
    personal_course_id: str | None = None
    tee_name: str
    # Valgfritt eget navn på runden (2026-07-25) -- mangler det, faller
    # visningen tilbake til course_name_snapshot, som alltid er satt.
    name: str | None = Field(default=None, max_length=200)
    played_at: date
    start_hole: int = Field(default=1, ge=1, le=18)
    holes_planned: Literal[9, 18] = 18
    stat_level: StatLevel = "strokes_only"
    # Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
    # tidsbruk (completed_at - started_at) når runden fullføres.
    started_at: datetime | None = None
    # Selvdeklarert spilleform (ADR-038 Beslutning D, utvidet ADR-039) --
    # ingen egen "oppdag selv"-motor for frittstående runder, så dette taes
    # brukerens ord for. match/fourball/foursome/greensome/scramble_*
    # trenger sider (opprettes separat via POST .../sides, se ADR-039
    # Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
    play_format: Literal[
        "stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"
    ] = "stroke"
    # Frontend forhåndsutfyller denne til True når play_format='match'
    # (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
    exclude_owner_from_handicap: bool = False
    # KUN meningsfulle (og påkrevd) når play_format='skins' -- ADR-039
    # Beslutning D. Brukeren som setter opp runden velger begge uavhengig.
    skins_scoring: Literal["net", "gross"] | None = None
    skins_tie_handling: Literal["carry", "split"] | None = None
    # ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
    # innsyn (venner/offentligheten). Trygg standard 'private', samme
    # filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
    # En lagt-til medspiller ser ALLTID runden uansett, uendret av dette.
    visibility_mode: Literal["public", "private", "friends"] = "private"
    # KUN meningsfullt (og bør være ikke-tom) når visibility_mode='friends'
    # -- hvilke av EGNE private kategorier (se friends.py sin Category) som
    # får se runden. Tomt/utelatt for public/private.
    visible_categories: list[_FriendCategory] = []
    # Flere flighter i én "vanlig" runde (migrasjon 035) -- klient-generert
    # UUID som binder flere `round`-rader sammen. Utelatt/null for en vanlig
    # solo-/enkeltflight-runde. Klienten genererer denne selv (kun ETT sted:
    # round-detail.tsx sin "Legg til en flight til"), enten en FERSK verdi
    # (første gang en andre flight legges til en tidligere gruppeløs runde,
    # satt på DEN runden via PATCH FØRST) eller en allerede eksisterende
    # gruppes id (tredje+ flight). Se GET .../flight-group.
    flight_group_id: str | None = None


class RoundParticipantOut(BaseModel):
    id: str
    user_id: str | None
    guest_name: str | None
    # Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
    # for en lenket bruker, som allerede har sin egen kontos e-post.
    guest_email: str | None
    # Alltid utfylt -- guest_name for gjester, ellers en LEVENDE oppslått
    # app_user.display_name for enhver lenket bruker (eier ELLER medspiller,
    # ADR-036 fase 3-utvidelsen 2026-07-26) -- ikke en snapshot, samme
    # "vis gjeldende navn"-mønster som org-medlemslister/chat-forfatter.
    display_name: str
    is_owner: bool
    gender: str
    # Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
    # deltakers, f.eks. et kjønnsdelt utslag eller et bevisst enklere valg.
    # Redigerbar av eieren via PATCH .../participants/{id}, kun for DENNE
    # runden (endrer aldri spillerens profil).
    tee_name_snapshot: str
    handicap_index_snapshot: float | None
    course_handicap_snapshot: int | None
    counts_for_handicap: bool
    score_differential: float | None
    stat_level: StatLevel
    # ADR-038 Beslutning C -- manuell opt-out, uavhengig av
    # counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller
    # runde-eieren), se update_participant.
    exclude_from_handicap: bool
    # ADR-039 Beslutning A -- hvilken side deltakeren er på (match/fourball/
    # foursome/greensome/scramble_*), null for slagspill/skins (ingen sider).
    round_side_id: str | None
    # ADR-039 Beslutning B -- den format-avledede, RELATIVE match-play-
    # enheten (fra AllowanceStrategy), IKKE samme som course_handicap_
    # snapshot (som forblir den absolutte, individuelle WHS-verdien).
    playing_handicap: int | None


class RoundSideOut(BaseModel):
    id: str
    label: str | None


class RoundOut(BaseModel):
    id: str
    course_source: str
    name: str | None
    course_name_snapshot: str
    tee_name_snapshot: str
    played_at: date
    start_hole: int
    holes_planned: int
    play_format: str
    skins_scoring: str | None
    skins_tie_handling: str | None
    started_at: str | None
    completed_at: str | None
    participants: list[RoundParticipantOut]
    # ADR-039 Beslutning A -- tom liste for formater uten sider (slagspill/skins).
    sides: list[RoundSideOut]
    # 2026-07-28 oppfølging -- match/skins/fourball/foursome/greensome/
    # scramble krever flere spillere enn slagspill, håndhevet i
    # complete_round. setup_message er None når setup_complete er sann.
    setup_complete: bool
    setup_message: str | None
    # Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036
    # fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra
    # round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme
    # dag, siden feltet nå er VIEWER-relativt, ikke alltid eierens.
    # `my_score_to_par` er None helt til minst ett hull er registrert.
    my_holes_played: int
    my_total_score: int | None
    my_score_to_par: int | None
    # 2026-07-29 (dashbord-rundeboks-runden): netto til-par, KUN for
    # slagspill (`play_format == "stroke"`) og kun når HCP faktisk spores
    # for spilleren (course_handicap_snapshot satt). Samme allokerings-
    # algoritme som list_holes/update_hole -- ikke en egen utregning.
    my_net_score_to_par: int | None
    # Viewer-relativ løpende matchstatus for to-sidede formater (match/
    # fourball/foursome/greensome/scramble), None for slagspill/skins eller
    # før sidene er komplette. "1 UP"/"AS"/"1 DOWN"/"Vunnet 2&1"/"Tapt 3&2"
    # -- ALDRI backend sin bokstavelige "(A)"/"(B)", speiler alltid
    # SPØRRENDE bruker sin egen side. my_match_lead er samme fortegn (>0 =
    # leder, <0 = ligger under) -- brukt av frontend til fargelegging uten
    # å måtte parse teksten.
    my_match_status: str | None
    my_match_lead: int | None
    # ADR-036 Beslutning B (fase 2, 2026-07-28) -- kun eieren (eller en
    # medspiller, som ser dette som ren informasjon) trenger å se/endre
    # disse -- eksponert her siden dette ER den autentiserte eier-/
    # medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen.
    visibility_mode: str
    visible_categories: list[str]
    # Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
    flight_group_id: str | None


async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
    round_row = await conn.fetchrow(
        """
        SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
               played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
               started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id
        FROM round WHERE id = $1
        """,
        round_id,
    )
    visible_category_rows = await conn.fetch(
        "SELECT category FROM round_visible_category WHERE round_id = $1", round_id
    )
    participant_rows = await conn.fetch(
        """
        SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
               COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
               rp.is_owner, rp.gender, rp.tee_name_snapshot,
               rp.handicap_index_snapshot::float AS handicap_index_snapshot,
               rp.course_handicap_snapshot, rp.counts_for_handicap,
               rp.score_differential::float AS score_differential, rp.stat_level,
               rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
               rp.playing_handicap
        FROM round_participant rp
        LEFT JOIN app_user au ON au.id = rp.user_id
        WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
        """,
        round_id,
    )
    side_rows = await conn.fetch(
        "SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
    )
    # Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
    # medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
    # fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad
    # i det usannsynlige tilfellet spørreren ikke har noen egen
    # deltaker-rad (bør ikke skje, siden tilgang allerede er gatet til
    # eier-eller-deltaker av kallerne).
    viewer_participant_id = next((r["id"] for r in participant_rows if r["user_id"] == viewer_user_id), None)
    target_id = viewer_participant_id or next((r["id"] for r in participant_rows if r["is_owner"]), None)
    viewer_agg = await conn.fetchrow(
        """
        SELECT COUNT(*) FILTER (WHERE played) AS played_count,
               COALESCE(SUM(score) FILTER (WHERE played), 0) AS total_score,
               COALESCE(SUM(par) FILTER (WHERE played), 0) AS total_par
        FROM round_hole WHERE round_participant_id = $1
        """,
        target_id,
    )
    my_holes_played = viewer_agg["played_count"] if viewer_agg else 0
    my_total_score = viewer_agg["total_score"] if my_holes_played > 0 else None
    my_score_to_par = (
        viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None
    )

    # Netto til-par (2026-07-29, dashbord-rundeboks-runden) -- kun slagspill,
    # kun når HCP faktisk spores for spilleren. Bruker samme allokerings-
    # algoritme som list_holes/update_hole -- ingen ny utregningsmåte.
    my_net_score_to_par: int | None = None
    if round_row["play_format"] == "stroke" and my_holes_played > 0:
        target_row = next((r for r in participant_rows if r["id"] == target_id), None)
        if target_row is not None and target_row["course_handicap_snapshot"] is not None:
            hole_rows = await conn.fetch(
                "SELECT hole_number, stroke_index, played, score FROM round_hole "
                "WHERE round_participant_id = $1 ORDER BY hole_number",
                target_id,
            )
            if len(hole_rows) == 18:
                allocation = allocate_strokes_by_index(
                    target_row["course_handicap_snapshot"], [r["stroke_index"] for r in hole_rows]
                )
                strokes_by_hole = dict(zip(range(1, 19), allocation))
                total_strokes_received = sum(
                    strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None
                )
                my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received

    # Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
    # Gjenbruker _build_format_result uendret (samme sannhetskilde som selve
    # scorekortet) og flipper fortegnet slik at positivt ALLTID betyr
    # "spørrende bruker leder", uansett hvilken side de faktisk sitter på.
    my_match_status: str | None = None
    my_match_lead: int | None = None
    if round_row["play_format"] in _TWO_SIDED_FORMATS:
        target_row = next((r for r in participant_rows if r["id"] == target_id), None)
        viewer_side_id = target_row["round_side_id"] if target_row is not None else None
        if viewer_side_id is not None and len(side_rows) == 2:
            fr = await _build_format_result(conn, round_id)
            if fr.ready and fr.match_lead is not None and fr.match_holes_played:
                sorted_side_ids = sorted(r["id"] for r in side_rows)
                viewer_is_side_a = viewer_side_id == sorted_side_ids[0]
                lead = fr.match_lead if viewer_is_side_a else -fr.match_lead
                my_match_lead = lead
                my_match_status = _viewer_match_status_text(
                    lead, fr.match_holes_remaining or 0, fr.match_is_closed or False
                )

    setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
    return RoundOut(
        id=round_row["id"],
        course_source=round_row["course_source"],
        name=round_row["name"],
        course_name_snapshot=round_row["course_name_snapshot"],
        tee_name_snapshot=round_row["tee_name_snapshot"],
        played_at=round_row["played_at"],
        start_hole=round_row["start_hole"],
        holes_planned=round_row["holes_planned"],
        play_format=round_row["play_format"],
        skins_scoring=round_row["skins_scoring"],
        skins_tie_handling=round_row["skins_tie_handling"],
        started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
        completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
        participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
        sides=[RoundSideOut(**dict(r)) for r in side_rows],
        setup_complete=setup_complete,
        setup_message=setup_message,
        my_holes_played=my_holes_played,
        my_total_score=my_total_score,
        my_score_to_par=my_score_to_par,
        my_net_score_to_par=my_net_score_to_par,
        my_match_status=my_match_status,
        my_match_lead=my_match_lead,
        visibility_mode=round_row["visibility_mode"],
        visible_categories=[r["category"] for r in visible_category_rows],
        flight_group_id=round_row["flight_group_id"],
    )


async def _create_participant(
    conn,
    round_id: str,
    resolved: _ResolvedCourse,
    tee_name: str,
    *,
    user_id: str | None,
    guest_name: str | None,
    is_owner: bool,
    gender: str,
    handicap_index: float | None,
    stat_level: StatLevel = "strokes_only",
    guest_email: str | None = None,
    exclude_from_handicap: bool = False,
    round_side_id: str | None = None,
    individual_round_hole: bool = True,
) -> str:
    rating = resolved.rating_for(tee_name, gender)
    course_rating = slope_rating = tee_par = None
    course_handicap_snapshot = None
    if rating is not None:
        course_rating, slope_rating, tee_par = rating
        if handicap_index is not None:
            course_handicap_snapshot = course_handicap(handicap_index, slope_rating, course_rating, tee_par)

    participant_row = await conn.fetchrow(
        """
        INSERT INTO round_participant
            (round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
             course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
             stat_level, exclude_from_handicap, round_side_id)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
        RETURNING id::text AS id
        """,
        round_id,
        user_id,
        guest_name,
        guest_email,
        is_owner,
        gender,
        tee_name,
        handicap_index,
        course_rating,
        slope_rating,
        tee_par,
        course_handicap_snapshot,
        stat_level,
        exclude_from_handicap,
        round_side_id,
    )
    participant_id = participant_row["id"]

    # ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/
    # scramble) lagrer scoren PÅ SIDEN (round_hole.round_side_id, opprettet
    # samtidig med selve siden, se create_round_side), ikke per deltaker --
    # ingen individuelle round_hole-rader for disse.
    if individual_round_hole:
        for hole_number, par, stroke_index in resolved.holes:
            await conn.execute(
                "INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
                participant_id,
                hole_number,
                par,
                stroke_index,
            )
    return participant_id


@router.post("/rounds", response_model=RoundOut, status_code=201)
async def create_round(
    body: RoundCreate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
    if body.course_source == "teeoff":
        if not body.teeoff_facility_slug or not body.teeoff_course_id:
            raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
        resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
    else:
        if not body.personal_course_id:
            raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
        resolved = await _resolve_personal_course(body.personal_course_id)

    # ADR-039 Beslutning D -- skins-konfigurasjon er påkrevd for skins, og
    # gir ikke mening for noe annet format.
    if body.play_format == "skins":
        if body.skins_scoring is None or body.skins_tie_handling is None:
            raise app_error(
                400, "VALIDATION_FAILED",
                "skins_scoring og skins_tie_handling er påkrevd når play_format='skins'.",
            )
    elif body.skins_scoring is not None or body.skins_tie_handling is not None:
        raise app_error(400, "VALIDATION_FAILED", "skins_scoring/skins_tie_handling gir kun mening for play_format='skins'.")

    if body.flight_group_id is not None:
        try:
            uuid.UUID(body.flight_group_id)
        except ValueError:
            raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")

    async with plain_connection() as conn:
        owner_row = await conn.fetchrow(
            "SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
            user.user_id,
        )
        if owner_row is None or owner_row["gender"] is None:
            raise app_error(400, "VALIDATION_FAILED", "Fullfør profilen din (kjønn/HCP) før du registrerer en runde.")

        if resolved.rating_for(body.tee_name, owner_row["gender"]) is None:
            raise app_error(
                400, "VALIDATION_FAILED",
                "Valgt utslag har ingen rating for ditt registrerte kjønn på denne banen.",
            )

        async with conn.transaction(), translate_db_errors():
            round_row = await conn.fetchrow(
                """
                INSERT INTO round
                    (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
                     personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
                     start_hole, holes_planned, started_at, name, play_format,
                     skins_scoring, skins_tie_handling, visibility_mode, flight_group_id)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
                RETURNING id::text AS id
                """,
                user.user_id,
                body.course_source,
                body.teeoff_facility_slug,
                str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
                body.personal_course_id,
                resolved.course_name,
                body.tee_name,
                body.played_at,
                body.start_hole,
                body.holes_planned,
                body.started_at,
                body.name.strip() if body.name and body.name.strip() else None,
                body.play_format,
                body.skins_scoring,
                body.skins_tie_handling,
                body.visibility_mode,
                body.flight_group_id,
            )
            round_id = round_row["id"]

            await _create_participant(
                conn, round_id, resolved, body.tee_name,
                user_id=user.user_id, guest_name=None, is_owner=True,
                gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
                stat_level=body.stat_level,
                exclude_from_handicap=body.exclude_owner_from_handicap,
                individual_round_hole=body.play_format not in SIDE_IS_UNIT,
            )

            if body.visibility_mode == "friends" and body.visible_categories:
                for category in sorted(set(body.visible_categories)):
                    await conn.execute(
                        "INSERT INTO round_visible_category (round_id, category) VALUES ($1, $2)",
                        round_id,
                        category,
                    )

        # Rundevarsler (2026-07-28 oppfølging av ADR-036 fase 2) -- kun
        # meningsfullt for 'public'/'friends' ('private' gir alltid en tom
        # liste). Sendt EN GANG, her, ikke gjenberegnet ved hver visning.
        if body.visibility_mode != "private":
            watcher_ids = await _friends_who_can_see_round(conn, round_id, user.user_id)
            if watcher_ids:
                owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
                round_label = (body.name.strip() if body.name and body.name.strip() else None) or resolved.course_name
                for friend_id in watcher_ids:
                    await create_notification(
                        conn,
                        user_id=friend_id,
                        type="round",
                        message=f"{owner_name} startet en runde du kan følge ({round_label}).",
                        link_path=f"/watch/{round_id}",
                    )

        return await _load_round_out(conn, round_id, user.user_id)


@router.get("/rounds", response_model=list[RoundOut])
async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[RoundOut]:
    async with plain_connection() as conn:
        # ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller ser nå
        # runden i SIN EGEN liste også, ikke bare eieren -- bekreftet av
        # brukeren som ønsket allerede 2026-07-25.
        ids = await conn.fetch(
            """
            SELECT r.id::text AS id
            FROM round r
            WHERE r.owner_user_id = $1
               OR EXISTS(SELECT 1 FROM round_participant WHERE round_id = r.id AND user_id = $1)
            ORDER BY r.played_at DESC, r.created_at DESC
            """,
            user.user_id,
        )
        return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]


class RoundStatsSummary(BaseModel):
    rounds_completed: int
    avg_score_to_par: float | None
    avg_putts_per_18: float | None
    putts_tracked_rounds: int
    fairway_hit_pct: float | None
    fairway_left_pct: float | None
    fairway_right_pct: float | None
    fairway_tracked_holes: int
    gir_pct: float | None
    gir_tracked_holes: int
    green_miss_long_pct: float | None
    green_miss_short_pct: float | None
    green_miss_left_pct: float | None
    green_miss_right_pct: float | None
    green_miss_tracked_holes: int
    one_putt_pct: float | None
    putt_dist_one_pct: float | None
    putt_dist_two_pct: float | None
    putt_dist_three_plus_pct: float | None
    scrambling_pct: float | None
    sand_save_pct: float | None
    avg_chip_per_round: float | None
    avg_bunker_per_round: float | None
    avg_penalty_per_round: float | None
    avg_anyway_per_round: float | None
    # Til-par splittet på om en hendelse inntraff på HULLET -- pooler
    # enkelthull på tvers av alle runder (ikke per-runde-snitt, siden dette
    # er hull-nivå-betingelser), samme formel som `round-stats.tsx` sin
    # `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` bruker
    # for én runde.
    avg_to_par_with_gir: float | None
    avg_to_par_without_gir: float | None
    avg_to_par_fairway_hit: float | None
    avg_to_par_fairway_miss: float | None
    avg_to_par_with_bunker: float | None
    avg_to_par_without_bunker: float | None
    avg_to_par_with_anyway: float | None
    avg_to_par_without_anyway: float | None


class RoundStatsWindowSummary(BaseModel):
    window: str
    current: RoundStatsSummary
    previous: RoundStatsSummary


StatsWindow = Literal["last_round", "last_5", "last_10", "month", "year", "last_year", "all"]


def _resolve_stats_window(window: StatsWindow, ordered_rounds: list[tuple[str, date]]) -> tuple[set[str], set[str]]:
    """`ordered_rounds` er (round_id, played_at) sortert NYEST FØRST.
    Returnerer (gjeldende periode, forrige periode) som round_id-mengder.

    Forrige periode er ALLTID perioden av SAMME LENGDE rett før gjeldende
    periodes start -- rullerende for antall-baserte vinduer (samme antall
    runder rett før), rullerende for dato-baserte vinduer (samme antall
    DAGER rett før startdatoen). Én ensartet regel for alle vindutyper,
    ingen kalender-spesialtilfeller (unngår å måtte definere "forrige
    måned"/"forrige år" ulikt fra "forrige periode" for et rullerende
    vindu som "siste 365 dager").
    """
    if window == "all":
        return {r[0] for r in ordered_rounds}, set()

    if window in ("last_round", "last_5", "last_10"):
        n = {"last_round": 1, "last_5": 5, "last_10": 10}[window]
        current = ordered_rounds[:n]
        previous = ordered_rounds[n : 2 * n]
        return {r[0] for r in current}, {r[0] for r in previous}

    today = date.today()
    if window == "month":
        start = today.replace(day=1)
    elif window == "year":
        start = today.replace(month=1, day=1)
    elif window == "last_year":
        start = today - timedelta(days=365)
    else:
        raise ValueError(f"Ukjent vindu: {window}")

    length_days = (today - start).days + 1
    prev_end = start - timedelta(days=1)
    prev_start = prev_end - timedelta(days=length_days - 1)
    current = {r[0] for r in ordered_rounds if r[1] >= start}
    previous = {r[0] for r in ordered_rounds if prev_start <= r[1] <= prev_end}
    return current, previous


def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level_by_round: dict[str, str]) -> RoundStatsSummary:
    """Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
    enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
    gjeldende og forrige periode (se `_resolve_stats_window`)."""

    def avg(values: list[float]) -> float | None:
        return sum(values) / len(values) if values else None

    to_par_values: list[float] = []
    putt_totals: list[float] = []
    for round_id in round_ids:
        holes = by_round.get(round_id, [])
        played = [h for h in holes if h["played"] and h["score"] is not None]
        if played:
            to_par_values.append(sum(h["score"] - h["par"] for h in played))
        if stat_level_by_round.get(round_id) != "strokes_only":
            putt_totals.append(sum(h["putts"] if h["putts"] is not None else 2 for h in holes))

    all_played = [h for rid in round_ids for h in by_round.get(rid, []) if h["played"] and h["score"] is not None]

    fairway_tracked = [h for h in all_played if h["par"] != 3 and h["tee_shot_result"] is not None]
    fairway_hit_pct = avg([100.0 if h["tee_shot_result"] == "fairway" else 0.0 for h in fairway_tracked])
    fairway_left_pct = avg([100.0 if h["tee_shot_result"] == "left" else 0.0 for h in fairway_tracked])
    fairway_right_pct = avg([100.0 if h["tee_shot_result"] == "right" else 0.0 for h in fairway_tracked])

    def is_gir(h) -> bool:
        return h["score"] - h["putts"] <= h["par"] - 2

    gir_eligible = [h for h in all_played if h["putts"] is not None]
    gir_pct = avg([100.0 if is_gir(h) else 0.0 for h in gir_eligible])
    one_putt_pct = avg([100.0 if h["putts"] <= 1 else 0.0 for h in gir_eligible])
    # Puttfordeling (donut-vennlig, 3 gjensidig utelukkende bøtter) -- egne
    # eksakte grenser (==1/==2/>=3), SAMME bøtter som `round-stats.tsx` sin
    # `puttCategories` bruker per enkeltrunde, ikke samme grense som
    # `one_putt_pct` over (som bevisst bruker <=1 -- en litt annen,
    # allerede etablert "hvor ofte putter du bra"-rate, ikke en distribusjon).
    putt_dist_one_pct = avg([100.0 if h["putts"] == 1 else 0.0 for h in gir_eligible])
    putt_dist_two_pct = avg([100.0 if h["putts"] == 2 else 0.0 for h in gir_eligible])
    putt_dist_three_plus_pct = avg([100.0 if h["putts"] >= 3 else 0.0 for h in gir_eligible])

    # Retning på bomskudd mot green -- eget spor (approach_result), ikke
    # avledet av putte-basert GIR: en spiller kan registrere retning uten
    # noensinne å registrere putter (og omvendt), samme uavhengighet som
    # `round-stats.tsx` sin `missedGreen`/`missDir`.
    green_missed = [h for h in all_played if h["approach_result"] is not None and h["approach_result"] != "hit"]

    def miss_pct(direction: str) -> float | None:
        return avg([100.0 if h["approach_result"] == direction else 0.0 for h in green_missed])

    gir_missed = [h for h in gir_eligible if not is_gir(h)]
    scrambling_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in gir_missed])
    sand_eligible = [h for h in gir_missed if h["bunker_shot_count"] is not None and h["bunker_shot_count"] >= 1]
    sand_save_pct = avg([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in sand_eligible])

    # Til-par splittet på hull-nivå-hendelser (brukerens eksplisitte
    # instruks 2026-07-28) -- pooler ENKELTHULL på tvers av alle runder i
    # perioden, samme `diff()`-formel som `round-stats.tsx` sin
    # `avgToParWithGir`/`avgToParFairwayHit`/`avgToParWithBunker` (portert
    # uendret, anywayslag-splitten er en ny, konsekvent utvidelse av samme
    # mønster -- fantes ikke fra før for enkeltrunder).
    def diff(h) -> int:
        return h["score"] - h["par"]

    avg_to_par_with_gir = avg([diff(h) for h in gir_eligible if is_gir(h)])
    avg_to_par_without_gir = avg([diff(h) for h in gir_eligible if not is_gir(h)])

    avg_to_par_fairway_hit = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] == "fairway"])
    avg_to_par_fairway_miss = avg([diff(h) for h in fairway_tracked if h["tee_shot_result"] in ("left", "right")])

    bunker_eligible = [h for h in all_played if h["bunker_shot_count"] is not None]
    avg_to_par_with_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] >= 1])
    avg_to_par_without_bunker = avg([diff(h) for h in bunker_eligible if h["bunker_shot_count"] == 0])

    anyway_eligible = [h for h in all_played if h["anyway_strokes"] is not None]
    avg_to_par_with_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] >= 1])
    avg_to_par_without_anyway = avg([diff(h) for h in anyway_eligible if h["anyway_strokes"] == 0])

    def avg_per_round(field: str) -> float | None:
        totals = []
        for round_id in round_ids:
            eligible = [h for h in by_round.get(round_id, []) if h["played"] and h["score"] is not None and h[field] is not None]
            if eligible:
                totals.append(sum(h[field] for h in eligible))
        return avg(totals)

    return RoundStatsSummary(
        rounds_completed=len(round_ids),
        avg_score_to_par=avg(to_par_values),
        avg_putts_per_18=avg(putt_totals),
        putts_tracked_rounds=len(putt_totals),
        fairway_hit_pct=fairway_hit_pct,
        fairway_left_pct=fairway_left_pct,
        fairway_right_pct=fairway_right_pct,
        fairway_tracked_holes=len(fairway_tracked),
        gir_pct=gir_pct,
        gir_tracked_holes=len(gir_eligible),
        green_miss_long_pct=miss_pct("long"),
        green_miss_short_pct=miss_pct("short"),
        green_miss_left_pct=miss_pct("left"),
        green_miss_right_pct=miss_pct("right"),
        green_miss_tracked_holes=len(green_missed),
        one_putt_pct=one_putt_pct,
        putt_dist_one_pct=putt_dist_one_pct,
        putt_dist_two_pct=putt_dist_two_pct,
        putt_dist_three_plus_pct=putt_dist_three_plus_pct,
        scrambling_pct=scrambling_pct,
        sand_save_pct=sand_save_pct,
        avg_chip_per_round=avg_per_round("chip_count"),
        avg_bunker_per_round=avg_per_round("bunker_shot_count"),
        avg_penalty_per_round=avg_per_round("penalty_strokes"),
        avg_anyway_per_round=avg_per_round("anyway_strokes"),
        avg_to_par_with_gir=avg_to_par_with_gir,
        avg_to_par_without_gir=avg_to_par_without_gir,
        avg_to_par_fairway_hit=avg_to_par_fairway_hit,
        avg_to_par_fairway_miss=avg_to_par_fairway_miss,
        avg_to_par_with_bunker=avg_to_par_with_bunker,
        avg_to_par_without_bunker=avg_to_par_without_bunker,
        avg_to_par_with_anyway=avg_to_par_with_anyway,
        avg_to_par_without_anyway=avg_to_par_without_anyway,
    )


@router.get("/rounds/stats/summary", response_model=RoundStatsWindowSummary)
async def get_rounds_stats_summary(
    window: StatsWindow = Query("all"),
    user: CurrentUser = Depends(get_current_user),
) -> RoundStatsWindowSummary:
    """Aggregert statistikk over brukerens fullførte runder (egen
    deltaker-rad, ikke medspilleres), for et valgt tidsvindu -- pluss samme
    tall for FORRIGE periode av samme lengde til sammenligning (se
    `_resolve_stats_window`). Ingen paginering -- samme "hent alt, aggreger
    i Python"-mønster som `list_rounds`, forsvarlig i volum siden
    `round_hole` alltid har nøyaktig 18 rader per deltaker uansett
    holes_planned (se `_create_participant`).

    Putt/18-hull-regelen (brukerens eksplisitte instruks 2026-07-28): for
    hver fullført runde der puttsporing faktisk var på (stat_level ≠
    'strokes_only') telles ALLE 18 lagrede hull -- et hull uten registrert
    putt-verdi (uspilt, eller utenfor et 9-hulls spilleomfang) telles som 2
    putter. Runder uten puttsporing holdes helt utenfor putt-tallet.
    Alle andre andelstall (fairway/GIR/én-putt/scrambling/sand save/miss-
    retning) pooler kun FAKTISK registrerte hull -- samme formler som
    `round-stats.tsx` sin `computeStats()` bruker per enkeltrunde (portert
    hit, ikke en ny definisjon), ingen padding der brukeren ikke ba om det.
    """
    async with plain_connection() as conn:
        rows = await conn.fetch(
            """
            SELECT rp.round_id::text AS round_id, rp.stat_level, r.played_at,
                   rh.par, rh.played, rh.score, rh.putts,
                   rh.tee_shot_result, rh.approach_result, rh.chip_count,
                   rh.bunker_shot_count, rh.penalty_strokes, rh.anyway_strokes
            FROM round_participant rp
            JOIN round r ON r.id = rp.round_id
            JOIN round_hole rh ON rh.round_participant_id = rp.id
            WHERE rp.user_id = $1 AND r.completed_at IS NOT NULL
            ORDER BY r.played_at DESC, rh.hole_number
            """,
            user.user_id,
        )

    by_round: dict[str, list] = {}
    stat_level_by_round: dict[str, str] = {}
    played_at_by_round: dict[str, date] = {}
    for r in rows:
        by_round.setdefault(r["round_id"], []).append(r)
        stat_level_by_round[r["round_id"]] = r["stat_level"]
        played_at_by_round[r["round_id"]] = r["played_at"]

    # Distinkte runder, nyest først -- rekkefølgen `rows` allerede har
    # (ORDER BY r.played_at DESC) bevares siden Python-dicts er innsettings-
    # ordnet, ingen ny sortering nødvendig.
    ordered_rounds = [(rid, played_at_by_round[rid]) for rid in by_round]

    current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)

    return RoundStatsWindowSummary(
        window=window,
        current=_summarize_rounds(current_ids, by_round, stat_level_by_round),
        previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round),
    )


async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
    """Strengt eier-only -- for runde-forvaltning (rediger/slett metadata,
    legge til/fjerne deltakere). IKKE for lesing/scoreregistrering, se
    `_get_accessible_round_or_404`."""
    row = await conn.fetchrow("SELECT owner_user_id::text AS owner_user_id FROM round WHERE id = $1", round_id)
    if row is None:
        raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
    if row["owner_user_id"] != user_id:
        raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")


async def _get_accessible_round_or_404(conn, round_id: str, user_id: str):
    """Eier ELLER en lenket medspiller (round_participant.user_id) --
    ADR-036 fase 3-utvidelsen (2026-07-26): en medspiller kan se runden og
    registrere score for HELE flighten (bekreftet regel), men kan IKKE
    forvalte runden (se `_get_owned_round_or_404` for det)."""
    row = await conn.fetchrow(
        """
        SELECT r.owner_user_id::text AS owner_user_id,
               EXISTS(
                   SELECT 1 FROM round_participant
                   WHERE round_id = r.id AND user_id = $2
               ) AS is_linked_participant
        FROM round r WHERE r.id = $1
        """,
        round_id,
        user_id,
    )
    if row is None:
        raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
    if row["owner_user_id"] != user_id and not row["is_linked_participant"]:
        raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")


# ---------------------------------------------------------------------------
# Tredjeparts leseadgang (ADR-036 fase 2, 2026-07-28) -- venner/offentlig-
# heten sitt innsyn i en runde de IKKE selv er eier eller deltaker på.
# Bevisst ADSKILT fra _get_accessible_round_or_404 over: den dekker eier +
# faktisk medspiller (som alltid har full lese-/skrivetilgang uansett
# visibility_mode), denne dekker KUN lesing for alle andre, styrt av
# visibility_mode + (for 'friends') eierens private kategorisering av
# viewer. `viewer_user_id=None` er en gyldig, forventet verdi (anonym
# leser) -- gir tilgang KUN når visibility_mode='public'.
# ---------------------------------------------------------------------------

async def _can_view_round(conn, round_id: str, viewer_user_id: str | None) -> bool:
    row = await conn.fetchrow(
        "SELECT owner_user_id::text AS owner_user_id, visibility_mode FROM round WHERE id = $1",
        round_id,
    )
    if row is None:
        return False
    if viewer_user_id is not None and row["owner_user_id"] == viewer_user_id:
        return True
    if viewer_user_id is not None:
        is_participant = await conn.fetchval(
            "SELECT EXISTS(SELECT 1 FROM round_participant WHERE round_id = $1 AND user_id = $2)",
            round_id,
            viewer_user_id,
        )
        if is_participant:
            return True
    if row["visibility_mode"] == "public":
        return True
    if row["visibility_mode"] == "private":
        return False
    # 'friends' -- krever et AKSEPTERT vennskap MED eieren OG at EIERENS
    # kategorisering av viewer (ikke omvendt -- det er eieren som
    # begrenser, basert på egen gruppering, ADR-036 Beslutning B).
    #
    # Presisert av bruker 2026-07-29: "ikke vise" overstyrer "vise" -- er
    # vennen satt i FLERE kategorier, holder det IKKE at én av dem er
    # synlig for runden; ALLE vennens kategorier må være i rundens synlige
    # sett. En venn med INGEN kategorier i det hele tatt vises ALDRI (samme
    # runde: kategorisering er nå obligatorisk fra vennskapet inngås, se
    # friends.py -- en ukategorisert venn er derfor alltid en igjenværende
    # unntakstilstand fra FØR den regelen, ikke en normal, forventet sti).
    if viewer_user_id is None:
        return False
    owner_id = row["owner_user_id"]
    is_friend = await conn.fetchval(
        """
        SELECT EXISTS(
            SELECT 1 FROM friendship
            WHERE status = 'accepted'
              AND ((requester_user_id = $1 AND addressee_user_id = $2)
                OR (requester_user_id = $2 AND addressee_user_id = $1))
        )
        """,
        owner_id,
        viewer_user_id,
    )
    if not is_friend:
        return False
    return await conn.fetchval(
        """
        SELECT EXISTS(
            SELECT 1 FROM friend_categorization
            WHERE owner_user_id = $1 AND friend_user_id = $2
        )
        AND NOT EXISTS(
            SELECT 1 FROM friend_categorization fc
            WHERE fc.owner_user_id = $1 AND fc.friend_user_id = $2
              AND fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
        )
        """,
        owner_id,
        viewer_user_id,
        round_id,
    )


async def _get_viewable_round_or_404(conn, round_id: str, viewer_user_id: str | None) -> None:
    exists = await conn.fetchval("SELECT 1 FROM round WHERE id = $1", round_id)
    if exists is None:
        raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
    if not await _can_view_round(conn, round_id, viewer_user_id):
        raise app_error(403, "NOT_AUTHORIZED", "Du har ikke tilgang til denne runden.")


async def _friends_who_can_see_round(conn, round_id: str, owner_user_id: str) -> list[str]:
    """Rundevarsler (FEATURE_BACKLOG.md, 2026-07-28 oppfølging av ADR-036
    fase 2) -- aksepterte venner av EIEREN som kan se DENNE runden akkurat
    nå (offentlig, eller riktig venne-kategori), brukt til fan-out-varsling
    ved opprettelse OG ved fullføring. Bevisst GJENBEREGNET hver gang (ikke
    en lagret mottakerliste) -- samme regel som selve tilgangskontrollen i
    `_can_view_round`, aldri ute av synk med den."""
    row = await conn.fetchrow("SELECT visibility_mode FROM round WHERE id = $1", round_id)
    if row is None or row["visibility_mode"] == "private":
        return []
    friend_rows = await conn.fetch(
        """
        SELECT (CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END)::text AS friend_id
        FROM friendship
        WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
        """,
        owner_user_id,
    )
    friend_ids = [r["friend_id"] for r in friend_rows]
    if not friend_ids:
        return []
    if row["visibility_mode"] == "public":
        return friend_ids
    # Samme "alle kategorier må være synlige, ikke bare én"-regel som
    # _can_view_round -- en venn med minst én ikke-synlig kategori
    # ekskluderes fra fan-out-varselet, selv om en annen av kategoriene
    # deres er valgt.
    matched = await conn.fetch(
        """
        SELECT fc.friend_user_id::text AS friend_id
        FROM friend_categorization fc
        WHERE fc.owner_user_id = $1 AND fc.friend_user_id = ANY($2::uuid[])
        GROUP BY fc.friend_user_id
        HAVING COUNT(*) FILTER (
            WHERE fc.category NOT IN (SELECT category FROM round_visible_category WHERE round_id = $3)
        ) = 0
        """,
        owner_user_id,
        friend_ids,
        round_id,
    )
    return [r["friend_id"] for r in matched]


# ---------------------------------------------------------------------------
# Faktisk (beregnet) HCP -- ADR-038. v1 KUN fra frittstående runder --
# `_gather_qualifying_differentials` er det bevisste skjøtepunktet for en
# fremtidig UNION mot turnering-/organisasjonsscoring (ADR-037), ikke bygget
# ennå. Ingen ny, generell "scoring record"-tabell -- for tidlig abstraksjon
# for en kilde som ikke finnes.
# ---------------------------------------------------------------------------

async def _gather_qualifying_differentials(conn, user_id: str) -> list[tuple[date, float]]:
    rows = await conn.fetch(
        """
        SELECT r.played_at, rp.score_differential::float AS score_differential
        FROM round_participant rp
        JOIN round r ON r.id = rp.round_id
        WHERE rp.user_id = $1
              AND rp.counts_for_handicap
              AND NOT rp.exclude_from_handicap
              AND rp.score_differential IS NOT NULL
        ORDER BY r.played_at DESC
        """,
        user_id,
    )
    return [(row["played_at"], row["score_differential"]) for row in rows]


async def _recompute_computed_handicap_index(conn, user_id: str) -> None:
    """Rule 5.2 (beste 8-av-≤20) + Rule 5.7/5.8 (Low HI-cap). Ingen tidligere
    'computed'-historikk å låse mot -- capping hoppes bevisst over (Low HI
    er udefinert før en første indeks er etablert), ikke en feil."""
    differentials = await _gather_qualifying_differentials(conn, user_id)
    new_index = handicap_index_from_differentials([d for _, d in differentials][:20])

    if new_index is None:
        await conn.execute(
            "UPDATE app_user SET computed_handicap_index = NULL, computed_handicap_index_updated_at = now() "
            "WHERE id = $1",
            user_id,
        )
        return

    history_rows = await conn.fetch(
        """
        SELECT recorded_at::date AS d, handicap_index::float AS idx
        FROM handicap_history WHERE user_id = $1 AND source = 'computed'
        ORDER BY recorded_at
        """,
        user_id,
    )
    low = low_handicap_index([(row["d"], row["idx"]) for row in history_rows], date.today())
    final_index = apply_index_caps(new_index, low) if low is not None else new_index

    await conn.execute(
        "UPDATE app_user SET computed_handicap_index = $2, computed_handicap_index_updated_at = now() WHERE id = $1",
        user_id, final_index,
    )
    await conn.execute(
        "INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'computed')",
        user_id, final_index,
    )


@router.get("/rounds/{round_id}", response_model=RoundOut)
async def get_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        return await _load_round_out(conn, round_id, user.user_id)


class RoundUpdate(BaseModel):
    # Bane-bytte er ATOMISK -- enten oppgis course_source+tee_name (pluss de
    # kildespesifikke feltene) sammen, eller ingen av dem. holes_planned/
    # start_hole kan endres uavhengig, i samme kall eller alene -- disse
    # rører aldri round_hole, kun visnings-/navigasjonsmetadata.
    course_source: Literal["teeoff", "custom"] | None = None
    teeoff_facility_slug: str | None = None
    teeoff_course_id: int | None = None
    personal_course_id: str | None = None
    tee_name: str | None = None
    # Rent metadata-felt -- kan endres/fjernes uansett fullført-status,
    # samme begrunnelse som start_hole/started_at/completed_at under. Sendes
    # feltet med tom streng, tolkes det som "fjern navnet" (NULL); mangler
    # feltet i kallet, røres eksisterende navn ikke (samme mønster som de
    # andre feltene her -- kun `None`/utelatt betyr "ikke rør").
    name: str | None = Field(default=None, max_length=200)
    holes_planned: Literal[9, 18] | None = None
    start_hole: int | None = Field(default=None, ge=1, le=18)
    # Ren metadata (ADR-038 Beslutning D) -- rører aldri AGS/differensial-
    # matematikken selv, kan derfor endres uansett fullført-status.
    play_format: Literal["stroke", "match"] | None = None
    # Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
    # etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
    # senere, og vil rette opp tidsbruken). completed_at kan KUN settes på
    # en runde som allerede er fullført (se validering under) -- denne
    # PATCH-en fullfører aldri runden selv, kun korrigerer et tidspunkt som
    # /complete allerede har satt.
    started_at: datetime | None = None
    completed_at: datetime | None = None
    # ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, endrer
    # aldri HCP-beregningen, kan derfor justeres uansett fullført-status,
    # samme begrunnelse som start_hole/started_at over.
    # visible_categories er en FULL ERSTATNING av settet når sendt (samme
    # "erstatt hele settet"-kontrakt som PUT /friends/{id}/categories) --
    # utelatt/None rører ikke eksisterende kategorier, tom liste [] tømmer
    # dem.
    visibility_mode: Literal["public", "private", "friends"] | None = None
    visible_categories: list[_FriendCategory] | None = None
    # Flere flighter (migrasjon 035) -- SETTER gruppe-id (None = ikke rør,
    # samme konvensjon som resten av denne modellen). Kun brukt til å binde
    # en tidligere gruppeløs runde til en fersk gruppe idet en andre flight
    # legges til (round-detail.tsx). Ingen vei til å FJERNE en runde fra en
    # gruppe igjen i denne runden -- ikke etterspurt.
    flight_group_id: str | None = None


@router.patch("/rounds/{round_id}", response_model=RoundOut)
async def update_round(
    round_id: str,
    body: RoundUpdate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundOut:
    """
    Retter opp feil bane/utslag/antall hull/starthull ETTER at runden er
    opprettet (2026-07-24) -- f.eks. hvis man oppdager underveis at feil
    bane ble valgt, eller vil justere utslags-/fullført-tidspunktet i
    etterkant. Endrer ALDRI allerede registrerte slag/putter/etc. i
    round_hole -- kun rating-grunnlaget (par/stroke-index-snapshot +
    course/slope-rating) hullene regnes ut fra. Bane-bytte og
    holes_planned er bevisst avvist etter at runden er fullført
    (differensialen er da allerede beregnet fra det gamle grunnlaget, og
    en re-beregning etter fullføring er utenfor omfang her -- ulikt
    tilsvarende bane-bytte for turnering-økter, som bevisst TILLATER dette
    selv etter avgjørelse). start_hole/started_at/completed_at er derimot
    ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
    uansett fullført-status.
    """
    if body.flight_group_id is not None:
        try:
            uuid.UUID(body.flight_group_id)
        except ValueError:
            raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")

    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
        is_completed = round_row["completed_at"] is not None

        if is_completed and (body.course_source is not None or body.holes_planned is not None):
            raise app_error(
                409, "ALREADY_COMPLETED",
                "Runden er allerede fullført -- kan ikke endre bane eller antall hull i etterkant.",
            )

        if body.completed_at is not None and not is_completed:
            raise app_error(
                400, "VALIDATION_FAILED",
                "Runden må fullføres via \"Fullfør runde\" først -- deretter kan tidspunktet justeres.",
            )

        effective_started_at = body.started_at if body.started_at is not None else round_row["started_at"]
        effective_completed_at = body.completed_at if body.completed_at is not None else round_row["completed_at"]
        if (
            effective_started_at is not None
            and effective_completed_at is not None
            and effective_completed_at <= effective_started_at
        ):
            raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")

        resolved: _ResolvedCourse | None = None
        participants = None
        if body.course_source is not None:
            if not body.tee_name:
                raise app_error(400, "VALIDATION_FAILED", "tee_name er påkrevd ved bane-bytte.")
            if body.course_source == "teeoff":
                if not body.teeoff_facility_slug or not body.teeoff_course_id:
                    raise app_error(400, "VALIDATION_FAILED", "teeoff_facility_slug og teeoff_course_id er påkrevd.")
                resolved = await _resolve_teeoff_course(body.teeoff_facility_slug, body.teeoff_course_id)
            else:
                if not body.personal_course_id:
                    raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
                resolved = await _resolve_personal_course(body.personal_course_id)

            participants = await conn.fetch(
                """
                SELECT id::text AS id, gender, handicap_index_snapshot::float AS handicap_index_snapshot
                FROM round_participant WHERE round_id = $1
                """,
                round_id,
            )
            # Valider FOR ALLE deltakere FØR noe skrives -- hele bane-byttet
            # avvises tydelig hvis ÉN eneste deltaker ville mistet HCP-
            # sporing, ingen delvis anvendt endring.
            for p in participants:
                if resolved.rating_for(body.tee_name, p["gender"]) is None:
                    raise app_error(
                        400, "VALIDATION_FAILED",
                        "Den nye banen/utslaget mangler rating for én eller flere av deltakernes kjønn -- ingenting er endret.",
                    )

        async with conn.transaction():
            if body.holes_planned is not None:
                await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned)
            if body.start_hole is not None:
                await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole)
            if body.name is not None:
                await conn.execute(
                    "UPDATE round SET name = $2 WHERE id = $1",
                    round_id,
                    body.name.strip() if body.name.strip() else None,
                )
            if body.play_format is not None:
                await conn.execute("UPDATE round SET play_format = $2 WHERE id = $1", round_id, body.play_format)
            if body.started_at is not None:
                await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
            if body.completed_at is not None:
                await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
            if body.visibility_mode is not None:
                await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
            if body.flight_group_id is not None:
                await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
            if body.visible_categories is not None:
                await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id)
                for category in sorted(set(body.visible_categories)):
                    await conn.execute(
                        "INSERT INTO round_visible_category (round_id, category) VALUES ($1, $2)",
                        round_id,
                        category,
                    )

            if resolved is not None and participants is not None:
                await conn.execute(
                    """
                    UPDATE round SET course_source = $2, teeoff_facility_slug = $3, teeoff_course_id = $4,
                           personal_course_id = $5, course_name_snapshot = $6, tee_name_snapshot = $7
                    WHERE id = $1
                    """,
                    round_id,
                    body.course_source,
                    body.teeoff_facility_slug,
                    str(body.teeoff_course_id) if body.teeoff_course_id is not None else None,
                    body.personal_course_id,
                    resolved.course_name,
                    body.tee_name,
                )
                par_by_hole = {h[0]: h[1] for h in resolved.holes}
                index_by_hole = {h[0]: h[2] for h in resolved.holes}
                for p in participants:
                    course_rating, slope_rating, tee_par = resolved.rating_for(body.tee_name, p["gender"])
                    course_handicap_snapshot = None
                    if p["handicap_index_snapshot"] is not None:
                        course_handicap_snapshot = course_handicap(
                            p["handicap_index_snapshot"], slope_rating, course_rating, tee_par
                        )
                    await conn.execute(
                        """
                        UPDATE round_participant SET tee_name_snapshot = $2, course_rating_snapshot = $3,
                               slope_rating_snapshot = $4, tee_par_snapshot = $5, course_handicap_snapshot = $6
                        WHERE id = $1
                        """,
                        p["id"], body.tee_name, course_rating, slope_rating, tee_par, course_handicap_snapshot,
                    )
                    for hole_number in range(1, 19):
                        await conn.execute(
                            """
                            UPDATE round_hole SET par = $3, stroke_index = $4
                            WHERE round_participant_id = $1 AND hole_number = $2
                            """,
                            p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number],
                        )

        result = await _load_round_out(conn, round_id, user.user_id)

    await broadcast_round_update(round_id)
    return result


@router.delete("/rounds/{round_id}", status_code=204)
async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> None:
    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        # Fanges FØR sletting -- kaskaden fjerner round_participant-radene,
        # så de tellende brukerne må hentes ut nå for å kunne regnes på nytt.
        affected_users = await conn.fetch(
            "SELECT user_id::text AS user_id FROM round_participant "
            "WHERE round_id = $1 AND user_id IS NOT NULL AND counts_for_handicap",
            round_id,
        )
        await conn.execute("DELETE FROM round WHERE id = $1", round_id)
        for row in affected_users:
            await _recompute_computed_handicap_index(conn, row["user_id"])
    # Varsler evt. tilkoblede medspillere om at runden er borte -- deres
    # neste refetch vil da naturlig treffe 404 og vise en tydelig feil.
    await broadcast_round_update(round_id)


# ---------------------------------------------------------------------------
# Medspillere (flighten) -- enten en EKTE TeeCup-bruker (funnet via det
# delte tiered personsøket, /people/search, ADR-036) eller en gjest uten
# konto (fritekstnavn). ADR-036 fase 3-utvidelsen (2026-07-26) -- inntil da
# var alle deltakere utenom eieren gjester.
# ---------------------------------------------------------------------------

class ParticipantCreate(BaseModel):
    # Nøyaktig én av user_id/guest_name.
    user_id: str | None = None
    guest_name: str | None = Field(default=None, min_length=1, max_length=100)
    # Kun brukt for guest_name-varianten -- for user_id hentes kjønn/HCP fra
    # personens egen profil (samme kilde som eierens egen deltaker-rad).
    gender: Literal["m", "f", "x"] | None = None
    handicap_index: float | None = Field(default=None, ge=-10, le=54)
    # Valgfritt kontaktfelt for en gjest (2026-07-26) -- avvist hvis oppgitt
    # sammen med user_id (en lenket bruker har allerede sin egen e-post).
    guest_email: EmailStr | None = None
    # Utslagssted for DENNE deltakeren (2026-07-26) -- valgfritt, faller
    # tilbake til rundens eget utslag (samme som før) hvis utelatt. Kan
    # avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
    tee_name: str | None = None
    stat_level: StatLevel = "strokes_only"
    # ADR-038 Beslutning C/D -- kun meningsfullt for user_id-varianten (en
    # gjest har ingen computed_handicap_index å beskytte). Frontend
    # forhåndsutfyller True når rundens play_format='match'.
    exclude_from_handicap: bool = False
    # ADR-039 Beslutning A -- valgfritt, kun gyldig når rundens play_format
    # bruker sider (match/fourball/foursome/greensome/scramble_*). Kan også
    # settes/endres senere via PATCH .../participants/{id}.
    round_side_id: str | None = None


@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_participant(
    round_id: str,
    body: ParticipantCreate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
    if (body.user_id is not None) == (body.guest_name is not None):
        raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
    if body.user_id is not None and body.guest_email is not None:
        raise app_error(400, "VALIDATION_FAILED", "guest_email gir ikke mening sammen med user_id -- en lenket bruker har allerede sin egen konto-e-post.")

    async with plain_connection() as conn:
        round_row = await conn.fetchrow(
            "SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, "
            "teeoff_course_id, personal_course_id, tee_name_snapshot, play_format, completed_at, "
            "name, course_name_snapshot "
            "FROM round WHERE id = $1",
            round_id,
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        if round_row["owner_user_id"] != user.user_id:
            raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")

        if body.round_side_id is not None:
            if round_row["play_format"] not in _TWO_SIDED_FORMATS:
                raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
            side_exists = await conn.fetchval(
                "SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
            )
            if not side_exists:
                raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
            await _check_side_capacity(conn, body.round_side_id, round_row["play_format"])

        resolved = await _resolve_round_course(round_row)

        if body.user_id is not None:
            if body.user_id == round_row["owner_user_id"]:
                raise app_error(400, "VALIDATION_FAILED", "Du er allerede med i runden som eier.")
            target = await conn.fetchrow(
                "SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
                body.user_id,
            )
            if target is None:
                raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
            if target["gender"] is None:
                raise app_error(
                    400, "VALIDATION_FAILED",
                    "Spilleren du valgte har ikke fullført profilen sin (mangler kjønn) og kan ikke legges til ennå.",
                )
            gender = target["gender"]
            handicap_index = target["handicap_index"]
            guest_name = None
            linked_user_id = body.user_id
        else:
            gender = body.gender
            handicap_index = body.handicap_index
            guest_name = body.guest_name.strip()
            linked_user_id = None
            if gender is None:
                raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")

        tee_name = body.tee_name or round_row["tee_name_snapshot"]
        if resolved.rating_for(tee_name, gender) is None and handicap_index is not None:
            raise app_error(
                400, "VALIDATION_FAILED",
                "Valgt utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
            )

        async with conn.transaction(), translate_db_errors():
            participant_id = await _create_participant(
                conn, round_id, resolved, tee_name,
                user_id=linked_user_id, guest_name=guest_name, is_owner=False,
                gender=gender, handicap_index=handicap_index,
                stat_level=body.stat_level,
                guest_email=body.guest_email,
                exclude_from_handicap=body.exclude_from_handicap,
                round_side_id=body.round_side_id,
                individual_round_hole=round_row["play_format"] not in SIDE_IS_UNIT,
            )
            if body.round_side_id is not None:
                await _recompute_side_handicaps(conn, round_id, round_row["play_format"])

        # Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
        # begått, samme mønster som create_round/complete_round (en
        # e-post-utsending, se create_notification, skal aldri skje mens
        # en DB-transaksjon holder låser).
        if linked_user_id is not None:
            owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
            round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
            await create_notification(
                conn,
                user_id=linked_user_id,
                type="round",
                message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).",
                link_path=f"/my-rounds/{round_id}",
            )
        row = await conn.fetchrow(
            """
            SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
                   COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
                   rp.is_owner, rp.gender, rp.tee_name_snapshot,
                   rp.handicap_index_snapshot::float AS handicap_index_snapshot,
                   rp.course_handicap_snapshot, rp.counts_for_handicap,
                   rp.score_differential::float AS score_differential, rp.stat_level,
                   rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
                   rp.playing_handicap
            FROM round_participant rp
            LEFT JOIN app_user au ON au.id = rp.user_id
            WHERE rp.id = $1
            """,
            participant_id,
        )
    await broadcast_round_update(round_id)
    return RoundParticipantOut(**dict(row))


# ---------------------------------------------------------------------------
# Sider (ADR-039 Beslutning A) -- nøyaktig to per runde, kun for formater
# som faktisk bruker sider. Håndhevet i app-laget (samme mønster som
# ADR-011s to-lags-grense for org-turneringer), ikke en DB-constraint.
# ---------------------------------------------------------------------------

class RoundSideCreate(BaseModel):
    label: str | None = Field(default=None, max_length=100)


@router.post("/rounds/{round_id}/sides", response_model=RoundSideOut, status_code=201)
async def create_round_side(
    round_id: str,
    body: RoundSideCreate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundSideOut:
    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow(
            "SELECT play_format, course_source, teeoff_facility_slug, teeoff_course_id, "
            "personal_course_id, completed_at FROM round WHERE id = $1",
            round_id,
        )
        if round_row["play_format"] not in _TWO_SIDED_FORMATS:
            raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
        if round_row["completed_at"] is not None:
            raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
        existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
        if existing_count >= 2:
            raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")

        async with conn.transaction():
            side_row = await conn.fetchrow(
                "INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
                round_id, body.label,
            )
            # Delt-ball-formater (ADR-039 Beslutning C): siden får sine
            # round_hole-rader med en gang -- rating trengs ikke her, kun
            # par/stroke-index-snapshot, uavhengig av om siden har fått
            # noen deltakere ennå.
            engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
            if engine_format in SIDE_IS_UNIT:
                resolved = await _resolve_round_course(round_row)
                for hole_number, par, stroke_index in resolved.holes:
                    await conn.execute(
                        "INSERT INTO round_hole (round_side_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
                        side_row["id"], hole_number, par, stroke_index,
                    )
    await broadcast_round_update(round_id)
    return RoundSideOut(**dict(side_row))


@router.delete("/rounds/{round_id}/sides/{side_id}", status_code=204)
async def delete_round_side(
    round_id: str,
    side_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> None:
    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow("SELECT completed_at FROM round WHERE id = $1", round_id)
        if round_row["completed_at"] is not None:
            raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
        side_exists = await conn.fetchval(
            "SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id
        )
        if not side_exists:
            raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
        # Kaskade fjerner sidens round_hole-rader; deltakere som var på
        # denne siden faller tilbake til round_side_id = NULL (ON DELETE
        # SET NULL) -- de forblir i runden, bare uten side-tilhørighet.
        await conn.execute("DELETE FROM round_side WHERE id = $1", side_id)
    await broadcast_round_update(round_id)


@router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption])
async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]:
    """Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'-
    panelet til å la eieren velge et annet utslag enn rundens default for en
    enkelt deltaker (f.eks. et kjønnsdelt utslag). Tilgjengelig for eier
    ELLER en lenket medspiller (samme lese-tilgang som resten av runden)."""
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow(
            "SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id FROM round WHERE id = $1",
            round_id,
        )
    if round_row["course_source"] == "teeoff":
        resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
    else:
        resolved = await _resolve_personal_course(round_row["personal_course_id"])
    return resolved.tee_options()


class ParticipantUpdate(BaseModel):
    # Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
    # utelatt felt endres ikke. `handicap_index` kan sendes eksplisitt som
    # `null` for å fjerne HCP-sporing for denne deltakeren (samme mønster
    # som profil-PATCH-en).
    stat_level: StatLevel | None = None
    # Utslagssted OG HCP er begge KUN et snapshot for DENNE runden (2026-07-26)
    # -- endrer aldri spillerens faktiske profil, samme reproduserbarhets-
    # prinsipp som resten av round_participant. Bevisst avvist etter at
    # runden er fullført, samme presedens som bane-bytte i RoundUpdate
    # (differensialen er da allerede beregnet fra det gamle grunnlaget).
    tee_name: str | None = None
    handicap_index: float | None = Field(default=None, ge=-10, le=54)
    # KUN gyldig for en gjest (user_id IS NULL) -- gir ingen mening for en
    # lenket bruker, som allerede har sin egen kontos navn/kjønn/e-post.
    # `gender` er en del av rating-bunten under (påvirker hvilken utslags-
    # rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
    guest_name: str | None = Field(default=None, min_length=1, max_length=100)
    gender: Literal["m", "f", "x"] | None = None
    guest_email: EmailStr | None = None
    # ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
    # medspiller som redigerer SIN EGEN rad) får sende her.
    exclude_from_handicap: bool | None = None
    # ADR-039 Beslutning A -- (re)tildel/fjern en deltakers side. Sendes som
    # `null` for å fjerne fra en side. Eier-only (se _OWNER_ONLY_
    # PARTICIPANT_FIELDS under).
    round_side_id: str | None = None


# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
# eier-only, se update_participant sin autorisasjonssjekk.
_OWNER_ONLY_PARTICIPANT_FIELDS = {
    "stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
}


@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
async def update_participant(
    round_id: str,
    participant_id: str,
    body: ParticipantUpdate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    async with plain_connection() as conn:
        # Eier ELLER en lenket medspiller (som kun får røre SIN EGEN
        # eksklusjons-flagg, se under) -- ikke strengt eier-only lenger.
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        current = await conn.fetchrow(
            """
            SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
                   rp.handicap_index_snapshot::float AS handicap_index_snapshot,
                   rp.counts_for_handicap,
                   r.owner_user_id::text AS round_owner_user_id,
                   r.completed_at, r.course_source, r.teeoff_facility_slug,
                   r.teeoff_course_id, r.personal_course_id, r.play_format
            FROM round_participant rp
            JOIN round r ON r.id = rp.round_id
            WHERE rp.id = $1 AND rp.round_id = $2
            """,
            participant_id, round_id,
        )
        if current is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")

        is_owner = current["round_owner_user_id"] == user.user_id
        if not is_owner:
            if _OWNER_ONLY_PARTICIPANT_FIELDS & updates.keys():
                raise app_error(
                    403, "NOT_AUTHORIZED",
                    "Du kan kun endre om DIN egen deltakelse skal telle mot ditt faktiske HCP her.",
                )
            if current["user_id"] != user.user_id:
                raise app_error(403, "NOT_AUTHORIZED", "Du kan kun endre dette for din egen deltakelse.")

        guest_only_fields = {"guest_name", "gender", "guest_email"}
        if current["user_id"] is not None and guest_only_fields & updates.keys():
            raise app_error(
                400, "VALIDATION_FAILED",
                "Navn/kjønn/e-post kan ikke endres her for en spiller med egen konto -- kun for en gjest.",
            )

        rating_changed = "tee_name" in updates or "handicap_index" in updates or "gender" in updates
        if rating_changed and current["completed_at"] is not None:
            raise app_error(
                409, "ALREADY_COMPLETED",
                "Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.",
            )

        set_clauses: list[str] = []
        values: list[object] = []

        if "stat_level" in updates:
            values.append(updates["stat_level"])
            set_clauses.append(f"stat_level = ${len(values)}")
        if "guest_name" in updates:
            values.append(updates["guest_name"].strip())
            set_clauses.append(f"guest_name = ${len(values)}")
        if "guest_email" in updates:
            values.append(updates["guest_email"])
            set_clauses.append(f"guest_email = ${len(values)}")
        if "exclude_from_handicap" in updates:
            values.append(updates["exclude_from_handicap"])
            set_clauses.append(f"exclude_from_handicap = ${len(values)}")
        if "round_side_id" in updates:
            new_side_id = updates["round_side_id"]
            if new_side_id is not None:
                if current["play_format"] not in _TWO_SIDED_FORMATS:
                    raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
                side_exists = await conn.fetchval(
                    "SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
                )
                if not side_exists:
                    raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
                await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
            values.append(new_side_id)
            set_clauses.append(f"round_side_id = ${len(values)}")

        if rating_changed:
            if current["course_source"] == "teeoff":
                resolved = await _resolve_teeoff_course(
                    current["teeoff_facility_slug"], int(current["teeoff_course_id"])
                )
            else:
                resolved = await _resolve_personal_course(current["personal_course_id"])

            new_tee_name = updates.get("tee_name") or current["tee_name_snapshot"]
            new_gender = updates.get("gender") or current["gender"]
            new_handicap_index = (
                updates["handicap_index"] if "handicap_index" in updates else current["handicap_index_snapshot"]
            )
            rating = resolved.rating_for(new_tee_name, new_gender)
            if rating is None:
                raise app_error(
                    400, "VALIDATION_FAILED",
                    "Valgt utslag har ingen rating for spillerens (nye) kjønn.",
                )
            course_rating, slope_rating, tee_par = rating
            course_handicap_snapshot = (
                course_handicap(new_handicap_index, slope_rating, course_rating, tee_par)
                if new_handicap_index is not None else None
            )

            values.append(new_tee_name)
            set_clauses.append(f"tee_name_snapshot = ${len(values)}")
            values.append(new_handicap_index)
            set_clauses.append(f"handicap_index_snapshot = ${len(values)}")
            values.append(course_rating)
            set_clauses.append(f"course_rating_snapshot = ${len(values)}")
            values.append(slope_rating)
            set_clauses.append(f"slope_rating_snapshot = ${len(values)}")
            values.append(tee_par)
            set_clauses.append(f"tee_par_snapshot = ${len(values)}")
            values.append(course_handicap_snapshot)
            set_clauses.append(f"course_handicap_snapshot = ${len(values)}")
            if "gender" in updates:
                values.append(new_gender)
                set_clauses.append(f"gender = ${len(values)}")

        values.append(participant_id)
        updated_id = await conn.fetchval(
            f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id",
            *values,
        )

        # Side-tildeling endret (ADR-039) -- regn playing_handicap for begge
        # sider på nytt FØR raden under leses, slik at responsen reflekterer
        # den ferske verdien (billig no-op hvis fortsatt ikke komplett).
        if "round_side_id" in updates and current["play_format"] in _TWO_SIDED_FORMATS:
            await _recompute_side_handicaps(conn, round_id, current["play_format"])

        row = await conn.fetchrow(
            """
            SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
                   COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
                   rp.is_owner, rp.gender, rp.tee_name_snapshot,
                   rp.handicap_index_snapshot::float AS handicap_index_snapshot,
                   rp.course_handicap_snapshot, rp.counts_for_handicap,
                   rp.score_differential::float AS score_differential, rp.stat_level,
                   rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
                   rp.playing_handicap
            FROM round_participant rp
            LEFT JOIN app_user au ON au.id = rp.user_id
            WHERE rp.id = $1
            """,
            updated_id,
        )

        # En eksklusjons-endring på en ALLEREDE FULLFØRT, tellende runde
        # endrer umiddelbart hvilke differensialer faktisk HCP skal regnes
        # fra -- regn på nytt med en gang, ikke vent til neste fullføring.
        if (
            "exclude_from_handicap" in updates
            and current["user_id"] is not None
            and current["completed_at"] is not None
        ):
            await _recompute_computed_handicap_index(conn, current["user_id"])
    await broadcast_round_update(round_id)
    return RoundParticipantOut(**dict(row))


@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant(
    round_id: str,
    participant_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> None:
    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        row = await conn.fetchrow(
            "SELECT is_owner, user_id::text AS user_id, counts_for_handicap FROM round_participant "
            "WHERE id = $1 AND round_id = $2",
            participant_id, round_id,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
        if row["is_owner"]:
            raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
        await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id)

        # Fjerner en tellende differensial fra grunnlaget -- regn faktisk
        # HCP på nytt for den fjernede spilleren (ikke eieren, som er igjen).
        if row["user_id"] is not None and row["counts_for_handicap"]:
            await _recompute_computed_handicap_index(conn, row["user_id"])
    await broadcast_round_update(round_id)


# ---------------------------------------------------------------------------
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------

class RoundHoleOut(BaseModel):
    hole_number: int
    par: int
    stroke_index: int
    played: bool
    score: int | None
    putts: int | None
    club_off_tee: str | None
    tee_shot_result: str | None
    approach_result: str | None
    chip_count: int | None
    bunker_shot_count: int | None
    penalty_strokes: int | None
    first_putt_distance_bucket: str | None
    anyway_strokes: int | None
    # Slag mottatt på dette hullet, utledet fra course_handicap_snapshot
    # (samme allokeringsalgoritme som resten av appen) -- kun til visning
    # av netto-score i en oversiktstabell, aldri lagret. None hvis
    # deltakeren ikke har en beregnet course handicap (f.eks. gjest uten
    # HCP).
    strokes_received: int | None


async def _build_participant_holes(conn, round_id: str, participant_id: str) -> list[RoundHoleOut]:
    """Delt mellom den autentiserte GET og den offentlige/venn-gatede
    /public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
    kun autorisasjonssjekken FØR dette kalles skiller de to."""
    participant_row = await conn.fetchrow(
        "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
        participant_id, round_id,
    )
    if participant_row is None:
        raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
    rows = await conn.fetch(
        """
        SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
               tee_shot_result, approach_result, chip_count, bunker_shot_count,
               penalty_strokes, first_putt_distance_bucket, anyway_strokes
        FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
        """,
        participant_id,
    )

    strokes_received_by_hole: dict[int, int] | None = None
    if participant_row["course_handicap_snapshot"] is not None:
        allocation = allocate_strokes_by_index(
            participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in rows]
        )
        strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}

    return [
        RoundHoleOut(
            **dict(r),
            strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
        )
        for r in rows
    ]


@router.get(
    "/rounds/{round_id}/participants/{participant_id}/holes",
    response_model=list[RoundHoleOut],
)
async def list_holes(
    round_id: str,
    participant_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> list[RoundHoleOut]:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        return await _build_participant_holes(conn, round_id, participant_id)


# ---------------------------------------------------------------------------
# Delt-ball-scoring (ADR-039 Beslutning C) -- foursome/greensome/scramble
# lagrer ÉN score PER SIDE per hull, ikke per spiller. Ingen av de andre
# detalj-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
# entydig mening for en delt ball -- kun selve slagtallet.
# ---------------------------------------------------------------------------

class RoundSideHoleOut(BaseModel):
    hole_number: int
    par: int
    stroke_index: int
    played: bool
    score: int | None
    # Slag mottatt av SIDEN (kombinert playing_handicap, samme for begge
    # partnere på siden) på dette hullet -- samme allokeringsalgoritme og
    # samme "kun til visning av netto"-begrunnelse som RoundHoleOut sin
    # strokes_received. None hvis siden ikke har en beregnet playing
    # handicap ennå (f.eks. kun én av to partnere tildelt).
    strokes_received: int | None = None
    # Hvilken av sidens partnere sitt utslag ble valgt (migrasjon 036) --
    # helt valgfritt, null = ikke registrert. Kun meningsfullt her (delt-
    # ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet
    # gir ikke mening for RoundHoleOut.
    selected_participant_id: str | None = None


async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]:
    """Delt mellom den autentiserte GET og /public/rounds (ADR-036 fase 2)
    -- se _build_participant_holes over for samme begrunnelse."""
    side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
    if not side_exists:
        raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
    rows = await conn.fetch(
        "SELECT hole_number, par, stroke_index, played, score, selected_participant_id::text AS selected_participant_id "
        "FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number",
        side_id,
    )
    playing_handicap = await conn.fetchval(
        "SELECT playing_handicap FROM round_participant WHERE round_side_id = $1 LIMIT 1", side_id
    )

    strokes_received_by_hole: dict[int, int] | None = None
    if playing_handicap is not None:
        allocation = allocate_strokes_by_index(playing_handicap, [r["stroke_index"] for r in rows])
        strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}

    return [
        RoundSideHoleOut(
            **dict(r),
            strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
        )
        for r in rows
    ]


@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
async def list_side_holes(
    round_id: str,
    side_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> list[RoundSideHoleOut]:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        return await _build_side_holes(conn, round_id, side_id)


class SideHoleUpdate(BaseModel):
    played: bool = True
    score: int | None = Field(default=None, ge=1, le=20)
    # Hvem sitt utslag ble valgt (migrasjon 036) -- helt valgfritt, samme
    # "full overwrite hver gang"-kontrakt som played/score over (klienten
    # sender alltid gjeldende verdi, null = ikke registrert -- IKKE en
    # delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under.
    selected_participant_id: str | None = None


@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
async def update_side_hole(
    round_id: str,
    side_id: str,
    hole_number: int,
    body: SideHoleUpdate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundSideHoleOut:
    async with plain_connection() as conn:
        # Samme begrunnelse som update_hole -- en medspiller som fører
        # score for flighten skal også kunne føre for en delt-ball-side.
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
        if not side_exists:
            raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
        if body.selected_participant_id is not None:
            belongs = await conn.fetchval(
                "SELECT 1 FROM round_participant WHERE id = $1 AND round_side_id = $2",
                body.selected_participant_id, side_id,
            )
            if not belongs:
                raise app_error(
                    400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
                )
        row = await conn.fetchrow(
            """
            UPDATE round_hole SET played = $3, score = $4, selected_participant_id = $5
            WHERE round_side_id = $1 AND hole_number = $2
            RETURNING hole_number, par, stroke_index, played, score,
                      selected_participant_id::text AS selected_participant_id
            """,
            side_id, hole_number, body.played, body.score, body.selected_participant_id,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
    await broadcast_round_update(round_id)
    return RoundSideHoleOut(**dict(row))


# ---------------------------------------------------------------------------
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),
# samme "cache aldri det som kan regnes ut fra rå data"-filosofi som resten
# av appen. Slagspill trenger ingen egen visning her -- eksisterende
# scorekort/leaderboard dekker det allerede.
# ---------------------------------------------------------------------------

class FormatHoleEntry(BaseModel):
    """Én "enhet" sitt tall på ett hull -- en deltaker for individuell-ball-
    formater (match/fourball/skins), eller en SIDE for delt-ball-formater
    (foursome/greensome/scramble, ADR-039 Beslutning C: én score for hele
    siden, ingen individuell deltaker-rad å vise i stedet)."""

    id: str  # participant_id, eller round_side_id for delt-ball
    label: str
    side: Literal["a", "b"] | None = None  # kun to-sidede formater
    gross: int | None = None
    net: int | None = None  # None hvis ingen course handicap er klart (eller skins_scoring="gross")
    strokes_received: int | None = None
    # Fourball: KUN den ene av de to partnernes netto teller for siden det
    # hullet (R&A-regelen: laveste netto blant lagets spillere) -- markerer
    # HVILKEN, slik at "hvorfor vant siden" er synlig uten egen forklarende
    # tekst. Alltid sann for formater med kun én enhet per side (match,
    # foursome/greensome/scramble sin ene side-rad).
    counted: bool = True


class FormatHoleOut(BaseModel):
    hole_number: int
    par: int
    stroke_index: int
    entries: list[FormatHoleEntry]
    result: str | None = None  # to-sidede formater: "a"/"b"/"halved"
    winner_ids: list[str] | None = None  # skins: hvem vant (evt. delte) hullets skin(s)
    carried: bool | None = None  # skins: hullet var uavgjort og potten rullet videre


class RoundFormatResultOut(BaseModel):
    play_format: str
    ready: bool  # False = handicap ikke klart for én eller begge sider ennå
    # Match/fourball/foursome/greensome/scramble:
    match_lead: int | None = None
    match_holes_played: int | None = None
    match_holes_remaining: int | None = None
    match_is_closed: bool | None = None
    match_is_dormie: bool | None = None
    match_status_text: str | None = None
    hole_results: list[str] | None = None  # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge
    # Skins:
    skins_won: dict[str, float] | None = None
    # Hull-for-hull-forløp (2026-07-28, "hvorfor ble resultatet som det ble" --
    # brutto/netto/slag-mottatt per enhet, ikke bare et vinn/tap-merke).
    # None når ready=False (to-sidede formater) -- ellers alltid satt,
    # inkl. skins (som ikke har noe eget "ready"-konsept).
    holes: list[FormatHoleOut] | None = None


def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
    """Samme sirkulære spillerekkefølge som frontend allerede bruker
    (round-detail.tsx) -- starter på start_hole, ruller rundt 1-18."""
    return [((start_hole - 1 + i) % 18) + 1 for i in range(holes_planned)]


async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
    """Delt mellom den autentiserte GET og den offentlige/venn-gatede
    /public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
    kun autorisasjonssjekken FØR dette kalles skiller de to."""
    round_row = await conn.fetchrow(
        "SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned "
        "FROM round WHERE id = $1",
        round_id,
    )
    play_format = round_row["play_format"]

    if play_format in _TWO_SIDED_FORMATS:
        relative = await _relative_strokes_for_round(conn, round_id, play_format)
        if not relative:
            return RoundFormatResultOut(play_format=play_format, ready=False)

        engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
        play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])

        if engine_format in SIDE_IS_UNIT:
            # Delt ball -- én rad per SIDE per hull. "Enheten" ER siden,
            # så label = side.label (fallback "Side A"/"Side B" avgjøres
            # under, samme sortert-på-id-konvensjon som frontend bruker
            # for match_status_text sin bokstavelige "(A)"/"(B)").
            rows = await conn.fetch(
                """
                SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
                FROM round_hole rh
                JOIN round_side rs ON rs.id = rh.round_side_id
                WHERE rs.round_id = $1
                """,
                round_id,
            )
            side_label_rows = await conn.fetch(
                "SELECT id::text AS id, label FROM round_side WHERE round_id = $1", round_id
            )
            unit_label = {r["id"]: r["label"] for r in side_label_rows}
            unit_to_side: dict[str, str] | None = None
        else:
            # Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv
            # (samme som _relative_strokes_for_round sin nøkkel for
            # disse formatene). Henter navn+side-tilhørighet i samme
            # spørring (én rundtur, ikke to).
            rows = await conn.fetch(
                """
                SELECT rp.id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
                FROM round_hole rh
                JOIN round_participant rp ON rp.id = rh.round_participant_id
                WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL
                """,
                round_id,
            )
            participant_label_rows = await conn.fetch(
                """
                SELECT rp.id::text AS id, rp.round_side_id::text AS side_id,
                       COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label
                FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id
                WHERE rp.round_id = $1
                """,
                round_id,
            )
            unit_label = {r["id"]: r["label"] for r in participant_label_rows}
            unit_to_side = {r["id"]: r["side_id"] for r in participant_label_rows}

        by_unit_hole: dict[tuple[str, int], list] = {}
        stroke_index_by_hole: dict[int, int] = {}
        par_by_hole: dict[int, int] = {}
        for r in rows:
            if r["played"] and r["score"] is not None:
                by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"])
            stroke_index_by_hole[r["hole_number"]] = r["stroke_index"]
            par_by_hole[r["hole_number"]] = r["par"]
        all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None

        if engine_format in SIDE_IS_UNIT:
            side_ids = sorted(relative.keys())
        else:
            side_ids = sorted({s for s in unit_to_side.values() if s is not None})

        if len(side_ids) != 2 or all_18_si is None:
            return RoundFormatResultOut(play_format=play_format, ready=False)

        strokes_per_hole = {
            unit: dict(zip(range(1, 19), allocate_over_played_holes(v, all_18_si, list(range(1, 19)))))
            for unit, v in relative.items()
        }
        side_a_id, side_b_id = side_ids
        side_label_of: dict[str, Literal["a", "b"]] = {side_a_id: "a", side_b_id: "b"}
        expected_players = _SIDE_PLAYER_COUNT[play_format]

        def units_for_side(side_id: str) -> list[str]:
            if engine_format in SIDE_IS_UNIT:
                return [side_id]
            return [uid for uid, sid in unit_to_side.items() if sid == side_id]

        # Delt ball -- siden ER enheten (nøyaktig én per side), uansett
        # hvor mange SPILLERE formatet krever. "expected_players" (2 for
        # foursome/greensome, 2 eller 4 for scramble) er PLAYER-antallet,
        # ikke UNIT-antallet -- bruk kun for individuell ball, der hver
        # spiller er sin egen enhet.
        required_units = 1 if engine_format in SIDE_IS_UNIT else expected_players

        def side_net(hole_number: int, side_id: str) -> int | None:
            nets = []
            for unit_id in units_for_side(side_id):
                scores = by_unit_hole.get((unit_id, hole_number), [])
                if len(scores) != 1:
                    return None
                per_hole = strokes_per_hole.get(unit_id)
                if per_hole is None:
                    return None
                nets.append(scores[0] - per_hole[hole_number])
            if len(nets) != required_units:
                return None
            return min(nets)

        hole_results: list[HoleResult] = []
        # Hull-for-hull-forløp (2026-07-28) -- samme sannhetskilde/
        # trunkering som hole_results: kun hull som er FERDIG avgjort
        # (begge sider komplette) tas med, i samme rekkefølge. Et
        # ufullstendig hull (noen har registrert, andre ikke) vises
        # ikke her ennå -- samme "vent til begge er ferdig"-prinsipp
        # som resten av match-motoren.
        holes: list[FormatHoleOut] = []
        for hole_number in play_order:
            net_a = side_net(hole_number, side_a_id)
            net_b = side_net(hole_number, side_b_id)
            if net_a is None or net_b is None:
                break
            if net_a < net_b:
                hole_results.append(HoleResult.SIDE_A)
            elif net_b < net_a:
                hole_results.append(HoleResult.SIDE_B)
            else:
                hole_results.append(HoleResult.HALVED)

            entries: list[FormatHoleEntry] = []
            for side_id, side_net_value in ((side_a_id, net_a), (side_b_id, net_b)):
                for unit_id in units_for_side(side_id):
                    gross = by_unit_hole[(unit_id, hole_number)][0]
                    strokes = strokes_per_hole[unit_id][hole_number]
                    net = gross - strokes
                    entries.append(
                        FormatHoleEntry(
                            id=unit_id,
                            label=unit_label.get(unit_id, "Ukjent"),
                            side=side_label_of[side_id],
                            gross=gross,
                            net=net,
                            strokes_received=strokes,
                            # Fourball: kun laveste av de to partnernes
                            # netto teller for siden -- markerer HVILKEN,
                            # slik at "hvorfor vant siden" er synlig uten
                            # egen forklarende tekst (R&A-regelen, samme
                            # side_net()-funksjon over).
                            counted=(net == side_net_value),
                        )
                    )
            holes.append(
                FormatHoleOut(
                    hole_number=hole_number,
                    par=par_by_hole[hole_number],
                    stroke_index=stroke_index_by_hole[hole_number],
                    entries=entries,
                    result=_RESULT_LABEL[hole_results[-1]],
                )
            )

        state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
        return RoundFormatResultOut(
            play_format=play_format,
            ready=True,
            match_lead=state.lead,
            match_holes_played=state.holes_played,
            match_holes_remaining=state.holes_remaining,
            match_is_closed=state.is_closed,
            match_is_dormie=state.is_dormie,
            match_status_text=state.describe(),
            hole_results=[_RESULT_LABEL[r] for r in hole_results],
            holes=holes,
        )

    if play_format == "skins":
        participant_rows = await conn.fetch(
            """
            SELECT rp.id::text AS id, rp.course_handicap_snapshot,
                   COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label
            FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id
            WHERE rp.round_id = $1
            """,
            round_id,
        )
        label_by_participant = {p["id"]: p["label"] for p in participant_rows}
        hole_rows = await conn.fetch(
            """
            SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
            FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id
            WHERE rp.round_id = $1
            ORDER BY rh.hole_number
            """,
            round_id,
        )
        strokes_by_participant: dict[str, dict[int, int]] = {}
        if round_row["skins_scoring"] == "net":
            by_participant_holes: dict[str, list] = {}
            for r in hole_rows:
                by_participant_holes.setdefault(r["participant_id"], []).append(r)
            ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows}
            for pid, holes_for_participant in by_participant_holes.items():
                ch = ch_by_participant.get(pid)
                if ch is None:
                    continue
                allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes_for_participant])
                strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes_for_participant, allocation)}

        is_net_mode = round_row["skins_scoring"] == "net"
        gross_by_hole_number: dict[int, dict[str, int]] = {}
        par_by_hole: dict[int, int] = {}
        si_by_hole: dict[int, int] = {}
        scores_by_hole_number: dict[int, list[tuple[str, int]]] = {}
        for r in hole_rows:
            par_by_hole[r["hole_number"]] = r["par"]
            si_by_hole[r["hole_number"]] = r["stroke_index"]
            if not r["played"] or r["score"] is None:
                continue
            gross_by_hole_number.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"]
            if is_net_mode:
                per_hole = strokes_by_participant.get(r["participant_id"])
                if per_hole is None:
                    continue
                value = r["score"] - per_hole[r["hole_number"]]
            else:
                value = r["score"]
            scores_by_hole_number.setdefault(r["hole_number"], []).append((r["participant_id"], value))

        play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
        scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order]
        skins_won, hole_log = compute_skins_detail(scores_by_hole, tie_handling=round_row["skins_tie_handling"])

        # Hull-for-hull-forløp -- kun hull der minst én deltaker faktisk
        # har registrert en score tas med (et helt tomt hull har
        # ingenting å vise frem).
        holes = []
        for hole_number, log_entry in zip(play_order, hole_log):
            if not log_entry.values:
                continue
            gross_this_hole = gross_by_hole_number.get(hole_number, {})
            entries = [
                FormatHoleEntry(
                    id=pid,
                    label=label_by_participant.get(pid, "Ukjent"),
                    gross=gross_this_hole.get(pid),
                    net=value if is_net_mode else None,
                    strokes_received=strokes_by_participant.get(pid, {}).get(hole_number) if is_net_mode else None,
                )
                for pid, value in log_entry.values.items()
            ]
            holes.append(
                FormatHoleOut(
                    hole_number=hole_number,
                    par=par_by_hole[hole_number],
                    stroke_index=si_by_hole[hole_number],
                    entries=entries,
                    winner_ids=list(log_entry.awarded.keys()),
                    carried=log_entry.carried,
                )
            )
        return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won, holes=holes)

    return RoundFormatResultOut(play_format=play_format, ready=True)


@router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
async def get_round_format_result(
    round_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> RoundFormatResultOut:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        return await _build_format_result(conn, round_id)


class LeaderboardHoleOut(BaseModel):
    hole_number: int
    par: int
    stroke_index: int
    played: bool
    score: int | None
    # None når deltakeren ikke har noen beregnet course handicap (2026-07-26,
    # samme betingelse som net_score_to_par/total_points under).
    strokes_received: int | None


class LeaderboardEntryOut(BaseModel):
    participant_id: str
    display_name: str
    is_owner: bool
    holes_played: int
    total_score: int | None
    score_to_par: int | None
    # None når deltakeren ikke har noen beregnet course handicap (f.eks.
    # gjest uten registrert HCP) -- samme begrensning som strokes_received
    # i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index).
    net_score_to_par: int | None
    # Stableford-poengsum (2026-07-26, etterspurt av bruker) -- samme
    # betingelse/algoritme som net_score_to_par (krever course handicap),
    # summert over spilte hull med Math.max(0, par - netto + 2) per hull.
    total_points: int | None
    # Hullscorer (2026-07-26, etterspurt av bruker) -- samme rader som
    # allerede ble hentet for å beregne total_score/net_score_to_par over,
    # bare også eksponert rått slik at leaderboardet kan vise dem per hull.
    holes: list[LeaderboardHoleOut]


class LeaderboardOut(BaseModel):
    holes_planned: int
    completed: bool
    entries: list[LeaderboardEntryOut]


async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
    """Delt mellom den autentiserte GET og den offentlige/venn-gatede
    /public/rounds-varianten (ADR-036 fase 2)."""
    round_row = await conn.fetchrow(
        "SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
    )
    participant_rows = await conn.fetch(
        """
        SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot,
               au.display_name AS linked_display_name
        FROM round_participant rp
        LEFT JOIN app_user au ON au.id = rp.user_id
        WHERE rp.round_id = $1
        ORDER BY rp.is_owner DESC, rp.created_at
        """,
        round_id,
    )

    entries: list[LeaderboardEntryOut] = []
    for p in participant_rows:
        hole_rows = await conn.fetch(
            "SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
            p["id"],
        )
        played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None]
        holes_played = len(played_holes)
        total_score = sum(h["score"] for h in played_holes) if holes_played else None
        total_par = sum(h["par"] for h in played_holes) if holes_played else None
        score_to_par = total_score - total_par if total_score is not None else None

        # Beregnes uavhengig av om noen hull faktisk er spilt ennå --
        # brukt BÅDE for net_score_to_par/total_points under OG for
        # strokes_received per hull i den rå hull-listen som returneres.
        strokes_received_by_hole: dict[int, int] = {}
        if p["course_handicap_snapshot"] is not None:
            allocation = allocate_strokes_by_index(
                p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows]
            )
            strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)}

        net_score_to_par = None
        total_points = None
        if total_score is not None and strokes_received_by_hole:
            strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
            net_score_to_par = score_to_par - strokes_received_total
            # Samme stableford-formel som round-scorecard.tsx sin
            # stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
            # gulvet på 0.
            total_points = sum(
                max(0, h["par"] - (h["score"] - strokes_received_by_hole[h["hole_number"]]) + 2)
                for h in played_holes
            )

        # Aldri "Deg" her -- leaderboardet kan nå ses av flere (eier
        # OG lenkede medspillere, ADR-036 fase 3-utvidelsen), så et
        # viewer-relativt "Deg" ville vært feil for de andre som ser på.
        display_name = p["guest_name"] or p["linked_display_name"] or "Medspiller"
        entries.append(
            LeaderboardEntryOut(
                participant_id=p["id"],
                display_name=display_name,
                is_owner=p["is_owner"],
                holes_played=holes_played,
                total_score=total_score,
                score_to_par=score_to_par,
                net_score_to_par=net_score_to_par,
                total_points=total_points,
                holes=[
                    LeaderboardHoleOut(
                        hole_number=h["hole_number"],
                        par=h["par"],
                        stroke_index=h["stroke_index"],
                        played=h["played"],
                        score=h["score"],
                        strokes_received=strokes_received_by_hole.get(h["hole_number"]),
                    )
                    for h in hole_rows
                ],
            )
        )

    entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par))

    return LeaderboardOut(
        holes_planned=round_row["holes_planned"],
        completed=round_row["completed_at"] is not None,
        entries=entries,
    )


@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        return await _build_leaderboard(conn, round_id)


# ---------------------------------------------------------------------------
# Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering av
# separate `round`-rader, se moduldoc/FEATURE_BACKLOG.md. Bevisst egen,
# smal autorisasjon: kun eier-eller-deltaker (samme regel som resten av
# denne filens autentiserte lese-endepunkter) på DEN ANKER-runden anropet
# gjelder -- søsken-runder man selv ikke har tilgang til inkluderes ALDRI
# i svaret (verken i listen eller det slåtte leaderboardet), samme
# "trygg standard: se ingenting" som resten av rundevisibilitet.
# ---------------------------------------------------------------------------


class FlightSummaryOut(BaseModel):
    round_id: str
    name: str | None
    course_name_snapshot: str
    played_at: date
    started_at: str | None
    completed_at: str | None
    participant_count: int
    is_anchor: bool


class FlightGroupOut(BaseModel):
    flight_group_id: str | None
    flights: list[FlightSummaryOut]


async def _accessible_flight_rows(conn, round_id: str, user_id: str):
    """Anker-runden (tilgang allerede sjekket av kalleren) + ALLE søsken i
    samme flight_group_id som brukeren selv eier eller er lenket deltaker
    på -- rader brukeren ikke har tilgang til utelates helt, avsløres
    aldri (verken navn eller at de finnes)."""
    anchor = await conn.fetchrow(
        "SELECT id::text AS id, flight_group_id::text AS flight_group_id FROM round WHERE id = $1",
        round_id,
    )
    group_id = anchor["flight_group_id"]
    if group_id is None:
        sibling_ids = [round_id]
    else:
        sibling_rows = await conn.fetch(
            "SELECT id::text AS id FROM round WHERE flight_group_id = $1 ORDER BY created_at", group_id
        )
        sibling_ids = [r["id"] for r in sibling_rows]
    rows = []
    for sibling_id in sibling_ids:
        row = await conn.fetchrow(
            """
            SELECT r.id::text AS id, r.name, r.course_name_snapshot, r.played_at,
                   r.started_at, r.completed_at, r.owner_user_id::text AS owner_user_id,
                   EXISTS(SELECT 1 FROM round_participant WHERE round_id = r.id AND user_id = $2) AS is_linked_participant
            FROM round r WHERE r.id = $1
            """,
            sibling_id,
            user_id,
        )
        if row is None:
            continue
        if row["owner_user_id"] != user_id and not row["is_linked_participant"]:
            continue
        rows.append(row)
    return group_id, rows


@router.get("/rounds/{round_id}/flight-group", response_model=FlightGroupOut)
async def get_flight_group(round_id: str, user: CurrentUser = Depends(get_current_user)) -> FlightGroupOut:
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        group_id, rows = await _accessible_flight_rows(conn, round_id, user.user_id)
        flights = []
        for row in rows:
            participant_count = await conn.fetchval(
                "SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
            )
            flights.append(
                FlightSummaryOut(
                    round_id=row["id"],
                    name=row["name"],
                    course_name_snapshot=row["course_name_snapshot"],
                    played_at=row["played_at"],
                    started_at=row["started_at"].isoformat() if row["started_at"] else None,
                    completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
                    participant_count=participant_count,
                    is_anchor=row["id"] == round_id,
                )
            )
        return FlightGroupOut(flight_group_id=group_id, flights=flights)


class FlightGroupLeaderboardEntryOut(LeaderboardEntryOut):
    round_id: str
    flight_label: str


class FlightGroupLeaderboardOut(BaseModel):
    flight_group_id: str | None
    flights: list[FlightSummaryOut]
    entries: list[FlightGroupLeaderboardEntryOut]


@router.get("/rounds/{round_id}/flight-group/leaderboard", response_model=FlightGroupLeaderboardOut)
async def get_flight_group_leaderboard(
    round_id: str, user: CurrentUser = Depends(get_current_user)
) -> FlightGroupLeaderboardOut:
    """Slår sammen hver tilgjengelig søsken-flights EGET leaderboard
    (_build_leaderboard, uendret formel) til ÉN rangert liste, tagget med
    hvilken flight hver rad kom fra -- score_to_par er allerede normalisert
    mot par og dermed sammenlignbart på tvers av ulike baner/hullantall."""
    async with plain_connection() as conn:
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        group_id, rows = await _accessible_flight_rows(conn, round_id, user.user_id)
        flights: list[FlightSummaryOut] = []
        entries: list[FlightGroupLeaderboardEntryOut] = []
        for idx, row in enumerate(rows):
            participant_count = await conn.fetchval(
                "SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
            )
            label = row["name"] or f"Flight {idx + 1}"
            flights.append(
                FlightSummaryOut(
                    round_id=row["id"],
                    name=row["name"],
                    course_name_snapshot=row["course_name_snapshot"],
                    played_at=row["played_at"],
                    started_at=row["started_at"].isoformat() if row["started_at"] else None,
                    completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
                    participant_count=participant_count,
                    is_anchor=row["id"] == round_id,
                )
            )
            sub_board = await _build_leaderboard(conn, row["id"])
            for entry in sub_board.entries:
                entries.append(
                    FlightGroupLeaderboardEntryOut(round_id=row["id"], flight_label=label, **entry.model_dump())
                )

        entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par))
        return FlightGroupLeaderboardOut(flight_group_id=group_id, flights=flights, entries=entries)


class HoleUpdate(BaseModel):
    played: bool = True
    score: int | None = Field(default=None, ge=1, le=20)
    putts: int | None = Field(default=None, ge=0, le=10)
    club_off_tee: str | None = Field(default=None, max_length=50)
    tee_shot_result: Literal["fairway", "left", "right"] | None = None
    approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
    chip_count: int | None = Field(default=None, ge=0)
    bunker_shot_count: int | None = Field(default=None, ge=0)
    penalty_strokes: int | None = Field(default=None, ge=0)
    first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
    anyway_strokes: int | None = Field(default=None, ge=0)


@router.patch(
    "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}",
    response_model=RoundHoleOut,
)
async def update_hole(
    round_id: str,
    participant_id: str,
    hole_number: int,
    body: HoleUpdate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundHoleOut:
    async with plain_connection() as conn:
        # ADR-036 fase 3-utvidelsen (2026-07-26): en lenket medspiller kan
        # registrere score for HELE flighten (bekreftet regel), ikke bare
        # sin egen rad -- derfor accessible-sjekk, ikke owner-only, og
        # INGEN participant_id == viewer-sjekk under.
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        participant_row = await conn.fetchrow(
            "SELECT course_handicap_snapshot FROM round_participant WHERE id = $1 AND round_id = $2",
            participant_id, round_id,
        )
        if participant_row is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")

        async with translate_db_errors():
            row = await conn.fetchrow(
                """
                UPDATE round_hole SET
                    played = $3, score = $4, putts = $5, club_off_tee = $6,
                    tee_shot_result = $7, approach_result = $8, chip_count = $9,
                    bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12,
                    anyway_strokes = $13
                WHERE round_participant_id = $1 AND hole_number = $2
                RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
                          tee_shot_result, approach_result, chip_count, bunker_shot_count,
                          penalty_strokes, first_putt_distance_bucket, anyway_strokes
                """,
                participant_id, hole_number,
                body.played, body.score, body.putts, body.club_off_tee,
                body.tee_shot_result, body.approach_result, body.chip_count,
                body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
                body.anyway_strokes,
            )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")

        # strokes_received er utledet av HELE rundens stroke-indeks-rekkefølge
        # (samme allokeringsalgoritme som list_holes/GET), ikke bare dette ene
        # hullet -- må derfor hente alle 18 sin stroke_index for å plassere
        # riktig antall mottatte slag på nøyaktig dette hullet.
        strokes_received = None
        if participant_row["course_handicap_snapshot"] is not None:
            all_indexes = await conn.fetch(
                "SELECT hole_number, stroke_index FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number",
                participant_id,
            )
            allocation = allocate_strokes_by_index(
                participant_row["course_handicap_snapshot"], [r["stroke_index"] for r in all_indexes]
            )
            by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
            strokes_received = by_hole[hole_number]

    await broadcast_round_update(round_id)
    return RoundHoleOut(**dict(row), strokes_received=strokes_received)


# ---------------------------------------------------------------------------
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
# ---------------------------------------------------------------------------

@router.post("/rounds/{round_id}/complete", response_model=RoundOut)
async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_user)) -> RoundOut:
    async with plain_connection() as conn:
        # Samme begrunnelse som update_hole -- en medspiller som fører
        # score for flighten skal også kunne markere runden ferdig.
        await _get_accessible_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow(
            "SELECT holes_planned, play_format, owner_user_id::text AS owner_user_id, name, course_name_snapshot "
            "FROM round WHERE id = $1",
            round_id,
        )

        # 2026-07-28 oppfølging: match/skins/fourball/foursome/greensome/
        # scramble krever flere spillere enn slagspill -- ingenting fanget
        # dette opp før nå. Avvis tydelig FØR noe regnes ut, i stedet for å
        # stille fullføre en runde som er strukturelt umulig for formatet.
        setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
        if not setup_complete:
            raise app_error(409, "SETUP_INCOMPLETE", setup_message)

        participants = await conn.fetch(
            """
            SELECT id::text AS id, user_id::text AS user_id,
                   handicap_index_snapshot::float AS handicap_index_snapshot,
                   course_rating_snapshot::float AS course_rating_snapshot,
                   slope_rating_snapshot, tee_par_snapshot
            FROM round_participant WHERE round_id = $1
            """,
            round_id,
        )

        for p in participants:
            holes = await conn.fetch(
                """
                SELECT hole_number, par, stroke_index, played, score
                FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
                """,
                p["id"],
            )
            played_count = sum(1 for h in holes if h["played"])
            counts = round_counts_for_handicap(played_count, round_row["holes_planned"])
            differential = None

            if counts and p["handicap_index_snapshot"] is not None and p["course_rating_snapshot"] is not None:
                pars = [h["par"] for h in holes]
                strokes_received = allocate_strokes_by_index(
                    _course_handicap_from_row(p),
                    [h["stroke_index"] for h in holes],
                )
                scores = [h["score"] if h["played"] else None for h in holes]
                ags = adjusted_gross_score(scores, pars, strokes_received)
                differential = score_differential(ags, p["course_rating_snapshot"], p["slope_rating_snapshot"])

            await conn.execute(
                "UPDATE round_participant SET counts_for_handicap = $2, score_differential = $3 WHERE id = $1",
                p["id"], counts, differential,
            )

        await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)

        # Faktisk HCP (ADR-038) -- alle differensialer for runden er nå
        # skrevet, regn på nytt for hver deltaker med egen konto (gjester
        # har ingen computed_handicap_index å oppdatere).
        for p in participants:
            if p["user_id"] is not None:
                await _recompute_computed_handicap_index(conn, p["user_id"])

        # Rundevarsler (2026-07-28 oppfølging av ADR-036 fase 2) -- to
        # atskilte mottakergrupper med ulik lenke, siden de har ulik
        # tilgang: lenkede medspillere ser den fulle autentiserte siden,
        # tredjeparts-venner kun tredjeparts-/spectator-visningen.
        actor_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
        round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
        message = f"{actor_name} fullførte runden ({round_label})."

        linked_ids = {p["user_id"] for p in participants if p["user_id"] is not None}
        linked_ids.discard(user.user_id)
        for uid in linked_ids:
            await create_notification(
                conn, user_id=uid, type="result", message=message, link_path=f"/my-rounds/{round_id}",
            )

        watcher_ids = set(await _friends_who_can_see_round(conn, round_id, round_row["owner_user_id"]))
        watcher_ids -= linked_ids
        watcher_ids.discard(user.user_id)
        for uid in watcher_ids:
            await create_notification(
                conn, user_id=uid, type="result", message=message, link_path=f"/watch/{round_id}",
            )

        result = await _load_round_out(conn, round_id, user.user_id)

    await broadcast_round_update(round_id)
    return result


def _course_handicap_from_row(p) -> int:
    return course_handicap(
        p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"]
    )


# ---------------------------------------------------------------------------
# Sanntid (2026-07-26, oppfølging av ADR-027) -- "noe endret seg, hent på
# nytt"-signal for round-detail.tsx/round-stats.tsx/round-scorecard.tsx.
# Denne PRIVATE varianten krever alltid en ekte, autorisert sesjon (eier
# ELLER lenket medspiller) -- en anonym/tredjeparts-venn kan ikke bruke
# denne, se `/ws/public/rounds/{round_id}/live` under (ADR-036 fase 2,
# 2026-07-28) for den offentlige/venn-gatede varianten.
# ---------------------------------------------------------------------------

@router.websocket("/ws/rounds/{round_id}/live")
async def round_live_ws(websocket: WebSocket, round_id: str) -> None:
    user = await get_current_user_from_websocket(websocket)
    if user is None:
        await websocket.close(code=4401)
        return
    async with plain_connection() as conn:
        try:
            await _get_accessible_round_or_404(conn, round_id, user.user_id)
        except HTTPException as exc:
            await websocket.close(code=4404 if exc.status_code == 404 else 4403)
            return

    await websocket.accept()
    sockets = live_sockets_for_round(round_id)
    sockets.add(websocket)
    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        pass
    finally:
        sockets.discard(websocket)


# ---------------------------------------------------------------------------
# Tredjeparts/offentlig lesevisning (ADR-036 fase 2, 2026-07-28) -- en venn
# (i en synlig kategori) eller alle (visibility_mode='public') kan følge en
# runde live UTEN å være eier/deltaker. Samme "egen /public-prefiks,
# get_current_user_optional"-mønster som turneringers offentlige side
# (ADR-018/026/027) -- egne, leaner response-modeller (aldri guest_email,
# som er PII, og aldri my_*/setup_*, som kun gir mening for eier/deltaker),
# men gjenbruker de SAMME underliggende dataleserne
# (_build_participant_holes/_build_side_holes/_build_leaderboard/
# _build_format_result) som de autentiserte endepunktene over, for å unngå
# to implementasjoner av samme forretningslogikk.
# ---------------------------------------------------------------------------

class PublicRoundParticipantOut(BaseModel):
    id: str
    display_name: str
    is_owner: bool
    gender: str
    tee_name_snapshot: str
    handicap_index_snapshot: float | None
    course_handicap_snapshot: int | None
    round_side_id: str | None
    playing_handicap: int | None


class PublicRoundOut(BaseModel):
    id: str
    name: str | None
    course_name_snapshot: str
    tee_name_snapshot: str
    played_at: date
    start_hole: int
    holes_planned: int
    play_format: str
    completed_at: str | None
    owner_display_name: str
    participants: list[PublicRoundParticipantOut]
    sides: list[RoundSideOut]


async def _build_public_round_out(conn, round_id: str) -> PublicRoundOut:
    round_row = await conn.fetchrow(
        """
        SELECT id::text AS id, name, course_name_snapshot, tee_name_snapshot,
               played_at, start_hole, holes_planned, play_format, completed_at,
               owner_user_id::text AS owner_user_id
        FROM round WHERE id = $1
        """,
        round_id,
    )
    owner_display_name = await conn.fetchval(
        "SELECT display_name FROM app_user WHERE id = $1", round_row["owner_user_id"]
    )
    participant_rows = await conn.fetch(
        """
        SELECT rp.id::text AS id,
               COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
               rp.is_owner, rp.gender, rp.tee_name_snapshot,
               rp.handicap_index_snapshot::float AS handicap_index_snapshot,
               rp.course_handicap_snapshot, rp.round_side_id::text AS round_side_id,
               rp.playing_handicap
        FROM round_participant rp
        LEFT JOIN app_user au ON au.id = rp.user_id
        WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
        """,
        round_id,
    )
    side_rows = await conn.fetch(
        "SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
    )
    return PublicRoundOut(
        id=round_row["id"],
        name=round_row["name"],
        course_name_snapshot=round_row["course_name_snapshot"],
        tee_name_snapshot=round_row["tee_name_snapshot"],
        played_at=round_row["played_at"],
        start_hole=round_row["start_hole"],
        holes_planned=round_row["holes_planned"],
        play_format=round_row["play_format"],
        completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
        owner_display_name=owner_display_name or "Ukjent",
        participants=[PublicRoundParticipantOut(**dict(r)) for r in participant_rows],
        sides=[RoundSideOut(**dict(r)) for r in side_rows],
    )


@router.get("/public/rounds/{round_id}", response_model=PublicRoundOut)
async def get_public_round(
    round_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> PublicRoundOut:
    async with plain_connection() as conn:
        await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        return await _build_public_round_out(conn, round_id)


@router.get(
    "/public/rounds/{round_id}/participants/{participant_id}/holes",
    response_model=list[RoundHoleOut],
)
async def get_public_participant_holes(
    round_id: str,
    participant_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[RoundHoleOut]:
    async with plain_connection() as conn:
        await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        return await _build_participant_holes(conn, round_id, participant_id)


@router.get("/public/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
async def get_public_side_holes(
    round_id: str,
    side_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[RoundSideHoleOut]:
    async with plain_connection() as conn:
        await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        return await _build_side_holes(conn, round_id, side_id)


@router.get("/public/rounds/{round_id}/leaderboard", response_model=LeaderboardOut)
async def get_public_leaderboard(
    round_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> LeaderboardOut:
    async with plain_connection() as conn:
        await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        return await _build_leaderboard(conn, round_id)


@router.get("/public/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
async def get_public_format_result(
    round_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> RoundFormatResultOut:
    async with plain_connection() as conn:
        await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        return await _build_format_result(conn, round_id)


@router.websocket("/ws/public/rounds/{round_id}/live")
async def public_round_live_ws(websocket: WebSocket, round_id: str) -> None:
    user = await get_current_user_from_websocket(websocket)
    async with plain_connection() as conn:
        try:
            await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
        except HTTPException as exc:
            await websocket.close(code=4404 if exc.status_code == 404 else 4403)
            return

    await websocket.accept()
    sockets = live_sockets_for_round(round_id)
    sockets.add(websocket)
    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        pass
    finally:
        sockets.discard(websocket)


class PublicPersonRoundOut(BaseModel):
    id: str
    name: str | None
    course_name_snapshot: str
    played_at: date
    completed_at: str | None
    play_format: str


@router.get("/public/people/{user_id}/rounds", response_model=list[PublicPersonRoundOut])
async def list_public_person_rounds(
    user_id: str,
    user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[PublicPersonRoundOut]:
    """Brukes av vennprofil-siden (ADR-036 fase 2) -- lister EIERENS runder
    som SPØRREREN har lov til å se (public alltid, friends kun med riktig
    kategori-match, private aldri) -- egen lenket medspiller-runde vises
    ikke her (det er "Mine runder", en annen sak). Pågående
    (completed_at IS NULL) alltid øverst, deretter nyeste fullførte."""
    async with plain_connection() as conn:
        person_exists = await conn.fetchval("SELECT 1 FROM app_user WHERE id = $1", user_id)
        if person_exists is None:
            raise app_error(404, "NOT_FOUND", "Fant ikke brukeren.")
        rows = await conn.fetch(
            """
            SELECT id::text AS id, name, course_name_snapshot, played_at, completed_at, play_format
            FROM round WHERE owner_user_id = $1
            ORDER BY (completed_at IS NULL) DESC, played_at DESC, created_at DESC
            """,
            user_id,
        )
        viewer_id = user.user_id if user else None
        visible = [r for r in rows if await _can_view_round(conn, r["id"], viewer_id)]
    return [
        PublicPersonRoundOut(
            id=r["id"],
            name=r["name"],
            course_name_snapshot=r["course_name_snapshot"],
            played_at=r["played_at"],
            completed_at=r["completed_at"].isoformat() if r["completed_at"] else None,
            play_format=r["play_format"],
        )
        for r in visible
    ]
