"""
Individuelle/flerrunde-turneringer (ADR-037): grunnstruktur oppå
`040_individual_tournaments.sql`. Kun for `tournament.format_type =
'individual'` -- lagturneringer (`format_type = 'team'`, standard) er
uendret, bygget/vedlikeholdt i tournaments.py/matches.py/scoring.py.

Ett flatt felt av spillere (`tournament_participant`), over én eller flere
runder (`tournament_round`, samme rolle som `session` for lagturneringer).
Rå bruttoslag lagres per hull (`tournament_round_hole`, kilde-sannhet); et
ferdig utregnet resultat caches per deltaker per runde
(`tournament_round_score`) -- samme "regn på nytt ved hver innsending,
SQL summerer bare det cachede ved lesing"-mønster som `match.status_text`/
`points_side_a/b`. Sammenlagt over flere runder summeres VED LESING i
leaderboardet, ikke i en egen tredje cache-tabell (ADR-037 Beslutning C).

Autorisasjon (strammet inn 2026-07-30, se `user_is_own_tournament_
participant` i `app/team_authz.py`): selve SCORE-REGISTRERINGEN
(`update_hole`) krever nå at brukeren ER deltakeren (via `player.user_id`)
ELLER org-eier/admin -- samme mønster som `user_is_match_participant`
strammet inn lagturneringer i ADR-023. Runde-/deltaker-OPPSETT (opprett/
slett runde, legg til/fjern turnering-/rundedeltaker) forblir bevisst på
vanlig org-medlemsnivå (`get_authorized_org`) -- samme presedens som
`session`-opprettelse og `team_roster`-tilføyelse i tournaments.py, som
heller aldri har vært captain-/admin-gatet. Kun VALGET AV HVEM SOM SPILLER
og selve SCORINGEN er gatet strengere noe sted i appen, aldri det generelle
oppsettet.

Københavner (2026-07-30): scoring_method='copenhagen' -- flatt felt av
NØYAKTIG 3 spillere, 6 poeng delt per hull etter relativ NETTO-rangering
(handicap_engine.py sin compute_copenhagen_detail). Eneste scoring_method
her som krever paring PÅ TVERS av spillere per hull -- _recompute_
copenhagen_points regner derfor om for ALLE tre samtidig (kalt fra
update_hole), ikke ett-om-gangen slik gross/net/stableford ellers gjøres.

Bevisst UTENFOR omfang (se ADR-037): High-low-high/Robbins/Try all/
Flaggturnering (High-low-high/Robbins/Try all krever to SIDER, ikke et
flatt felt -- hører hjemme i org-lagturneringer i stedet, se matches.py/
scoring.py) og Order of Merit (sesong-sammenlagt på tvers av FLERE
turneringer).
"""

from __future__ import annotations

from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field

from handicap_engine import (
    allocate_over_played_holes,
    allocate_strokes_by_index,
    bbb_points_for_hole,
    compute_bbb,
    compute_copenhagen_detail,
    course_handicap_raw,
    EclecticHoleValue,
    eclectic_best_per_hole,
    flag_result,
    round_half_up,
    stableford_points_for_hole,
    stableford_total,
    stroke_play_gross_total,
    stroke_play_net_total,
)

from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..team_authz import user_is_own_tournament_participant
from .scoring import played_hole_numbers

router = APIRouter()

_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford", "copenhagen", "flag"}


# =====================================================================
# Runder (tournament_round)
# =====================================================================

class TournamentRoundCreate(BaseModel):
    sequence: int = Field(ge=1)
    name: str | None = None
    hole_config: str = Field(default="full_18", pattern="^(full_18|front_9|back_9)$")
    course_id: str
    scheduled_at: str | None = None  # ISO 8601, tolkes av asyncpg/Pydantic ved behov
    tee_interval_minutes: int | None = Field(default=None, gt=0)
    start_hole: int = Field(default=1, ge=1, le=18)


class TournamentRound(BaseModel):
    id: str
    tournament_id: str
    sequence: int
    name: str | None
    hole_config: str
    course_id: str
    course_name: str
    scheduled_at: str | None
    tee_interval_minutes: int | None
    start_hole: int


