"""
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-indeks-oppdatering (app_user.handicap_index/handicap_history) skjer
IKKE automatisk her -- eksplisitt uavklart punkt i ADR-033. Et fullført,
tellende resultat lagres kun som `round_participant.score_differential`.
"""

from __future__ import annotations

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

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

from .. import teeoff_client
from ..auth import CurrentUser, get_current_user
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
    adjusted_gross_score,
    allocate_strokes_by_index,
    course_handicap,
    round_counts_for_handicap,
    score_differential,
)

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))


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


# ---------------------------------------------------------------------------
# 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"]


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


class RoundParticipantOut(BaseModel):
    id: str
    user_id: str | None
    guest_name: str | None
    is_owner: bool
    gender: str
    handicap_index_snapshot: float | None
    course_handicap_snapshot: int | None
    counts_for_handicap: bool
    score_differential: float | None
    stat_level: StatLevel


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
    started_at: str | None
    completed_at: str | None
    participants: list[RoundParticipantOut]
    # Eierens egen fremdrift/score, utledet fra round_hole (aldri lagret) --
    # brukt av rundelisten (round-card.tsx) som i dag manglet ethvert tall i
    # det hele tatt (kun tee/hull/dato/spillerantall), rapportert av bruker
    # 2026-07-25. `owner_score_to_par` er None helt til minst ett hull er
    # registrert.
    owner_holes_played: int
    owner_total_score: int | None
    owner_score_to_par: int | None


async def _load_round_out(conn, round_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, started_at, completed_at
        FROM round WHERE id = $1
        """,
        round_id,
    )
    participant_rows = await conn.fetch(
        """
        SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
               handicap_index_snapshot::float AS handicap_index_snapshot,
               course_handicap_snapshot, counts_for_handicap,
               score_differential::float AS score_differential, stat_level
        FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
        """,
        round_id,
    )
    owner_id = next((r["id"] for r in participant_rows if r["is_owner"]), None)
    owner_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
        """,
        owner_id,
    )
    owner_holes_played = owner_agg["played_count"] if owner_agg else 0
    owner_total_score = owner_agg["total_score"] if owner_holes_played > 0 else None
    owner_score_to_par = (
        owner_agg["total_score"] - owner_agg["total_par"] if owner_holes_played > 0 else None
    )
    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"],
        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],
        owner_holes_played=owner_holes_played,
        owner_total_score=owner_total_score,
        owner_score_to_par=owner_score_to_par,
    )


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",
) -> 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, is_owner, gender, handicap_index_snapshot,
             course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
             stat_level)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
        RETURNING id::text AS id
        """,
        round_id,
        user_id,
        guest_name,
        is_owner,
        gender,
        handicap_index,
        course_rating,
        slope_rating,
        tee_par,
        course_handicap_snapshot,
        stat_level,
    )
    participant_id = participant_row["id"]

    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)

    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)
                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
                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,
            )
            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,
            )

        return await _load_round_out(conn, round_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:
        ids = await conn.fetch(
            "SELECT id::text AS id FROM round WHERE owner_user_id = $1 ORDER BY played_at DESC, created_at DESC",
            user.user_id,
        )
        return [await _load_round_out(conn, r["id"]) for r in ids]


async def _get_owned_round_or_404(conn, round_id: str, user_id: str):
    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.")


@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_owned_round_or_404(conn, round_id, user.user_id)
        return await _load_round_out(conn, round_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)
    # 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


@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.
    """
    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.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 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 course_rating_snapshot = $2, slope_rating_snapshot = $3,
                               tee_par_snapshot = $4, course_handicap_snapshot = $5
                        WHERE id = $1
                        """,
                        p["id"], 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],
                        )

        return await _load_round_out(conn, round_id)


@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)
        await conn.execute("DELETE FROM round WHERE id = $1", round_id)


# ---------------------------------------------------------------------------
# Gjest-deltakere (flighten) -- se moduldoc om hvorfor kun gjester i v1
# ---------------------------------------------------------------------------

class GuestParticipantCreate(BaseModel):
    guest_name: str = Field(min_length=1, max_length=100)
    gender: Literal["m", "f", "x"]
    handicap_index: float | None = Field(default=None, ge=-10, le=54)
    stat_level: StatLevel = "strokes_only"


@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
async def add_guest_participant(
    round_id: str,
    body: GuestParticipantCreate,
    user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
    async with plain_connection() as conn:
        await _get_owned_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, tee_name_snapshot 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"])

        if resolved.rating_for(round_row["tee_name_snapshot"], body.gender) is None and body.handicap_index is not None:
            raise app_error(
                400, "VALIDATION_FAILED",
                "Rundens 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, round_row["tee_name_snapshot"],
                user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
                gender=body.gender, handicap_index=body.handicap_index,
                stat_level=body.stat_level,
            )
        row = await conn.fetchrow(
            """
            SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
                   handicap_index_snapshot::float AS handicap_index_snapshot,
                   course_handicap_snapshot, counts_for_handicap,
                   score_differential::float AS score_differential, stat_level
            FROM round_participant WHERE id = $1
            """,
            participant_id,
        )
        return RoundParticipantOut(**dict(row))


class ParticipantUpdate(BaseModel):
    stat_level: StatLevel


@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:
    async with plain_connection() as conn:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        row = await conn.fetchrow(
            """
            UPDATE round_participant SET stat_level = $3
            WHERE id = $1 AND round_id = $2
            RETURNING id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
                      handicap_index_snapshot::float AS handicap_index_snapshot,
                      course_handicap_snapshot, counts_for_handicap,
                      score_differential::float AS score_differential, stat_level
            """,
            participant_id, round_id, body.stat_level,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
        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 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)


# ---------------------------------------------------------------------------
# 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


@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_owned_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.")
        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
    ]


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


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


@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_owned_round_or_404(conn, round_id, user.user_id)
        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 owner_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

            net_score_to_par = None
            if total_score is not None and 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)}
                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

            display_name = p["guest_name"] if not p["is_owner"] else (p["owner_display_name"] or "Deg")
            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,
                )
            )

        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,
    )


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:
        await _get_owned_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]

    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:
        await _get_owned_round_or_404(conn, round_id, user.user_id)
        round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)

        participants = await conn.fetch(
            """
            SELECT id::text AS 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)
        return await _load_round_out(conn, round_id)


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"]
    )