_ROUND_COLUMNS = """
    tr.id::text, tr.tournament_id::text, tr.sequence, tr.name,
    tr.hole_config::text, tr.course_id::text, c.name AS course_name,
    tr.scheduled_at::text, tr.tee_interval_minutes, tr.start_hole
"""


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
    response_model=TournamentRound,
    status_code=201,
)
async def create_round(
    tournament_id: str,
    body: TournamentRoundCreate,
    organization_id: str = Depends(get_authorized_org),
) -> TournamentRound:
    async with org_connection(organization_id) as conn, translate_db_errors():
        tournament = await conn.fetchrow(
            "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
        )
        if tournament is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        if tournament["format_type"] != "individual":
            raise app_error(
                400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
            )
        # Eclectic (ADR-067-tillegget "Del C"): "beste resultat per hull på
        # tvers av rundene" krever at hullnumrene faktisk betyr det samme
        # (samme par/stroke index/fysiske hull) i alle runder -- fail
        # loudly fremfor å stille sammenligne epler og pærer, samme
        # ADR-019-filosofi som resten av appen.
        if tournament["scoring_method"] is not None and tournament["scoring_method"].startswith("eclectic_"):
            existing_course_id = await conn.fetchval(
                "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1",
                tournament_id,
            )
            if existing_course_id is not None and existing_course_id != body.course_id:
                raise app_error(
                    400, "VALIDATION_FAILED",
                    "Eclectic krever samme bane for alle turneringens runder.",
                )
        row = await conn.fetchrow(
            f"""
            WITH inserted AS (
                INSERT INTO tournament_round
                    (organization_id, tournament_id, sequence, name, hole_config,
                     course_id, scheduled_at, tee_interval_minutes, start_hole)
                VALUES ($1, $2, $3, $4, $5::hole_scope, $6, $7, $8, $9)
                RETURNING *
            )
            SELECT {_ROUND_COLUMNS} FROM inserted tr JOIN course c ON c.id = tr.course_id
            """,
            organization_id,
            tournament_id,
            body.sequence,
            body.name,
            body.hole_config,
            body.course_id,
            body.scheduled_at,
            body.tee_interval_minutes,
            body.start_hole,
        )
    return TournamentRound(**dict(row))


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds",
    response_model=list[TournamentRound],
)
async def list_rounds(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[TournamentRound]:
    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        rows = await conn.fetch(
            f"""
            SELECT {_ROUND_COLUMNS} FROM tournament_round tr
            JOIN course c ON c.id = tr.course_id
            WHERE tr.tournament_id = $1
            ORDER BY tr.sequence
            """,
            tournament_id,
        )
    return [TournamentRound(**dict(r)) for r in rows]


@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}", status_code=204)
async def delete_round(
    tournament_id: str,
    round_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        has_participants = await conn.fetchval(
            "SELECT EXISTS(SELECT 1 FROM tournament_round_participant WHERE tournament_round_id = $1)",
            round_id,
        )
        if has_participants:
            raise app_error(
                409,
                "NOT_EMPTY",
                "Runden har allerede deltakere -- fjern dem først, eller la runden stå.",
            )
        deleted = await conn.fetchval(
            "DELETE FROM tournament_round WHERE id = $1 AND tournament_id = $2 RETURNING id",
            round_id,
            tournament_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")


# =====================================================================
# Turnering-deltakere (tournament_participant -- flatt felt, intet lag)
# =====================================================================

class TournamentParticipantCreate(BaseModel):
    player_id: str
    # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
    class_id: str | None = None


class TournamentParticipantUpdate(BaseModel):
    """Eneste redigerbare felt i dag er klassetilhørighet -- exclude_unset,
    samme PATCH-semantikk som resten av appen."""

    class_id: str | None = None


class TournamentParticipantOut(BaseModel):
    id: str
    player_id: str
    player_name: str
    handicap_index_snapshot: float | None
    class_id: str | None
    class_name: str | None


_TOURNAMENT_PARTICIPANT_COLUMNS = """
    tp.id::text AS id, tp.player_id::text AS player_id,
    p.display_name AS player_name,
    tp.handicap_index_snapshot::float AS handicap_index_snapshot,
    tp.class_id::text AS class_id, tc.name AS class_name
"""


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/participants",
    response_model=TournamentParticipantOut,
    status_code=201,
)
async def add_tournament_participant(
    tournament_id: str,
    body: TournamentParticipantCreate,
    organization_id: str = Depends(get_authorized_org),
) -> TournamentParticipantOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        player = await conn.fetchrow(
            "SELECT display_name, handicap_index FROM player WHERE id = $1", body.player_id
        )
        if player is None:
            raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
        row = await conn.fetchrow(
            f"""
            WITH inserted AS (
                INSERT INTO tournament_participant
                    (organization_id, tournament_id, player_id, handicap_index_snapshot, class_id)
                VALUES ($1, $2, $3, $4, $5)
                RETURNING id, player_id, handicap_index_snapshot, class_id
            )
            SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
            FROM inserted tp
            JOIN player p ON p.id = tp.player_id
            LEFT JOIN tournament_class tc ON tc.id = tp.class_id
            """,
            organization_id,
            tournament_id,
            body.player_id,
            player["handicap_index"],
            body.class_id,
        )
    return TournamentParticipantOut(**dict(row))


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/participants",
    response_model=list[TournamentParticipantOut],
)
async def list_tournament_participants(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[TournamentParticipantOut]:
    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        rows = await conn.fetch(
            f"""
            SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
            FROM tournament_participant tp
            JOIN player p ON p.id = tp.player_id
            LEFT JOIN tournament_class tc ON tc.id = tp.class_id
            WHERE tp.tournament_id = $1
            ORDER BY p.display_name
            """,
            tournament_id,
        )
    return [TournamentParticipantOut(**dict(r)) for r in rows]


@router.patch(
    "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
    response_model=TournamentParticipantOut,
)
async def update_tournament_participant(
    tournament_id: str,
    participant_id: str,
    body: TournamentParticipantUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> TournamentParticipantOut:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
    values = list(updates.values())
    values.append(participant_id)
    values.append(tournament_id)

    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            f"""
            WITH updated AS (
                UPDATE tournament_participant SET {', '.join(set_clauses)}
                WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
                RETURNING id, player_id, handicap_index_snapshot, class_id
            )
            SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
            FROM updated tp
            JOIN player p ON p.id = tp.player_id
            LEFT JOIN tournament_class tc ON tc.id = tp.class_id
            """,
            *values,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")
    return TournamentParticipantOut(**dict(row))


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
    status_code=204,
)
async def remove_tournament_participant(
    tournament_id: str,
    participant_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn, translate_db_errors():
        deleted = await conn.fetchval(
            "DELETE FROM tournament_participant WHERE id = $1 AND tournament_id = $2 RETURNING id",
            participant_id,
            tournament_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.")


# =====================================================================
# Deltaker PÅ ÉN RUNDE (tee + cachet handicap for akkurat den runden)
# =====================================================================

class RoundParticipantCreate(BaseModel):
    tournament_participant_id: str
    tee_id: str


class RoundParticipantOut(BaseModel):
    id: str
    tournament_participant_id: str
    player_name: str
    tee_id: str
    tee_name: str
    course_handicap: int | None
    playing_handicap: int | None


async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
    """Regner course_handicap/playing_handicap for ÉN rundedeltaker.

    v1 har ingen allowance-prosent for individuelle turneringer (ulikt
    lagturneringenes AllowanceStrategy-familie, som er bygget for et
    relativt to-siders oppgjør -- ikke relevant her) -- playing_handicap er
    derfor alltid identisk med (avrundet) course_handicap. Setter INGENTING
    hvis spilleren mangler handicap_index_snapshot eller en matchende
    tee_rating -- kalleren har allerede validert dette FØR innsetting når
    scoring_method krever det (se add_round_participant).
    """
    row = await conn.fetchrow(
        """
        SELECT tp.handicap_index_snapshot::float AS handicap_index_snapshot,
               rating.course_rating::float AS course_rating,
               rating.slope_rating::float AS slope_rating,
               rating.par AS par
        FROM tournament_round_participant trp
        JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
        JOIN player p ON p.id = tp.player_id
        JOIN tee_rating rating ON rating.tee_id = trp.tee_id
                               AND rating.scope = 'full_18'
                               AND rating.gender = p.gender
        WHERE trp.id = $1
        """,
        round_participant_id,
    )
    if row is None or row["handicap_index_snapshot"] is None:
        return
    ch = round_half_up(
        course_handicap_raw(
            row["handicap_index_snapshot"], row["slope_rating"], row["course_rating"], row["par"]
        )
    )
    await conn.execute(
        "UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
        ch,
        round_participant_id,
    )


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
    response_model=RoundParticipantOut,
    status_code=201,
)
async def add_round_participant(
    tournament_id: str,
    round_id: str,
    body: RoundParticipantCreate,
    organization_id: str = Depends(get_authorized_org),
) -> RoundParticipantOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        round_row = await conn.fetchrow(
            """
            SELECT tr.course_id::text AS course_id, t.scoring_method
            FROM tournament_round tr
            JOIN tournament t ON t.id = tr.tournament_id
            WHERE tr.id = $1 AND tr.tournament_id = $2
            """,
            round_id,
            tournament_id,
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")

        tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id)
        if tee_course_id is None or tee_course_id != round_row["course_id"]:
            raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")

        requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
        if requires_handicap:
            player_row = await conn.fetchrow(
                """
                SELECT tp.handicap_index_snapshot, p.gender
                FROM tournament_participant tp
                JOIN player p ON p.id = tp.player_id
                WHERE tp.id = $1
                """,
                body.tournament_participant_id,
            )
            if player_row is None:
                raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
            if player_row["handicap_index_snapshot"] is None:
                raise app_error(
                    400,
                    "VALIDATION_FAILED",
                    "Denne spilleren mangler handicap-indeks -- påkrevd for "
                    "nettoslagspill/Stableford.",
                )
            if player_row["gender"] is None:
                raise app_error(
                    400,
                    "VALIDATION_FAILED",
                    "Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
                )
            has_rating = await conn.fetchval(
                "SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
                body.tee_id,
                player_row["gender"],
            )
            if not has_rating:
                kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
                raise app_error(
                    400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
                )

        row = await conn.fetchrow(
            """
            WITH inserted AS (
                INSERT INTO tournament_round_participant
                    (organization_id, tournament_round_id, tournament_participant_id, tee_id)
                VALUES ($1, $2, $3, $4)
                RETURNING id
            )
            SELECT inserted.id::text AS id
            FROM inserted
            """,
            organization_id,
            round_id,
            body.tournament_participant_id,
            body.tee_id,
        )
        round_participant_id = row["id"]
        await _compute_round_participant_handicap(conn, round_participant_id)

        out = await conn.fetchrow(
            """
            SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
                   p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
                   trp.course_handicap, trp.playing_handicap
            FROM tournament_round_participant trp
            JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
            JOIN player p ON p.id = tp.player_id
            JOIN tee ON tee.id = trp.tee_id
            WHERE trp.id = $1
            """,
            round_participant_id,
        )
    return RoundParticipantOut(**dict(out))


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
    response_model=list[RoundParticipantOut],
)
async def list_round_participants(
    tournament_id: str,
    round_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[RoundParticipantOut]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            """
            SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
                   p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
                   trp.course_handicap, trp.playing_handicap
            FROM tournament_round_participant trp
            JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
            JOIN player p ON p.id = tp.player_id
            JOIN tee ON tee.id = trp.tee_id
            WHERE trp.tournament_round_id = $1
            ORDER BY p.display_name
            """,
            round_id,
        )
    return [RoundParticipantOut(**dict(r)) for r in rows]


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}",
    status_code=204,
)
async def remove_round_participant(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        deleted = await conn.fetchval(
            "DELETE FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2 RETURNING id",
            round_participant_id,
            round_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")


# =====================================================================
# Hull-for-hull-score (tournament_round_hole -- kilde-sannhet)
# =====================================================================

class HoleUpdate(BaseModel):
    gross_strokes: int = Field(ge=1, le=20)


class RoundHoleOut(BaseModel):
    hole_number: int
    par: int
    stroke_index: int
    gross_strokes: int | None
    strokes_received: int | None


async def _recompute_round_score(conn, round_id: str, round_participant_id: str, organization_id: str) -> None:
    """Regner ferdig totalsum (brutto/netto/Stableford) på nytt for ÉN
    deltaker i ÉN runde, og cacher resultatet -- kalt etter HVER
    hull-innsending (samme mønster som recompute_and_cache_match_state).
    """
    ctx = await conn.fetchrow(
        """
        SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
               trp.tournament_participant_id::text AS tournament_participant_id,
               trp.playing_handicap
        FROM tournament_round_participant trp
        JOIN tournament_round tr ON tr.id = trp.tournament_round_id
        WHERE trp.id = $1
        """,
        round_participant_id,
    )
    holes = await conn.fetch(
        """
        SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
        FROM hole h
        LEFT JOIN tournament_round_hole trh
            ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
        WHERE h.course_id = $2
        ORDER BY h.hole_number
        """,
        round_participant_id,
        ctx["course_id"],
    )
    played = [h for h in holes if h["gross_strokes"] is not None]
    holes_played = len(played)
    gross_total = stroke_play_gross_total([h["gross_strokes"] for h in played]) if played else None

    net_total = None
    stableford_points = None
    if played and ctx["playing_handicap"] is not None:
        all_18_si = [h["stroke_index"] for h in holes]
        played_numbers = [h["hole_number"] for h in played]
        received = allocate_over_played_holes(ctx["playing_handicap"], all_18_si, played_numbers)
        gross_list = [h["gross_strokes"] for h in played]
        pars = [h["par"] for h in played]
        net_total = stroke_play_net_total(gross_list, received)
        stableford_points = stableford_total(pars, gross_list, received)

    await conn.execute(
        """
        INSERT INTO tournament_round_score
            (organization_id, tournament_round_id, tournament_participant_id,
             holes_played, gross_total, net_total, stableford_points, updated_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, now())
        ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
            holes_played = $4, gross_total = $5, net_total = $6,
            stableford_points = $7, updated_at = now()
        """,
        organization_id,
        round_id,
        ctx["tournament_participant_id"],
        holes_played,
        gross_total,
        net_total,
        stableford_points,
    )


async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
    """Københavner (2026-07-30): 6 poeng deles per hull mellom NØYAKTIG 3
    deltakere basert på relativ NETTO-rangering -- kan derfor IKKE regnes
    per deltaker isolert slik gross/net/stableford ellers gjøres (se
    modulens docstring, "bevisst utenfor omfang" er nå innhentet for dette
    ene formatet). Regner om for ALLE deltakerne i runden samtidig, kalt
    etter HVER hull-innsending når scoring_method='copenhagen' -- ETTER at
    _recompute_round_score allerede har kjørt for alle tre (sikrer at
    tournament_round_score-raden finnes for alle, ikke bare den som nettopp
    scoret)."""
    participants = await conn.fetch(
        """
        SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
               trp.playing_handicap
        FROM tournament_round_participant trp
        WHERE trp.tournament_round_id = $1
        """,
        round_id,
    )
    if len(participants) != 3:
        return  # Ikke komplett ennå -- copenhagen_points_for_hole krever nøyaktig 3

    course_id = await conn.fetchval("SELECT course_id::text FROM tournament_round WHERE id = $1", round_id)
    all_18 = await conn.fetch(
        "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
    )
    all_18_si = [h["stroke_index"] for h in all_18]

    net_by_hole: dict[int, list[tuple[str, int]]] = {}
    for p in participants:
        if p["playing_handicap"] is None:
            continue
        holes = await conn.fetch(
            "SELECT hole_number, gross_strokes FROM tournament_round_hole "
            "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL "
            "ORDER BY hole_number",
            p["id"],
        )
        if not holes:
            continue
        played_numbers = [h["hole_number"] for h in holes]
        allocation = allocate_over_played_holes(p["playing_handicap"], all_18_si, played_numbers)
        for h, received in zip(holes, allocation):
            net = h["gross_strokes"] - received
            net_by_hole.setdefault(h["hole_number"], []).append((p["tournament_participant_id"], net))

    scores_by_hole = [net_by_hole.get(h["hole_number"], []) for h in all_18]
    totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False)

    for p in participants:
        await conn.execute(
            """
            INSERT INTO tournament_round_score
                (organization_id, tournament_round_id, tournament_participant_id, copenhagen_points, updated_at)
            VALUES ($1, $2, $3, $4, now())
            ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
                copenhagen_points = $4, updated_at = now()
            """,
            organization_id,
            round_id,
            p["tournament_participant_id"],
            totals.get(p["tournament_participant_id"]),
        )


async def _recompute_bbb_points(conn, round_id: str, organization_id: str) -> None:
    """Bingo Bango Bongo (2026-07-30): poeng er en per-HULL-fakta på tvers
    av ALLE deltakerne i runden, ikke per deltaker isolert -- regner om for
    alle samtidig, kalt etter HVER bbb-hull-registrering. Krever INGEN
    handicap (ulikt Københavner) -- rene observasjons-poeng."""
    participants = await conn.fetch(
        "SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id "
        "FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1",
        round_id,
    )
    if not participants:
        return
    tournament = await conn.fetchrow(
        "SELECT t.bbb_sweep_bonus_enabled FROM tournament_round tr "
        "JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1",
        round_id,
    )
    rows = await conn.fetch(
        """
        SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
               bango_participant_id::text AS bango_participant_id,
               bongo_participant_id::text AS bongo_participant_id
        FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 ORDER BY hole_number
        """,
        round_id,
    )
    hole_log = [(r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in rows]
    totals = compute_bbb(hole_log, tournament["bbb_sweep_bonus_enabled"])

    for p in participants:
        await conn.execute(
            """
            INSERT INTO tournament_round_score
                (organization_id, tournament_round_id, tournament_participant_id, bbb_points, updated_at)
            VALUES ($1, $2, $3, $4, now())
            ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
                bbb_points = $4, updated_at = now()
            """,
            organization_id,
            round_id,
            p["tournament_participant_id"],
            totals.get(p["tournament_participant_id"]),
        )


class BBBHoleUpdate(BaseModel):
    bingo_participant_id: str | None = None
    bango_participant_id: str | None = None
    bongo_participant_id: str | None = None


class BBBHoleOut(BaseModel):
    hole_number: int
    bingo_participant_id: str | None
    bango_participant_id: str | None
    bongo_participant_id: str | None


@router.patch(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb/{hole_number}",
    response_model=BBBHoleOut,
)
async def update_bbb_hole(
    tournament_id: str,
    round_id: str,
    hole_number: int,
    body: BBBHoleUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> BBBHoleOut:
    """Runde-/deltaker-oppsett-nivå autorisasjon (get_authorized_org, org-
    medlem), samme presedens som resten av dette modulens oppsett -- ikke
    strammet inn til "kun deltakeren selv" slik update_hole er, siden
    bbb-registrering er en OBSERVASJON om alle deltakerne (hvem som var
    først på green osv.), ikke én persons egen score."""
    async with org_connection(organization_id) as conn, translate_db_errors():
        round_row = await conn.fetchrow(
            """
            SELECT tr.hole_config::text AS hole_config, t.scoring_method, t.id::text AS tournament_id
            FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
            WHERE tr.id = $1
            """,
            round_id,
        )
        if round_row is None or round_row["tournament_id"] != tournament_id:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        if round_row["scoring_method"] != "bingo_bango_bongo":
            raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Bingo Bango Bongo.")
        if hole_number not in played_hole_numbers(round_row["hole_config"]):
            raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")

        for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
            if pid is None:
                continue
            belongs = await conn.fetchval(
                "SELECT 1 FROM tournament_participant WHERE id = $1 AND tournament_id = $2",
                pid,
                tournament_id,
            )
            if not belongs:
                raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne turneringen.")

        row = await conn.fetchrow(
            """
            INSERT INTO tournament_round_bbb_hole
                (organization_id, tournament_round_id, hole_number,
                 bingo_participant_id, bango_participant_id, bongo_participant_id)
            VALUES ($1, $2, $3, $4, $5, $6)
            ON CONFLICT (tournament_round_id, hole_number) DO UPDATE SET
                bingo_participant_id = $4, bango_participant_id = $5, bongo_participant_id = $6
            RETURNING hole_number, bingo_participant_id::text AS bingo_participant_id,
                      bango_participant_id::text AS bango_participant_id,
                      bongo_participant_id::text AS bongo_participant_id
            """,
            organization_id,
            round_id,
            hole_number,
            body.bingo_participant_id,
            body.bango_participant_id,
            body.bongo_participant_id,
        )
        await _recompute_bbb_points(conn, round_id, organization_id)
    return BBBHoleOut(**dict(row))


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb",
    response_model=list[BBBHoleOut],
)
async def list_bbb_holes(
    tournament_id: str,
    round_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[BBBHoleOut]:
    """Rå bingo/bango/bongo-VALG per hull (ikke de utledede poengene) --
    samme begrunnelse som round.py sin frittstående-motpart."""
    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval(
            "SELECT id FROM tournament_round WHERE id = $1 AND tournament_id = $2", round_id, tournament_id
        )
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        rows = await conn.fetch(
            """
            SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
                   bango_participant_id::text AS bango_participant_id,
                   bongo_participant_id::text AS bongo_participant_id
            FROM tournament_round_bbb_hole WHERE tournament_round_id = $1
            """,
            round_id,
        )
    return [BBBHoleOut(**dict(r)) for r in rows]


@router.patch(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/holes/{hole_number}",
    response_model=RoundHoleOut,
)
async def update_hole(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    hole_number: int,
    body: HoleUpdate,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> RoundHoleOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        round_row = await conn.fetchrow(
            """
            SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method
            FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
            WHERE tr.id = $1
            """,
            round_id,
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        if hole_number not in played_hole_numbers(round_row["hole_config"]):
            raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")

        rp = await conn.fetchrow(
            """
            SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id
            FROM tournament_round_participant trp
            WHERE trp.id = $1 AND trp.tournament_round_id = $2
            """,
            round_participant_id,
            round_id,
        )
        if rp is None:
            raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")

        if not await user_is_own_tournament_participant(
            conn, organization_id, rp["tournament_participant_id"], user.user_id
        ):
            raise app_error(
                403,
                "NOT_TOURNAMENT_PARTICIPANT",
                "Du kan kun registrere score for deg selv (eller være organisasjonsadministrator).",
            )

        hole = await conn.fetchrow(
            "SELECT par, stroke_index FROM hole WHERE course_id = $1 AND hole_number = $2",
            round_row["course_id"],
            hole_number,
        )
        if hole is None:
            raise app_error(
                400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret."
            )

        await conn.execute(
            """
            INSERT INTO tournament_round_hole
                (organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at)
            VALUES ($1, $2, $3, $4, now())
            ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
                gross_strokes = $4, updated_at = now()
            """,
            organization_id,
            round_participant_id,
            hole_number,
            body.gross_strokes,
        )
        await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
        if round_row["scoring_method"] == "copenhagen":
            # Kobenhavner-poeng avhenger av ALLE tre deltakernes netto på
            # hvert hull -- regn om for de to andre ogsa (sikrer at deres
            # tournament_round_score-rad finnes) FOR selve poeng-fordelingen.
            other_ids = await conn.fetch(
                "SELECT id::text AS id FROM tournament_round_participant "
                "WHERE tournament_round_id = $1 AND id != $2",
                round_id,
                round_participant_id,
            )
            for other in other_ids:
                await _recompute_round_score(conn, round_id, other["id"], organization_id)
            await _recompute_copenhagen_points(conn, round_id, organization_id)

        strokes_received = None
        playing_handicap = await conn.fetchval(
            "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1", round_participant_id
        )
        if playing_handicap is not None:
            all_18 = await conn.fetch(
                "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
                round_row["course_id"],
            )
            allocation = allocate_over_played_holes(
                playing_handicap, [h["stroke_index"] for h in all_18], [hole_number]
            )
            strokes_received = allocation[0]

    return RoundHoleOut(
        hole_number=hole_number,
        par=hole["par"],
        stroke_index=hole["stroke_index"],
        gross_strokes=body.gross_strokes,
        strokes_received=strokes_received,
    )


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/holes",
    response_model=list[RoundHoleOut],
)
async def list_round_participant_holes(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[RoundHoleOut]:
    async with org_connection(organization_id) as conn:
        round_row = await conn.fetchrow(
            "SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1", round_id
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        playing_handicap = await conn.fetchval(
            "SELECT playing_handicap FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
            round_participant_id,
            round_id,
        )
        holes = await conn.fetch(
            """
            SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes
            FROM hole h
            LEFT JOIN tournament_round_hole trh
                ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
            WHERE h.course_id = $2
            ORDER BY h.hole_number
            """,
            round_participant_id,
            round_row["course_id"],
        )
        allocation = (
            allocate_over_played_holes(
                playing_handicap, [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
            )
            if playing_handicap is not None
            else [None] * len(holes)
        )
    return [
        RoundHoleOut(
            hole_number=h["hole_number"],
            par=h["par"],
            stroke_index=h["stroke_index"],
            gross_strokes=h["gross_strokes"],
            strokes_received=a,
        )
        for h, a in zip(holes, allocation)
    ]


# =====================================================================
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
# =====================================================================

class RoundCellOut(BaseModel):
    round_number: int
    label: str
    tone: str | None  # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell


class EclecticHoleCellOut(BaseModel):
    hole_number: int
    par: int
    value: int  # brutto/nettoslag eller Stableford-poeng, avhengig av scoring_method
    round_number: int  # HVILKEN runde (sequence) det beste resultatet kom fra ("beste-kilde")


class LeaderboardEntry(BaseModel):
    tournament_participant_id: str
    # Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE
    # spilleren, ikke bare denne ENE turneringens deltaker-rad, for å
    # summere resultater på tvers av flere lenkede turneringer.
    player_id: str
    player_name: str
    rounds_played: int
    gross_total: int | None
    net_total: int | None
    stableford_total: int | None
    copenhagen_total: int | None
    bbb_total: int | None
    # Konkurranseklasse (2026-08-03) -- frontend grupperer den allerede
    # sorterte listen under på class_id (stabil gruppering bevarer riktig
    # rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
    class_id: str | None
    class_name: str | None
    # Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx) --
    # KUN populert for scoring_method i (stroke_gross, stroke_net, stableford).
    # Alltid None/tom liste for copenhagen/bingo_bango_bongo/flag -- frontend
    # bruker fortsatt den enkle listen for de formatene.
    position: str | None = None
    is_leader: bool = False
    today_label: str | None = None
    thru_label: str | None = None
    total_label: str | None = None
    rounds: list[RoundCellOut] = []
    # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
    # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
    # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
    # method-verdier.
    eclectic_total: int | None = None
    eclectic_holes: list[EclecticHoleCellOut] = []


async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]:
    """Delt mellom `individual_leaderboard` under (denne ENE turneringens
    egen visning) og Order of Merit sitt leaderboard-endepunkt
    (`order_of_merit.py`, som summerer resultater over FLERE lenkede
    turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere
    posisjon-/til-par-beregningen (inkl. den til-par-normaliserte
    rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).

    Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
    trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
    hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
    som faktisk er meningsfullt å lese videre."""
    tournament = await conn.fetchrow(
        "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
    )
    if tournament is None:
        raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
    if tournament["format_type"] != "individual":
        raise app_error(400, "VALIDATION_FAILED", "Kun individuelle turneringer har dette leaderboardet.")

    rows = await conn.fetch(
        """
        SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
               p.display_name AS player_name,
               count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
               SUM(trs.gross_total)::int AS gross_total,
               SUM(trs.net_total)::int AS net_total,
               SUM(trs.stableford_points)::int AS stableford_total,
               SUM(trs.copenhagen_points)::int AS copenhagen_total,
               SUM(trs.bbb_points)::int AS bbb_total,
               tc.id::text AS class_id, tc.name AS class_name
        FROM tournament_participant tp
        JOIN player p ON p.id = tp.player_id
        LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id
        LEFT JOIN tournament_class tc ON tc.id = tp.class_id
        WHERE tp.tournament_id = $1
        GROUP BY tp.id, p.display_name, tc.id, tc.name
        """,
        tournament_id,
    )
    entries = [LeaderboardEntry(**dict(r)) for r in rows]

    method = tournament["scoring_method"]
    if method in ("stroke_gross", "stroke_net", "stableford"):
        # VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her
        # (slik de andre metodene gjør) -- i en flerrunde-turnering har
        # deltakere spilt ULIKT antall hull til enhver tid (noen midt i
        # runde 2, andre ikke startet ennå), så rå sum er ikke
        # sammenlignbar. _attach_stroke_play_columns sorterer selv, på
        # til-par (normalisert for antall hull spilt), FØR posisjon
        # tildeles -- reell bug funnet under scratch-verifisering: en
        # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
        # deltakere med -5 til par over 27-36 hull, kun fordi rått
        # slagtall er mindre jo færre hull man har spilt.
        await _attach_stroke_play_columns(conn, tournament_id, method, entries)
    elif method == "copenhagen":
        # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
        entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
    elif method == "bingo_bango_bongo":
        entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
    elif method in ("eclectic_gross", "eclectic_net", "eclectic_stableford"):
        await _attach_eclectic_totals(conn, tournament_id, method, entries)
        if method == "eclectic_stableford":
            entries.sort(key=lambda e: (e.eclectic_total is None, -(e.eclectic_total or 0)))
        else:
            entries.sort(key=lambda e: (e.eclectic_total is None, e.eclectic_total or 0))
    else:
        entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))

    return dict(tournament), entries


@router.get(
    # IKKE /leaderboard -- den stien er allerede tournaments.py sitt
    # LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
    # klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
    # her ved å gi den et eget, ikke-overlappende navn fra start.
    "/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
    response_model=list[LeaderboardEntry],
)
async def individual_leaderboard(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[LeaderboardEntry]:
    async with org_connection(organization_id) as conn:
        _, entries = await _compute_individual_standings(conn, tournament_id)
    return entries


_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}


def _to_par_label(v: int) -> str:
    if v == 0:
        return "E"
    return f"+{v}" if v > 0 else str(v)


def _to_par_tone(v: int) -> str:
    if v < 0:
        return "under"
    if v > 0:
        return "over"
    return "even"


async def _attach_stroke_play_columns(
    conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]
) -> None:
    """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
    POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
    kalleren) i stedet for å bygge en ny liste -- gjenbruker eksisterende
    gross_total/net_total/stableford_total som ALLEREDE er summert riktig,
    legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
    per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
    """
    rounds_meta = await conn.fetch(
        """
        SELECT sequence, hole_config::text AS hole_config
        FROM tournament_round
        WHERE tournament_id = $1
        ORDER BY sequence
        """,
        tournament_id,
    )
    hole_count_by_seq = {
        r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta
    }

    # Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0).
    # par_played = summen av par for KUN de hullene deltakeren faktisk har
    # ført score på i den runden -- riktig grunnlag for et til-par-tall
    # midt i en runde (ikke hele rundens par, som ville misvist før runden
    # er ferdigspilt).
    round_rows = await conn.fetch(
        """
        SELECT tr.sequence,
               trs.tournament_participant_id::text AS tournament_participant_id,
               trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
               COALESCE(pp.par_played, 0)::int AS par_played
        FROM tournament_round tr
        JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
        LEFT JOIN LATERAL (
            SELECT SUM(h.par) AS par_played
            FROM tournament_round_participant trp
            JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
            JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
            WHERE trp.tournament_round_id = tr.id
              AND trp.tournament_participant_id = trs.tournament_participant_id
              AND trh.gross_strokes IS NOT NULL
        ) pp ON true
        WHERE tr.tournament_id = $1 AND trs.holes_played > 0
        ORDER BY tr.sequence
        """,
        tournament_id,
    )

    by_participant: dict[str, dict[int, object]] = {}
    started_sequences: set[int] = set()
    for r in round_rows:
        by_participant.setdefault(r["tournament_participant_id"], {})[r["sequence"]] = r
        started_sequences.add(r["sequence"])
    # "I dag" = runden med høyest sekvensnummer som NOEN har påbegynt -- ikke
    # nødvendigvis fullført (bekreftet med bruker: "det er den sist
    # påbegynte"). Ingen eksplisitt status-/aktiv-markering finnes på
    # tournament_round i dag, så dette utledes.
    today_sequence = max(started_sequences) if started_sequences else None

    value_col = "stableford_points" if method == "stableford" else (
        "net_total" if method == "stroke_net" else "gross_total"
    )

    # Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
    # uniformt for alle tre metodene -- stableford negeres siden flere poeng
    # er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
    # flerrunde-turnering har deltakere spilt ulikt antall hull til enhver
    # tid, så rå sum er ikke sammenlignbar (se kallstedets kommentar).
    NO_SCORE = 1_000_000_000
    rank_key_by_id: dict[str, int] = {}

    for e in entries:
        pmap = by_participant.get(e.tournament_participant_id, {})

        rounds_out: list[RoundCellOut] = []
        total_par_played = 0
        total_holes_played = 0
        for seq, row in sorted(pmap.items()):
            if row["holes_played"] == 0:
                continue
            total_par_played += row["par_played"]
            total_holes_played += row["holes_played"]
            if method == "stableford":
                points = row["stableford_points"] or 0
                expected = 2 * row["holes_played"]
                rounds_out.append(
                    RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
                )
            else:
                val = row[value_col]
                if val is None:
                    continue
                rounds_out.append(
                    RoundCellOut(
                        round_number=seq,
                        label=str(val),
                        tone=_to_par_tone(val - row["par_played"]),
                    )
                )
        e.rounds = rounds_out

        # TODAY / THRU
        today_row = pmap.get(today_sequence) if today_sequence is not None else None
        if today_row is not None and today_row["holes_played"] > 0:
            hole_count = hole_count_by_seq.get(today_sequence, 18)
            e.thru_label = "F" if today_row["holes_played"] >= hole_count else str(today_row["holes_played"])
            if method == "stableford":
                e.today_label = f"{today_row['stableford_points'] or 0} p"
            else:
                val = today_row[value_col]
                e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
        else:
            e.thru_label = "-"
            e.today_label = None

        # TOTAL -- gjenbruker den allerede korrekt summerte gross_total/
        # net_total/stableford_total (uendret av denne funksjonen), trekker
        # kun fra summert par-for-spilte-hull for til-par-formatet.
        if method == "stableford":
            e.total_label = f"{e.stableford_total or 0} p"
            rank_key_by_id[e.tournament_participant_id] = (
                NO_SCORE if e.stableford_total is None or total_holes_played == 0
                else -(e.stableford_total - 2 * total_holes_played)
            )
        else:
            total_val = e.net_total if method == "stroke_net" else e.gross_total
            e.total_label = _to_par_label(total_val - total_par_played) if total_val is not None else "E"
            rank_key_by_id[e.tournament_participant_id] = (
                NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played
            )

    # Sorter på til-par-nøkkelen (lavere = bedre), IKKE på rå total -- se
    # kallstedets kommentar for hvorfor rå total ikke er sammenlignbar på
    # tvers av ulikt antall hull spilt.
    entries.sort(key=lambda e: rank_key_by_id[e.tournament_participant_id])

    # POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste
    # rangering hopper over like mange plasser som antall uavgjorte).
    i, n = 0, len(entries)
    while i < n:
        j = i
        key_i = rank_key_by_id[entries[i].tournament_participant_id]
        while j < n and rank_key_by_id[entries[j].tournament_participant_id] == key_i:
            j += 1
        rank = i + 1
        tied = (j - i) > 1
        for k in range(i, j):
            entries[k].position = f"T{rank}" if tied else str(rank)
            entries[k].is_leader = rank == 1
        i = j


# ---------------------------------------------------------------------------
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- "drømmerunde" satt
# sammen av BESTE resultat per hullnummer på tvers av ALLE turneringens
# runder. Krever samme bane for alle runder (håndhevet ved rundeopprettelse,
# se create_round over) -- hullnummer 5 i runde 1 og hullnummer 5 i runde 2
# er da GARANTERT samme fysiske hull/par/stroke index, så sammenligningen er
# meningsfull. Motoren (eclectic_best_per_hole) vet ingenting om db/HCP --
# denne funksjonen forbereder per-hull-per-runde-verdiene (brutto/netto/
# Stableford-poeng, avhengig av `method`) og mater dem inn.
# ---------------------------------------------------------------------------

async def _attach_eclectic_totals(conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]) -> None:
    course_id = await conn.fetchval(
        "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
    )
    if course_id is None:
        return

    hole_rows = await conn.fetch(
        "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
        course_id,
    )
    par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
    si_by_hole = [r["stroke_index"] for r in hole_rows]  # hull 1..18, i rekkefølge

    round_rows = await conn.fetch(
        """
        SELECT tr.sequence,
               trp.tournament_participant_id::text AS tournament_participant_id,
               trp.course_handicap, trh.hole_number, trh.gross_strokes
        FROM tournament_round tr
        JOIN tournament_round_participant trp ON trp.tournament_round_id = tr.id
        JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
        WHERE tr.tournament_id = $1 AND trh.gross_strokes IS NOT NULL
        ORDER BY tr.sequence
        """,
        tournament_id,
    )

    # round_index i motorens forstand = rekkefølgen sequence-verdiene faktisk
    # dukker opp i (stabil, sortert) -- brukt kun til å spore "beste-kilde"
    # tilbake til et ekte rundenummer (sequence) for visning.
    sequence_by_index: dict[int, int] = {}
    for r in round_rows:
        if r["sequence"] not in sequence_by_index.values():
            sequence_by_index[len(sequence_by_index)] = r["sequence"]
    index_by_sequence = {seq: i for i, seq in sequence_by_index.items()}

    # Cache slagfordelingen per (course_handicap) -- samme fordeling for
    # enhver rad med samme handicap, uansett hvilken runde/hull den gjelder.
    allocation_cache: dict[int, list[int]] = {}

    values_by_participant: dict[str, list[list[EclecticHoleValue]]] = {}
    for r in round_rows:
        if r["course_handicap"] is None:
            continue
        pid = r["tournament_participant_id"]
        if pid not in values_by_participant:
            values_by_participant[pid] = [[] for _ in range(18)]

        strokes_received = 0
        if method != "eclectic_gross":
            hcp = r["course_handicap"]
            if hcp not in allocation_cache:
                allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole)
            strokes_received = allocation_cache[hcp][r["hole_number"] - 1]

        if method == "eclectic_gross":
            value = r["gross_strokes"]
        elif method == "eclectic_net":
            value = r["gross_strokes"] - strokes_received
        else:
            value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)

        values_by_participant[pid][r["hole_number"] - 1].append(
            EclecticHoleValue(round_index=index_by_sequence[r["sequence"]], value=value)
        )

    result_type = "gross" if method == "eclectic_gross" else "net" if method == "eclectic_net" else "stableford"
    for e in entries:
        values_by_hole = values_by_participant.get(e.tournament_participant_id)
        if values_by_hole is None:
            continue
        result = eclectic_best_per_hole(values_by_hole, result_type)
        if not result.holes:
            continue
        e.eclectic_total = result.total
        e.eclectic_holes = [
            EclecticHoleCellOut(
                hole_number=h.hole_number,
                par=par_by_hole[h.hole_number],
                value=h.value,
                round_number=sequence_by_index[h.round_index],
            )
            for h in result.holes
        ]


# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
# for de andre scoring_method-ene) -- Flag er strukturelt en
# enkelt-rundes-konkurranse ("hvem kommer lengst DENNE runden"), ikke et
# akkumulerbart poeng-/slagtall på tvers av flere runder.
# ---------------------------------------------------------------------------

class FlagParticipantResult(BaseModel):
    tournament_participant_id: str
    player_name: str
    holes_completed: int
    ran_out: bool
    strokes_remaining: int


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-result",
    response_model=list[FlagParticipantResult],
)
async def flag_round_result(
    tournament_id: str,
    round_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[FlagParticipantResult]:
    async with org_connection(organization_id) as conn:
        round_row = await conn.fetchrow(
            """
            SELECT tr.id::text AS id, tr.hole_config::text AS hole_config, t.scoring_method
            FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
            WHERE tr.id = $1 AND tr.tournament_id = $2
            """,
            round_id,
            tournament_id,
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        if round_row["scoring_method"] != "flag":
            raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")

        play_order = played_hole_numbers(round_row["hole_config"])
        participants = await conn.fetch(
            """
            SELECT trp.id::text AS id, trp.course_handicap, tp.id::text AS tournament_participant_id,
                   p.display_name AS player_name
            FROM tournament_round_participant trp
            JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
            JOIN player p ON p.id = tp.player_id
            WHERE trp.tournament_round_id = $1
            """,
            round_id,
        )
        results: list[FlagParticipantResult] = []
        for p in participants:
            if p["course_handicap"] is None:
                continue
            hole_rows = await conn.fetch(
                """
                SELECT h.hole_number, h.par, trh.gross_strokes
                FROM hole h
                LEFT JOIN tournament_round_hole trh
                    ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
                WHERE h.course_id = (SELECT course_id FROM tournament_round WHERE id = $2)
                """,
                p["id"],
                round_id,
            )
            by_hole = {r["hole_number"]: r for r in hole_rows}
            total_par = sum(by_hole[h]["par"] for h in play_order if h in by_hole)
            gross_prefix = []
            for h in play_order:
                row = by_hole.get(h)
                if row is None or row["gross_strokes"] is None:
                    break
                gross_prefix.append(row["gross_strokes"])
            budget = total_par + p["course_handicap"]
            fr = flag_result(gross_prefix, budget)
            results.append(
                FlagParticipantResult(
                    tournament_participant_id=p["tournament_participant_id"],
                    player_name=p["player_name"],
                    holes_completed=fr.holes_completed,
                    ran_out=fr.ran_out,
                    strokes_remaining=fr.strokes_remaining,
                )
            )
    results.sort(key=lambda r: -r.holes_completed)
    return results


# ---------------------------------------------------------------------------
# Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14).
# Speiler rounds.py sin frittstående-variant presist -- se der for full
# begrunnelse (egen isolert overflow-tabell, server-side beregnet hull/lap,
# aldri stolt blindt på fra klienten). Ulikt frittstående runder er
# scoreregistrering her SELV-only (user_is_own_tournament_participant),
# samme presedens som update_hole over -- ikke flight-styrt.
# ---------------------------------------------------------------------------

async def _flag_current_position_org(
    conn, tournament_round_participant_id: str, hole_config: str
) -> tuple[int, int]:
    """Org-ekvivalent av rounds.py sin _flag_current_position -- samme
    logikk, men leser tournament_round_hole/tournament_round_hole_flag_
    overflow, og play_order kommer fra played_hole_numbers(hole_config)
    (ingen start_hole-rotasjon på org-siden)."""
    play_order = played_hole_numbers(hole_config)
    lap1_rows = await conn.fetch(
        "SELECT hole_number FROM tournament_round_hole "
        "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL",
        tournament_round_participant_id,
    )
    lap1_played = {r["hole_number"] for r in lap1_rows}
    for h in play_order:
        if h not in lap1_played:
            return 1, h
    lap = 2
    while True:
        overflow_rows = await conn.fetch(
            "SELECT hole_number FROM tournament_round_hole_flag_overflow "
            "WHERE tournament_round_participant_id = $1 AND lap = $2 AND played",
            tournament_round_participant_id, lap,
        )
        overflow_played = {r["hole_number"] for r in overflow_rows}
        for h in play_order:
            if h not in overflow_played:
                return lap, h
        lap += 1


class FlagPlantIn(BaseModel):
    lat: float = Field(ge=-90, le=90)
    lng: float = Field(ge=-180, le=180)
    hole_number: int = Field(ge=1, le=18)
    lap: int = Field(ge=1)
    on_green: bool = False
    distance_to_pin_cm: int | None = Field(default=None, ge=0)


class FlagPlantOut(BaseModel):
    id: str
    lap: int
    hole_number: int
    lat: float
    lng: float
    on_green: bool
    distance_to_pin_cm: int | None
    planted_by_user_id: str
    planted_at: str


def _flag_plant_out(row) -> FlagPlantOut:
    return FlagPlantOut(
        id=row["id"], lap=row["lap"], hole_number=row["hole_number"],
        lat=row["lat"], lng=row["lng"], on_green=row["on_green"],
        distance_to_pin_cm=row["distance_to_pin_cm"],
        planted_by_user_id=row["planted_by_user_id"], planted_at=row["planted_at"].isoformat(),
    )


async def _get_round_participant_or_404(conn, tournament_id: str, round_id: str, round_participant_id: str):
    round_row = await conn.fetchrow(
        """
        SELECT tr.hole_config::text AS hole_config, t.scoring_method
        FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
        WHERE tr.id = $1 AND tr.tournament_id = $2
        """,
        round_id, tournament_id,
    )
    if round_row is None:
        raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
    if round_row["scoring_method"] != "flag":
        raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
    rp = await conn.fetchrow(
        "SELECT id, tournament_participant_id::text AS tournament_participant_id "
        "FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
        round_participant_id, round_id,
    )
    if rp is None:
        raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
    return round_row, rp


async def _require_own_tournament_participant(conn, organization_id: str, tournament_participant_id: str, user_id: str) -> None:
    if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user_id):
        raise app_error(
            403, "NOT_TOURNAMENT_PARTICIPANT",
            "Du kan kun plante/registrere flagg for deg selv (eller være organisasjonsadministrator).",
        )


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/flag-plant",
    response_model=FlagPlantOut,
)
async def plant_flag(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    body: FlagPlantIn,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> FlagPlantOut:
    if body.on_green and body.distance_to_pin_cm is None:
        raise app_error(400, "VALIDATION_FAILED", "Oppgi avstand til hullet når flagget plantes på green.")
    if not body.on_green and body.distance_to_pin_cm is not None:
        raise app_error(
            400, "VALIDATION_FAILED", "Avstand til hullet gir kun mening når flagget er plantet på green."
        )
    async with org_connection(organization_id) as conn:
        round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
        await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)

        actual_lap, actual_hole = await _flag_current_position_org(
            conn, round_participant_id, round_row["hole_config"]
        )
        if (body.lap, body.hole_number) != (actual_lap, actual_hole):
            raise app_error(
                400, "VALIDATION_FAILED",
                f"Registrert scoredata sier spilleren er på hull {actual_hole} (lap {actual_lap}), "
                f"ikke hull {body.hole_number} (lap {body.lap}). Før inn score for gjenstående hull først.",
            )

        async with conn.transaction():
            await conn.execute(
                "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
                round_participant_id,
            )
            row = await conn.fetchrow(
                """
                INSERT INTO tournament_round_participant_flag_plant
                    (organization_id, tournament_round_participant_id, lap, hole_number, lat, lng,
                     on_green, distance_to_pin_cm, planted_by_user_id)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
                RETURNING id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
                          planted_by_user_id::text AS planted_by_user_id, planted_at
                """,
                organization_id, round_participant_id, body.lap, body.hole_number, body.lat, body.lng,
                body.on_green, body.distance_to_pin_cm, user.user_id,
            )
    return _flag_plant_out(row)


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/flag-plant",
    response_model=FlagPlantOut | None,
)
async def get_flag_plant(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> FlagPlantOut | None:
    async with org_connection(organization_id) as conn:
        await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
        row = await conn.fetchrow(
            """
            SELECT id::text AS id, lap, hole_number, lat, lng, on_green, distance_to_pin_cm,
                   planted_by_user_id::text AS planted_by_user_id, planted_at
            FROM tournament_round_participant_flag_plant
            WHERE tournament_round_participant_id = $1
            """,
            round_participant_id,
        )
    return _flag_plant_out(row) if row is not None else None


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/flag-plant",
    status_code=204,
)
async def unplant_flag(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> None:
    async with org_connection(organization_id) as conn:
        _, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
        await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)
        await conn.execute(
            "DELETE FROM tournament_round_participant_flag_plant WHERE tournament_round_participant_id = $1",
            round_participant_id,
        )


class FlagOverflowHoleIn(BaseModel):
    gross_strokes: int = Field(gt=0, le=20)


class FlagOverflowHoleOut(BaseModel):
    lap: int
    hole_number: int
    gross_strokes: int | None
    played: bool


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/flag-overflow",
    response_model=list[FlagOverflowHoleOut],
)
async def list_flag_overflow_holes(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[FlagOverflowHoleOut]:
    async with org_connection(organization_id) as conn:
        await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
        rows = await conn.fetch(
            "SELECT lap, hole_number, gross_strokes, played FROM tournament_round_hole_flag_overflow "
            "WHERE tournament_round_participant_id = $1 ORDER BY lap, hole_number",
            round_participant_id,
        )
    return [FlagOverflowHoleOut(**dict(r)) for r in rows]


@router.put(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
    "/participants/{round_participant_id}/flag-overflow/{lap}/holes/{hole_number}",
    response_model=FlagOverflowHoleOut,
)
async def update_flag_overflow_hole(
    tournament_id: str,
    round_id: str,
    round_participant_id: str,
    lap: int,
    hole_number: int,
    body: FlagOverflowHoleIn,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> FlagOverflowHoleOut:
    if lap < 2:
        raise app_error(400, "VALIDATION_FAILED", "Lap må være 2 eller høyere -- lap 1 føres som vanlig scoreføring.")
    if not (1 <= hole_number <= 18):
        raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
    async with org_connection(organization_id) as conn:
        round_row, rp = await _get_round_participant_or_404(conn, tournament_id, round_id, round_participant_id)
        await _require_own_tournament_participant(conn, organization_id, rp["tournament_participant_id"], user.user_id)

        actual_lap, _ = await _flag_current_position_org(conn, round_participant_id, round_row["hole_config"])
        if lap > actual_lap:
            raise app_error(
                400, "VALIDATION_FAILED",
                f"Lap {lap} kan ikke føres ennå -- spilleren er fortsatt i lap {actual_lap}.",
            )

        row = await conn.fetchrow(
            """
            INSERT INTO tournament_round_hole_flag_overflow
                (organization_id, tournament_round_participant_id, lap, hole_number, gross_strokes, played)
            VALUES ($1, $2, $3, $4, $5, true)
            ON CONFLICT (tournament_round_participant_id, lap, hole_number)
            DO UPDATE SET gross_strokes = $5, played = true
            RETURNING lap, hole_number, gross_strokes, played
            """,
            organization_id, round_participant_id, lap, hole_number, body.gross_strokes,
        )
    return FlagOverflowHoleOut(**dict(row))


# ---------------------------------------------------------------------------
# Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) -- speiler
# rounds.py sin frittstående-variant presist. Styrt av tournament.flag_map_
# visible (ethvert org-medlem kan slå av/på via update_tournament,
# tournaments.py -- se TournamentUpdate.flag_map_visible). Bevisst KUN
# org-medlem-/deltaker-tilgang (samme presedens som flag_round_result over)
# -- ingen offentlig tilskuer-gren, individuelle turneringer har ingen
# offentlig spectator-side i det hele tatt ennå (ulikt frittstående
# rundevisning), se ADR-067 for avklaringen.
# ---------------------------------------------------------------------------

class FlagMapEntryOut(BaseModel):
    participant_id: str
    display_name: str
    lap: int
    hole_number: int
    lat: float
    lng: float
    on_green: bool
    distance_to_pin_cm: int | None
    planted_at: str


class FlagMapOut(BaseModel):
    visible_to_all: bool
    flags: list[FlagMapEntryOut]


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-map",
    response_model=FlagMapOut,
)
async def get_flag_map(
    tournament_id: str,
    round_id: str,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> FlagMapOut:
    async with org_connection(organization_id) as conn:
        round_row = await conn.fetchrow(
            """
            SELECT t.flag_map_visible
            FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
            WHERE tr.id = $1 AND tr.tournament_id = $2
            """,
            round_id,
            tournament_id,
        )
        if round_row is None:
            raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
        visible_to_all = round_row["flag_map_visible"]

        base_query = """
            SELECT trpfp.tournament_round_participant_id::text AS participant_id,
                   p.display_name AS display_name,
                   trpfp.lap, trpfp.hole_number, trpfp.lat, trpfp.lng,
                   trpfp.on_green, trpfp.distance_to_pin_cm, trpfp.planted_at
            FROM tournament_round_participant_flag_plant trpfp
            JOIN tournament_round_participant trp ON trp.id = trpfp.tournament_round_participant_id
            JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
            JOIN player p ON p.id = tp.player_id
            WHERE trp.tournament_round_id = $1
        """
        if visible_to_all:
            rows = await conn.fetch(base_query, round_id)
        else:
            rows = await conn.fetch(
                base_query + " AND p.user_id = $2",
                round_id,
                user.user_id,
            )

    return FlagMapOut(
        visible_to_all=visible_to_all,
        flags=[
            FlagMapEntryOut(
                participant_id=r["participant_id"],
                display_name=r["display_name"],
                lap=r["lap"],
                hole_number=r["hole_number"],
                lat=r["lat"],
                lng=r["lng"],
                on_green=r["on_green"],
                distance_to_pin_cm=r["distance_to_pin_cm"],
                planted_at=r["planted_at"].isoformat(),
            )
            for r in rows
        ],
    )
