"use client"

// Hull-for-hull-registrering for en frittstående runde (ADR-033).
// Presentasjon fra V0, datalag skrevet om fra mock til ekte fetch/PATCH.
//
// VIKTIG kontraktsdetalj (bekreftet i scratch): hull-PATCH-endepunktet er
// IKKE et ekte delvis-PATCH -- det skriver ALLE felt ved hvert kall. Derfor
// slår updateStat() alltid sammen med gjeldende hull-data FØR den sender,
// aldri kun det ene feltet som ble endret.

import type React from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import dynamic from "next/dynamic"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { RoundPageShell } from "@/components/round-page-shell"
import { RoundMessages } from "@/components/round-messages"
import type { FlagMapEntry } from "@/components/flag-map-overview"
import {
  ArrowRight,
  Check,
  ChevronDown,
  ChevronsUpDown,
  Clock,
  Trash2,
  ChevronLeft,
  ChevronRight,
  Crosshair,
  Flag,
  MapPin,
  Plus,
  RefreshCw,
  Ruler,
  Search,
  Settings2,
  Trophy,
  Users,
  WifiOff,
  X,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { ClubPicker } from "@/components/teecup/club-picker"
import {
  ChoiceRow,
  DirectionCross,
  golfTermForScore,
  HoleHistoryPanel,
  NumberPicker,
  Stepper,
  WizardSection,
} from "@/components/hole-stat-inputs"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { HoleTargetDistance } from "@/components/hole-target-distance"
import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { bearingDegrees } from "@/lib/geo"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"

// --- Types -----------------------------------------------------------------

type Gender = "male" | "female" | "other"
type ApiGender = "m" | "f" | "x"
type TeeShot = "left" | "fairway" | "right"
type Approach = "left" | "short" | "hit" | "long" | "right"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"

type Player = {
  id: string
  // Alltid den faktiske display_name (2026-07-27: fjernet "Deg"-erstatning
  // her -- se playerLabel() -- brøt "roster-kontekst → fullt navn" og ga en
  // "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
  name: string
  userId: string | null
  guestFirstName: string | null
  guestLastName: string | null
  guestEmail: string | null
  isOwner: boolean
  gender: Gender
  hcp: number | null
  // Tildelte slag for RUNDEN (course handicap, ikke selve HCP-indeksen) --
  // null når HCP ikke spores for spilleren (2026-07-26).
  courseHandicap: number | null
  teeName: string
  isSelf: boolean
  countsForHandicap: boolean
  scoreDifferential: number | null
  statLevel: StatLevel
  // ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av
  // countsForHandicap (den automatiske kvalifiseringen).
  excludeFromHandicap: boolean
  // ADR-039 -- hvilken side (match/fourball/foursome/greensome/scramble),
  // og den format-relative match-play-enheten (ikke samme som courseHandicap).
  roundSideId: string | null
  playingHandicap: number | null
  // Money Ball (2026-07-30) -- se ApiParticipant.lineup_order.
  lineupOrder: number | null
  // ADR-065 -- se ApiParticipant.completed.
  completed: boolean
}

type HoleStat = {
  played: boolean
  strokes: number | null
  // "Plukket opp" (migrasjon 038, Stableford) -- serveren regner selv ut
  // og skriver Net Double Bogey som `strokes` når dette er sant; feltet
  // her er KUN til visning ("Plukket opp" i stedet for tallet).
  pickedUp: boolean
  putts: number | null
  club: string
  teeShot: TeeShot | null
  approach: Approach | null
  chip: number
  bunker: number
  penalty: number
  firstPuttBucket: PuttBucket | null
  anywayStrokes: number | null
}

type Hole = {
  holeNumber: number
  par: number
  index: number
}

function emptyStat(): HoleStat {
  return {
    played: false,
    strokes: null,
    pickedUp: false,
    putts: null,
    club: "",
    teeShot: null,
    approach: null,
    chip: 0,
    bunker: 0,
    penalty: 0,
    firstPuttBucket: null,
    anywayStrokes: null,
  }
}

function statKey(playerId: string, holeNumber: number) {
  return `${playerId}:${holeNumber}`
}

// Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som
// round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for
// akkumulert score-så-langt per spiller i den nye scoringsflyten).
function formatToPar(value: number): string {
  if (value === 0) return "E"
  return value > 0 ? `+${value}` : `−${Math.abs(value)}`
}

function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null {
  if (!holes) return null
  const played = holes.filter((h) => h.played && h.score !== null)
  if (played.length === 0) return null
  const score = played.reduce((sum, h) => sum + (h.score as number), 0)
  const par = played.reduce((sum, h) => sum + h.par, 0)
  return { toPar: score - par, holesPlayed: played.length }
}

function apiGenderToUi(g: ApiGender): Gender {
  return g === "m" ? "male" : g === "f" ? "female" : "other"
}
function uiGenderToApi(g: Gender): ApiGender {
  return g === "male" ? "m" : g === "female" ? "f" : "x"
}

// --- API-typer ---------------------------------------------------------------

type ApiParticipant = {
  id: string
  user_id: string | null
  guest_name: string | null
  guest_first_name: string | null
  guest_last_name: string | null
  // Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
  guest_email: string | null
  // Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
  // visningsnavn for en lenket bruker (eier eller medspiller).
  display_name: string
  is_owner: boolean
  gender: ApiGender
  // Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
  // deltakers, redigerbart av eieren kun for DENNE runden.
  tee_name_snapshot: string
  handicap_index_snapshot: number | null
  course_handicap_snapshot: number | null
  counts_for_handicap: boolean
  score_differential: number | null
  stat_level: StatLevel
  exclude_from_handicap: boolean
  // ADR-039 -- hvilken side deltakeren er på (match/fourball/foursome/
  // greensome/scramble), null for slagspill/skins (ingen sider).
  round_side_id: string | null
  // ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som
  // course_handicap_snapshot (den absolutte WHS-verdien).
  playing_handicap: number | null
  // Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3),
  // null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball".
  lineup_order: number | null
  // Per-deltaker-fullføring (ADR-065, migrasjon 066) -- UAVHENGIG av
  // round.completed_at, kun en nagging-brems + e-post-trigger for DENNE
  // spilleren, ikke en cascade til hele rundens fullføring.
  completed: boolean
}

type ApiSide = {
  id: string
  label: string | null
  // KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
  // formater, som ikke skiller sidene fra hverandre strukturelt.
  side_role: "team" | "individual" | null
}

type PersonMatch = {
  id: string
  first_name: string
  last_name: string
  avatar_url: string | null
  home_club: string | null
  handicap_index: number | null
}

type ApiTeeOption = {
  name: string
  genders: ApiGender[]
}

type ApiRound = {
  id: string
  name: string | null
  course_name_snapshot: string
  tee_name_snapshot: string
  played_at: string
  start_hole: number
  holes_planned: number
  play_format: string
  skins_scoring: string | null
  skins_tie_handling: string | null
  started_at: string | null
  completed_at: string | null
  participants: ApiParticipant[]
  sides: ApiSide[]
  setup_complete: boolean
  setup_message: string | null
  // ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
  // innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
  visibility_mode: string
  visible_categories: string[]
  // Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
  flight_group_id: string | null
  // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
  flag_map_visible: boolean
}

// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
// sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering
// fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen).
const VISIBILITY_CATEGORY_OPTIONS = [
  { code: "spouse", label: "Make" },
  { code: "close_family", label: "Nær familie" },
  { code: "extended_family", label: "Storfamilie" },
  { code: "close_friends", label: "Nære venner" },
  { code: "golf_friends", label: "Golfvenner" },
  { code: "colleagues", label: "Kollegaer" },
  { code: "business", label: "Forretningsforbindelser" },
  { code: "classmates", label: "Studiekamerater" },
  { code: "acquaintances", label: "Perifere bekjente" },
  { code: "other", label: "Ymse" },
] as const

// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
// server-side -- disse styrer bare presentasjon.
// Åtte nye formater (2026-07-30). Chapman er to-sidet+delt-ball (som
// foursome). High-low-high er to-sidet+individuell-ball (som fourball).
// Copenhagen/BBB/Flag/Shamble/Money Ball er ALDRI to-sidet (flatt felt,
// se ADR/plan) -- faller derfor gjennom til vanlig individuell-ball
// scorekort/veiviser (samme som stroke/stableford/skins), med egne
// tilleggsvisninger i FormatResultPanel.
const TWO_SIDED_FORMATS = new Set([
  "match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
])
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
// Scramble mot enkeltspiller (migrasjon 056/057) -- HALVPARTEN delt-ball
// (laget deler ball via round_side_id) og HALVPARTEN individuell
// (motstanderen scorer per deltaker via round_participant_id). Passer
// derfor IKKE inn i SHARED_BALL_FORMATS sin alt-eller-ingenting-bryter --
// se scrambleSoloTeamSideId lenger ned, som ruter KUN lagets spillere til
// SideScoreWizard, mens individualspilleren fortsatt bruker vanlig
// ScoringWizard.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const SIDE_PLAYER_COUNT: Record<string, number> = {
  match: 1,
  fourball: 2,
  foursome: 2,
  greensome: 2,
  scramble_2: 2,
  scramble_4: 4,
  chapman: 2,
  high_low_high: 2,
}
const FORMAT_LABELS: Record<string, string> = {
  stroke: "Slagspill",
  stableford: "Stableford",
  match: "Match",
  skins: "Skins",
  fourball: "Fourball",
  foursome: "Foursome",
  greensome: "Greensome",
  scramble_2: "Scramble (2)",
  scramble_4: "Scramble (4)",
  chapman: "Chapman",
  copenhagen: "Københavner",
  bbb: "Bingo Bango Bongo",
  flag: "Flaggturnering",
  shamble: "Shamble",
  money_ball: "Money Ball",
  high_low_high: "High-low-high",
  scramble_solo: "Scramble mot enkeltspiller",
  scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "money_ball"])

// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
// lagret varighet, kun utledet ved visning.
function formatDuration(startedAt: string, completedAt: string): string | null {
  const start = new Date(startedAt).getTime()
  const end = new Date(completedAt).getTime()
  if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null
  const totalMinutes = Math.round((end - start) / 60000)
  const hours = Math.floor(totalMinutes / 60)
  const minutes = totalMinutes % 60
  if (hours === 0) return `${minutes} min`
  return `${hours}t ${minutes}min`
}

type ApiHole = {
  hole_number: number
  par: number
  stroke_index: number
  played: boolean
  score: number | null
  picked_up: boolean
  putts: number | null
  club_off_tee: string | null
  tee_shot_result: TeeShot | null
  approach_result: Approach | null
  chip_count: number | null
  bunker_shot_count: number | null
  penalty_strokes: number | null
  first_putt_distance_bucket: PuttBucket | null
  anyway_strokes: number | null
  strokes_received: number | null
  // Optimistisk versjonssjekk (ADR-057) -- sendes tilbake som
  // expected_version ved neste PATCH, se updateWizardStat.
  version: number
}

// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
// har ÉN score PER SIDE per hull, ikke per spiller -- ingen av de andre
// detalj-feltene gir entydig mening for en delt ball.
type ApiSideHole = {
  hole_number: number
  par: number
  stroke_index: number
  played: boolean
  score: number | null
  // Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
  // (migrasjon 036) -- helt valgfritt, null = ikke registrert.
  selected_participant_id: string | null
  // Sidens kombinerte playing_handicap sin allokering for DETTE hullet --
  // fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her
  // før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut").
  strokes_received: number | null
  // Optimistisk versjonssjekk (ADR-057) -- se ApiHole.
  version: number
}

function apiHoleToStat(h: ApiHole): HoleStat {
  return {
    played: h.played,
    strokes: h.score,
    pickedUp: h.picked_up,
    putts: h.putts,
    club: h.club_off_tee ?? "",
    teeShot: h.tee_shot_result,
    approach: h.approach_result,
    chip: h.chip_count ?? 0,
    bunker: h.bunker_shot_count ?? 0,
    penalty: h.penalty_strokes ?? 0,
    firstPuttBucket: h.first_putt_distance_bucket,
    anywayStrokes: h.anyway_strokes,
  }
}

function statToPatchBody(s: HoleStat) {
  return {
    played: s.played,
    score: s.strokes,
    picked_up: s.pickedUp,
    putts: s.putts,
    club_off_tee: s.club.trim() === "" ? null : s.club,
    tee_shot_result: s.teeShot,
    approach_result: s.approach,
    chip_count: s.chip,
    bunker_shot_count: s.bunker,
    penalty_strokes: s.penalty,
    first_putt_distance_bucket: s.firstPuttBucket,
    anyway_strokes: s.anywayStrokes,
  }
}

// Alltid det faktiske visningsnavnet -- ALDRI "Deg" (2026-07-27, compliance-
// pass mot teecup-scorekort-og-entry-spec.md). "Deg" var tidligere en
// viewer-relativ erstatning for selve navnet, men det produserte en ekte
// "Deg Deg"-duplikat der navnet OGSÅ sto ved siden av en egen "Deg"-badge
// (spillerkort, scoringslisten) -- og brøt spesifikasjonens eksplisitte
// "roster-kontekst → fullt navn"-regel i skjermer uten noen badge i det
// hele tatt (veiviserens header/kontekst-rad, "Neste: {navn}", fullført-
// banneret). `isSelf` (se players-konstruksjonen under) er fortsatt
// viewer-relativ og brukes alene til å vise "Deg"-badgen der det trengs.
function playerLabel(p: ApiParticipant): string {
  return p.display_name
}

// --- Component -------------------------------------------------------------

export function RoundDetail({ roundId }: { roundId: string }) {
  const router = useRouter()
  // Delt header (ADR-040) sin "Administrer"-dialog lenker hit med
  // ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard-
  // siden. Fungerte derfra fordi det er en navigasjon MELLOM to ulike
  // rute-komponenter (ekte remount, `initialTab` regnes ut på nytt) --
  // men fra selve Score-siden er `/my-rounds/{id}` -> `/my-rounds/{id}
  // ?tab=manage` KUN en søkeparameter-endring på SAMME rute, ingen
  // remount, så `useState(initialTab)` sitt opprinnelige mount-tidspunkt-
  // resultat ble stående uendret -- knappen så ut til å ikke gjøre noe
  // (rapportert av bruker via video 2026-08-03, bekreftet: URL-en endret
  // seg riktig til ?tab=manage, men selve fanen forble "Score"). Fikset
  // med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke
  // bare mount -- ensrettet (kun til "manage"), rører aldri brukerens
  // egen lokale "Score"-valg via pillen etterpå.
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score"
  // Bruker rapporterte 2026-08-18: en refresh mens man sto på f.eks. hull 7
  // hoppet alltid tilbake til starthullet (se `currentHole` sin seeding fra
  // `data.start_hole` i loadRound under) -- hull-valget levde KUN i React-
  // state, aldri i URL-en, så en full remount (refresh) mistet det
  // fullstendig. Samme `?param`-i-URL-mønster som `tab` over: les ved mount,
  // hold i sync ved senere endringer via `setHoleAndUrl` (kalt fra ALLE
  // steder som i dag kaller `setCurrentHole` direkte).
  const initialHoleParam = searchParams.get("hole")
  const initialHole = initialHoleParam && /^\d+$/.test(initialHoleParam) ? Number(initialHoleParam) : null
  const [round, setRound] = useState<ApiRound | null>(null)
  const [error, setError] = useState<string | null>(null)
  // ADR-057 (2026-08-10): `error` over er en FATAL, hele-siden-erstattende
  // tilstand (se early return under -- "runden finnes ikke" e.l.). En
  // versjonskonflikt på ETT hull (eller en avvist kø-synk) er forbigående
  // og skal IKKE ta over hele skjermen -- brukeren skal bare varsles og
  // fortsette å bruke resten av runden. Egen, dismissbar banner-tilstand
  // i stedet for å gjenbruke `error` her.
  const [conflictNotice, setConflictNotice] = useState<string | null>(null)
  const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
  const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
  // Flaggturnering: GPS-flaggplanting + runde 2+ (2026-08-14) -- null i
  // flagPlantByParticipant betyr "lastet, men ikke plantet ennå" (skilt fra
  // "ikke lastet", som er fraværet av nøkkelen).
  const [flagPlantByParticipant, setFlagPlantByParticipant] = useState<Record<string, ApiFlagPlant | null>>({})
  const [flagOverflowByParticipant, setFlagOverflowByParticipant] = useState<Record<string, ApiFlagOverflowHole[]>>({})
  // Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
  // knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
  // for eieren selv (gjester har ingen profil å hente dette fra).
  const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
  // Den innloggede brukerens egen id (ADR-036 fase 3-utvidelsen,
  // 2026-07-26) -- runden kan nå åpnes av en lenket medspiller, ikke bare
  // eieren, så "hvem er 'Deg'" og "hvem har lov til å forvalte runden"
  // avhenger av DENNE, ikke av `is_owner` alene.
  const [viewerId, setViewerId] = useState<string | null>(null)
  // `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
  // er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
  // lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
  // hull 1 uansett faktisk starthull). Seedes fra `?hole=`-URL-parameteren
  // hvis den finnes (se over) -- ellers forblir den `null` til `loadRound`
  // fyller inn `data.start_hole`, akkurat som før.
  const [currentHole, setCurrentHole] = useState<number | null>(initialHole)
  // Eneste stedet hull-navigasjon skal skje fra -- oppdaterer BÅDE React-
  // state og URL-en (`?hole=`) sammen, slik at en refresh beholder valget.
  // `router.replace` (ikke `push`) -- hull-bytte skal ikke fylle historikken
  // med et tilbake-steg per hull.
  const setHoleAndUrl = useCallback(
    (n: number) => {
      setCurrentHole(n)
      const params = new URLSearchParams(searchParams.toString())
      params.set("hole", String(n))
      router.replace(`${pathname}?${params.toString()}`, { scroll: false })
    },
    [pathname, router, searchParams],
  )
  // ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins-
  // tavlen skjer (hull-registrering, websocket-signal), slik at
  // formatResult-hentingen under vet den skal hente på nytt.
  const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0)
  // Samme "noe endret seg"-signal, brukt av RoundMessages (ADR-044) til å
  // hente kommentarer/bilder på nytt ved WS-signal, uten en egen socket.
  const [messagesRefreshTick, setMessagesRefreshTick] = useState(0)
  // Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/
  // SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant
  // hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad,
  // ikke bare selve statusbanneret. Én henting, delt av begge.
  const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
  const [showAddGuest, setShowAddGuest] = useState(false)
  // Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/
  // spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26,
  // etterspurt av bruker etter å ha sammenlignet direkte med en
  // konkurrentapp -- forrige runde flyttet kun ScoreSoFar, men lot alt det
  // ANDRE administrative innholdet stå igjen øverst på samme side som
  // scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt
  // når frem til oppgaven). "Score" er default -- den ENESTE fanen de
  // fleste trenger under selve spillingen.
  const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab)
  // Fikser "Administrer"-snarveien fra selve Score-siden (se begrunnelse
  // ved searchParams over) -- reagerer på selve parameter-ENDRINGEN i
  // stedet for kun mount-tidspunktet. Ensrettet med vilje: hopper TIL
  // "manage" når URL-en ber om det, men tvinger aldri tilbake til "score"
  // om parameteren mangler/fjernes (det ville kunnet rive brukeren vekk
  // fra et bevisst lokalt "Score"-valg de allerede har gjort).
  useEffect(() => {
    if (searchParams.get("tab") === "manage") setPageTab("manage")
  }, [searchParams])
  // Fanger opp nettleserens frem/tilbake-navigasjon (URL-en endrer seg uten
  // at `setHoleAndUrl` kalte den) -- speiler `?hole=`-parameteren inn i
  // state igjen. Sammenligner mot gjeldende verdi først for å unngå en
  // uendelig løkke med `setHoleAndUrl` sin egen `router.replace`.
  useEffect(() => {
    const p = searchParams.get("hole")
    const n = p && /^\d+$/.test(p) ? Number(p) : null
    if (n !== null) setCurrentHole((prev) => (prev === n ? prev : n))
  }, [searchParams])
  const [completing, setCompleting] = useState(false)
  const [deleting, setDeleting] = useState(false)
  const [showEditRound, setShowEditRound] = useState(false)
  // Hvilken spillers "Rediger"-panel som er åpent, om noen (2026-07-26 --
  // panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
  // derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
  const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
  // Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT
  // HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for
  // aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt).
  const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
  // ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer PER
  // SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
  const [wizardSideId, setWizardSideId] = useState<string | null>(null)
  const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
  // Score-fanens hull-fokuserte kortvisning (2026-08-03, V0-eksport zip 27,
  // etterspurt av bruker fordi det brede gridet tok for stor plass i bredden
  // på mobil -- se teecup-scorekort-og-entry-spec.md §1 for gridet dette
  // supplerer). Gridet i seg selv er UENDRET og fortsatt tilgjengelig, bare
  // ikke lenger standardvisningen -- default lukket ("ingenting mistes, kun
  // ett ekstra trykk unna").
  const [showFullScorecard, setShowFullScorecard] = useState(false)

  // --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------
  // Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx)
  // -- portert hit siden frittstående runder er der man oftest står alene
  // ute på banen uten dekning, og ADR-028s kø tidligere kun var koblet til
  // turnering-scorekortet. Enklere her: PATCH-endepunktene erstatter HELE
  // hull-raden per kall (ikke en append-historie), så en køet skriving kan
  // speiles direkte inn i holesByParticipant/holesBySide -- ingen egen
  // verdi-overlay nødvendig, kun et sett med nøkler for selve "lagret
  // lokalt"-indikatoren i veiviseren.
  const [isOnline, setIsOnline] = useState(true)
  const [pendingCount, setPendingCount] = useState(0)
  const [syncing, setSyncing] = useState(false)
  const [pendingParticipantHoles, setPendingParticipantHoles] = useState<Set<string>>(new Set())
  const [pendingSideHoles, setPendingSideHoles] = useState<Set<string>>(new Set())
  // Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
  // NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
  // helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
  // panelet) er utenfor skjermen (rapportert av bruker 2026-07-25).
  const holePanelRef = useRef<HTMLElement>(null)
  function scrollToHolePanel() {
    holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
  }

  const loadRound = useCallback(async () => {
    try {
      const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
      if (res.status === 403 || res.status === 404) {
        setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
        return
      }
      if (!res.ok) throw new Error(`round: ${res.status}`)
      const data: ApiRound = await res.json()
      setRound(data)
      setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
      setCurrentHole((prev) => prev ?? data.start_hole)
      // ADR-039 -- sider/deltaker-tildeling kan ha endret seg (SidesPanel
      // kaller denne som onSidesChanged), som igjen kan gjøre FormatResultPanel
      // sitt tidligere "ikke klart"-svar utdatert. Bump ved HVER runde-refetch
      // (billig, og runde-refetch skjer uansett ikke ofte nok til at det er en
      // reell kostnad) i stedet for å spore hver enkelt kallsted som kan
      // påvirke handicap-beregningen.
      setFormatResultRefreshTick((n) => n + 1)
    } catch {
      setError("Klarte ikke å hente runden. Prøv igjen om litt.")
    }
  }, [roundId])

  useEffect(() => {
    void loadRound()
  }, [loadRound])

  // Anker-scroll til kommentarseksjonen (ADR-047) -- leaderboardets nye
  // "Kommentarer"-lenke peker hit, men elementet finnes ikke i DOM-en før
  // `round` er lastet, så nettleserens EGEN anker-scroll (som skjer FØR
  // dette) rekker ikke frem. Trigges på nytt manuelt her når dataen er der.
  useEffect(() => {
    if (!round) return
    if (window.location.hash !== "#kommentarer") return
    document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
  }, [round])

  // Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri
  // lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid
  // (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford
  // (migrasjon 038) trenger ingen egen henting -- ingen sider/matchstatus.
  useEffect(() => {
    if (!round || round.play_format === "stroke" || round.play_format === "stableford") {
      setFormatResult(null)
      return
    }
    let cancelled = false
    fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then((data: ApiFormatResult | null) => {
        if (!cancelled) setFormatResult(data)
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [roundId, round?.play_format, formatResultRefreshTick])

  // Bingo Bango Bongo (2026-07-30) -- rå bingo/bango/bongo-VALG per hull
  // (ikke poengene, som ligger i formatResult over). Egen henting siden
  // dette er en separat tabell (round_bbb_hole), ikke del av
  // RoundFormatResultOut sin generiske form.
  const [bbbHoles, setBbbHoles] = useState<Record<number, ApiBBBHole>>({})
  useEffect(() => {
    if (!round || round.play_format !== "bbb") {
      setBbbHoles({})
      return
    }
    let cancelled = false
    fetch(`/rounds/${roundId}/bbb`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : []))
      .then((data: ApiBBBHole[]) => {
        if (!cancelled) setBbbHoles(Object.fromEntries(data.map((h) => [h.hole_number, h])))
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [roundId, round?.play_format, formatResultRefreshTick])

  async function patchBbbHole(holeNumber: number, body: Record<string, string | null>) {
    const res = await fetch(`/rounds/${roundId}/bbb/${holeNumber}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify(body),
    })
    if (!res.ok) return
    const updated: ApiBBBHole = await res.json()
    setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated }))
    setFormatResultRefreshTick((n) => n + 1)
  }

  // Flaggturnering (2026-08-14) -- laster flagg-status + runde 2-hull for
  // ALLE deltakere (samme "ikke bare aktiv spiller"-begrunnelse som
  // holesByParticipant under), kun for play_format="flag".
  useEffect(() => {
    if (!round || round.play_format !== "flag") {
      setFlagPlantByParticipant({})
      setFlagOverflowByParticipant({})
      return
    }
    let cancelled = false
    async function loadAll() {
      for (const p of round!.participants) {
        const [plantRes, overflowRes] = await Promise.all([
          fetch(`/rounds/${roundId}/participants/${p.id}/flag-plant`, { credentials: "include" }),
          fetch(`/rounds/${roundId}/participants/${p.id}/flag-overflow`, { credentials: "include" }),
        ])
        if (cancelled) return
        const plant: ApiFlagPlant | null = plantRes.ok ? await plantRes.json() : null
        const overflow: ApiFlagOverflowHole[] = overflowRes.ok ? await overflowRes.json() : []
        setFlagPlantByParticipant((prev) => ({ ...prev, [p.id]: plant }))
        setFlagOverflowByParticipant((prev) => ({ ...prev, [p.id]: overflow }))
      }
    }
    void loadAll()
    return () => {
      cancelled = true
    }
  }, [roundId, round?.play_format, round?.participants.length, formatResultRefreshTick])

  async function plantFlag(
    participantId: string,
    body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null },
  ): Promise<{ ok: true } | { ok: false; message: string }> {
    const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify(body),
    })
    if (!res.ok) {
      const errBody = await res.json().catch(() => null)
      return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å plante flagget. Prøv igjen." }
    }
    const updated: ApiFlagPlant = await res.json()
    setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: updated }))
    setFormatResultRefreshTick((n) => n + 1)
    return { ok: true }
  }

  async function unplantFlag(participantId: string): Promise<void> {
    const res = await fetch(`/rounds/${roundId}/participants/${participantId}/flag-plant`, {
      method: "DELETE",
      credentials: "include",
    })
    if (!res.ok) return
    setFlagPlantByParticipant((prev) => ({ ...prev, [participantId]: null }))
    setFormatResultRefreshTick((n) => n + 1)
  }

  async function updateFlagOverflowHole(
    participantId: string, lap: number, holeNumber: number, score: number,
  ): Promise<{ ok: true } | { ok: false; message: string }> {
    const res = await fetch(
      `/rounds/${roundId}/participants/${participantId}/flag-overflow/${lap}/holes/${holeNumber}`,
      {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ score }),
      },
    )
    if (!res.ok) {
      const errBody = await res.json().catch(() => null)
      return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å registrere score. Prøv igjen." }
    }
    const updated: ApiFlagOverflowHole = await res.json()
    setFlagOverflowByParticipant((prev) => ({
      ...prev,
      [participantId]: [
        ...(prev[participantId] ?? []).filter((h) => !(h.lap === updated.lap && h.hole_number === updated.hole_number)),
        updated,
      ],
    }))
    setFormatResultRefreshTick((n) => n + 1)
    return { ok: true }
  }

  useEffect(() => {
    let cancelled = false
    fetch("/auth/me", { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then((data: { id: string; bag_clubs: string[] } | null) => {
        if (!cancelled && data) {
          setOwnBagClubs(data.bag_clubs)
          setViewerId(data.id)
        }
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [])

  const loadHoles = useCallback(
    async (participantId: string) => {
      const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
      if (!res.ok) return
      const data: ApiHole[] = await res.json()
      setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
    },
    [roundId],
  )

  // Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) --
  // den nye scoringslisten viser akkumulert score-så-langt for HVER spiller
  // samtidig, ikke kun den ene som tilfeldigvis er "aktiv".
  useEffect(() => {
    if (!round) return
    for (const p of round.participants) {
      if (!holesByParticipant[p.id]) {
        void loadHoles(p.id)
      }
    }
  }, [round, holesByParticipant, loadHoles])

  // ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer per
  // SIDE, ikke per deltaker (se holesBySide over).
  const loadSideHoles = useCallback(
    async (sideId: string) => {
      const res = await fetch(`/rounds/${roundId}/sides/${sideId}/holes`, { credentials: "include" })
      if (!res.ok) return
      const data: ApiSideHole[] = await res.json()
      setHolesBySide((prev) => ({ ...prev, [sideId]: data }))
    },
    [roundId],
  )

  useEffect(() => {
    if (!round) return
    for (const s of round.sides) {
      if (!holesBySide[s.id]) {
        void loadSideHoles(s.id)
      }
    }
  }, [round, holesBySide, loadSideHoles])

  // --- Offline scoreregistrering, forts. -------------------------------
  // Køer en PATCH i IndexedDB og speiler den samme kroppen optimistisk inn
  // i lokal state -- body sine felt matcher ApiHole/ApiSideHole sine
  // navn 1:1 (se statToPatchBody), så en enkel spread er nok, ingen egen
  // oversettelse. Serveren får siste ord når synk faktisk skjer (bl.a.
  // strokes_received kan derfor være utdatert helt til da).
  async function queueParticipantHoleWrite(
    participantId: string,
    holeNumber: number,
    body: ReturnType<typeof statToPatchBody>,
  ) {
    const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`
    // expected_version = versjonen vi SIST kjenner til lokalt (ADR-057) --
    // flushQueue kjeder den videre selv for flere køede skrivinger til
    // samme hull i én batch, se offline-queue.ts.
    const currentVersion = holesByParticipant[participantId]?.find((h) => h.hole_number === holeNumber)?.version
    const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
    await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
    setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`))
    setPendingCount((c) => c + 1)
    setHolesByParticipant((prev) => ({
      ...prev,
      [participantId]: (prev[participantId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
    }))
  }

  async function queueSideHoleWrite(
    sideId: string,
    holeNumber: number,
    body: { played: boolean; score: number | null; selected_participant_id: string | null },
  ) {
    const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
    const currentVersion = holesBySide[sideId]?.find((h) => h.hole_number === holeNumber)?.version
    const bodyWithVersion = { ...body, expected_version: currentVersion ?? null }
    await enqueueWrite({ url, method: "PATCH", body: bodyWithVersion, matchId: roundId })
    setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
    setPendingCount((c) => c + 1)
    setHolesBySide((prev) => ({
      ...prev,
      [sideId]: (prev[sideId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
    }))
  }

  // Sender køede skrivinger og henter deltakerens/sidens hull på nytt fra
  // serveren for hver som faktisk fikk noe synkronisert -- reconciles bl.a.
  // strokes_received, som den optimistiske speilingen over ikke kan regne
  // ut selv. Matcher URL-mønsteret for å vite hvilken type/id det gjaldt
  // (samme kø brukes til begge endepunktstypene).
  async function flushPending() {
    if (syncing) return
    setSyncing(true)
    try {
      const outcomes = await flushQueue(roundId)
      if (outcomes.length === 0) return
      const syncedParticipants = new Set<string>()
      const syncedSides = new Set<string>()
      for (const o of outcomes) {
        const participantMatch = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/)
        if (participantMatch) {
          setPendingParticipantHoles((prev) => {
            const next = new Set(prev)
            next.delete(`${participantMatch[1]}:${participantMatch[2]}`)
            return next
          })
          syncedParticipants.add(participantMatch[1])
          continue
        }
        const sideMatch = o.entry.url.match(/\/sides\/([^/]+)\/holes\/(\d+)$/)
        if (sideMatch) {
          setPendingSideHoles((prev) => {
            const next = new Set(prev)
            next.delete(`${sideMatch[1]}:${sideMatch[2]}`)
            return next
          })
          syncedSides.add(sideMatch[1])
        }
      }
      const failed = outcomes.filter((o) => !o.ok)
      if (failed.length > 0) {
        // ADR-057: en avvist kø-synk (f.eks. en versjonskonflikt, se under)
        // er forbigående -- IKKE `setError`, som ville tatt over hele
        // siden. Hullene det gjaldt er allerede hentet på nytt over.
        setConflictNotice(
          `${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
        )
      }
      setPendingCount(await queueCount(roundId))
      for (const id of syncedParticipants) void loadHoles(id)
      for (const id of syncedSides) void loadSideHoles(id)
      setFormatResultRefreshTick((n) => n + 1)
    } finally {
      setSyncing(false)
    }
  }

  useEffect(() => {
    setIsOnline(navigator.onLine)
    function handleOnline() {
      setIsOnline(true)
      void flushPending()
    }
    function handleOffline() {
      setIsOnline(false)
    }
    window.addEventListener("online", handleOnline)
    window.addEventListener("offline", handleOffline)
    return () => {
      window.removeEventListener("online", handleOnline)
      window.removeEventListener("offline", handleOffline)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [roundId])

  // Sjekk for allerede køede skrivinger fra en tidligere økt (f.eks. siden
  // ble lukket mens offline) -- samme engangs-sjekk som session-scorecard.tsx.
  useEffect(() => {
    let cancelled = false
    queueCount(roundId)
      .then((count) => {
        if (cancelled) return
        setPendingCount(count)
        if (count > 0 && navigator.onLine) void flushPending()
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [roundId])

  // Alltid gjeldende deltaker-/side-id-er tilgjengelig INNI WebSocket-
  // handleren under, uten å måtte koble socket-en til/fra ved hver ny
  // runde-lasting (en vanlig closure over `round` i effektens
  // dependency-array ville krevd det).
  const participantIdsRef = useRef<string[]>([])
  const sideIdsRef = useRef<string[]>([])
  useEffect(() => {
    participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
    sideIdsRef.current = round?.sides.map((s) => s.id) ?? []
  }, [round])

  // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
  // signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST-
  // kallene den uansett gjør ved førstegangslasting. Henter nå ALLE
  // deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten
  // viser alles akkumulerte score-så-langt samtidig.
  useEffect(() => {
    const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
    const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
    socket.onmessage = () => {
      void loadRound()
      for (const id of participantIdsRef.current) {
        void loadHoles(id)
      }
      for (const id of sideIdsRef.current) {
        void loadSideHoles(id)
      }
      setFormatResultRefreshTick((n) => n + 1)
      setMessagesRefreshTick((n) => n + 1)
    }
    return () => socket.close()
  }, [roundId, loadRound, loadHoles, loadSideHoles])

  const completed = round?.completed_at != null
  const readOnly = completed

  const players: Player[] =
    round?.participants.map((p) => ({
      id: p.id,
      name: playerLabel(p),
      userId: p.user_id,
      guestFirstName: p.guest_first_name,
      guestLastName: p.guest_last_name,
      guestEmail: p.guest_email,
      isOwner: p.is_owner,
      gender: apiGenderToUi(p.gender),
      hcp: p.handicap_index_snapshot,
      courseHandicap: p.course_handicap_snapshot,
      teeName: p.tee_name_snapshot,
      isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
      countsForHandicap: p.counts_for_handicap,
      scoreDifferential: p.score_differential,
      statLevel: p.stat_level,
      excludeFromHandicap: p.exclude_from_handicap,
      roundSideId: p.round_side_id,
      playingHandicap: p.playing_handicap,
      lineupOrder: p.lineup_order,
      completed: p.completed,
    })) ?? []

  // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
  // medspillere) -- en lenket medspiller kan se runden og registrere
  // score for hele flighten, men ikke dette (ADR-036 fase 3-utvidelsen).
  const isOwnerViewer = viewerId !== null && (round?.participants.some((p) => p.is_owner && p.user_id === viewerId) ?? false)

  const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
  const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
  // Alltid en tallverdi å regne videre på selv rett etter mount, FØR
  // loadRound() har satt currentHole fra round.start_hole.
  const activeHole = currentHole ?? round?.start_hole ?? 1
  // Par/index for aktivt hull -- samme for ALLE deltakere/sider (samme
  // bane), hentet fra HVILKEN SOM HELST kilde som allerede har lastet inn
  // hullene sine (2026-08-05, erstatter en tidligere versjon som kun så på
  // activePlayerId sin egen liste -- brøt permanent for scramble_solo sine
  // lagspillere, som ALDRI får egne round_participant_id-keyede hull-rader
  // siden laget deler ball via round_side_id. En aktiv spiller på laget
  // ga derfor en evig tom `holes`-liste og en evig spinner). Samme mønster
  // som delt-ball-formatenes side-baserte oppslag alltid har brukt, nå
  // generalisert til å også dekke deltaker-baserte kilder.
  const hole: Hole | null = (() => {
    for (const list of Object.values(holesByParticipant)) {
      const h = list.find((x) => x.hole_number === activeHole)
      if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
    }
    for (const s of round?.sides ?? []) {
      const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
      if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
    }
    return null
  })()

  // Navigasjonsrekkefølge starter på øktens starthull og går rundt
  // (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
  // planlegger (holes_planned) -- en 9-hulls runde 10-18 skal aldri vise
  // hull 1-9 i det hele tatt, og "Forrige" fra hull 10 skal gå til hull
  // 18 (siste i settet), ikke hull 9 (funnet 2026-07-24: brukte tidligere
  // alltid 18, uansett holes_planned).
  const holeOrder = round
    ? Array.from({ length: round.holes_planned }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1)
    : []

  // Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
  // nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
  // "Så langt i runden"-oversikt lenger ned som vises).
  const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null
  const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
  const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat()

  async function updateWizardStat(patch: Partial<HoleStat>) {
    if (readOnly || !wizardPlayerId) return
    const merged: HoleStat = { ...wizardStat, ...patch }
    const body = statToPatchBody(merged)
    if (!navigator.onLine) {
      await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
      return
    }
    // Optimistisk versjonssjekk (ADR-057) -- sendes med selv på den
    // direkte (online) skrive-veien, ikke bare via offline-køen.
    const bodyWithVersion = { ...body, expected_version: wizardApiHole?.version ?? null }
    let res: Response
    try {
      res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify(bodyWithVersion),
      })
    } catch {
      // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
      // for å tape registreringen, se ADR-028.
      await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
      return
    }
    if (!res.ok) {
      // 409 = en annen enhet skrev til akkurat dette hullet først (ADR-057).
      // Vis det tydelig og hent inn den andres versjon i stedet for å la
      // brukeren tro sin egen, nå avviste, endring gjaldt.
      if (res.status === 409) {
        setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
        void loadHoles(wizardPlayerId)
      }
      return
    }
    const updated: ApiHole = await res.json()
    setHolesByParticipant((prev) => ({
      ...prev,
      [wizardPlayerId]: (prev[wizardPlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
    }))
    setFormatResultRefreshTick((n) => n + 1)
  }

  function goPrev() {
    const i = holeOrder.indexOf(activeHole)
    setHoleAndUrl(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
    scrollToHolePanel()
  }
  function goNext() {
    const i = holeOrder.indexOf(activeHole)
    setHoleAndUrl(holeOrder[(i + 1) % holeOrder.length])
    scrollToHolePanel()
  }

  // Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
  // siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
  // veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
  // lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
  // ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
  // scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
  // -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
  // mening for en delt ball).
  const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
  // Scramble mot enkeltspiller (2026-08-05) -- KUN lagets side, aldri
  // individualspillerens (den fortsetter å bruke vanlig ScoringWizard/
  // wizardPlayerId). Null for alle andre formater.
  const scrambleSoloTeamSideId =
    round && SCRAMBLE_SOLO_FORMATS.has(round.play_format)
      ? round.sides.find((s) => s.side_role === "team")?.id ?? null
      : null
  const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
  const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null

  // Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
  // "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
  // enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
  // (samme lagscore for alle, keyet på round_side_id), mens
  // individualspilleren fortsatt åpner den vanlige, per-deltaker
  // ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
  // som før.
  function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
    setHoleAndUrl(holeNumber)
    const player = players.find((p) => p.id === playerId)
    if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
      setWizardSideId(scrambleSoloTeamSideId)
      return
    }
    setActivePlayerId(playerId)
    setWizardPlayerId(playerId)
  }

  // Tilbake til en tidligere spiller fra INNI veiviseren (ADR-077,
  // 2026-08-15) -- samme mål-oppløsning som openPlayerOrTeamSideEntry over,
  // men rører IKKE currentHole (vi er allerede på riktig hull, kalt fra
  // kontekst-raden i selve veiviseren).
  function switchWizardPlayer(playerId: string) {
    const target = players.find((p) => p.id === playerId)
    if (scrambleSoloTeamSideId && target?.roundSideId === scrambleSoloTeamSideId) {
      setWizardPlayerId(null)
      setWizardSideId(scrambleSoloTeamSideId)
      return
    }
    setActivePlayerId(playerId)
    setWizardPlayerId(playerId)
  }

  // Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
  // siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
  // veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
  // lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
  // Hopper over lagets spillere for scramble_solo (2026-08-05) -- de scorer
  // ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
  // de skal aldri kunne bli et "neste"-mål her heller.
  function advanceWizardPlayer() {
    // Fullførte spillere (ADR-065) hopper over i samlebåndet -- de skal
    // ikke bli "neste mål" for registrering, akkurat som scramble_solos
    // lag-spillere allerede hoppes over.
    const chain = players.filter((p) => (!scrambleSoloTeamSideId || p.roundSideId !== scrambleSoloTeamSideId) && !p.completed)
    const idx = chain.findIndex((p) => p.id === wizardPlayerId)
    const isLast = idx === -1 || idx === chain.length - 1
    if (!isLast) {
      const next = chain[idx + 1]
      setActivePlayerId(next.id)
      setWizardPlayerId(next.id)
    } else {
      setWizardPlayerId(null)
      setActivePlayerId(chain[0]?.id ?? null)
      goNext()
    }
  }

  // Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE
  // deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
  // vis en tydelig CTA på Score-fanen i stedet for å stole på at brukeren
  // selv husker den tonet-ned knappen i "Spillere og runde"-fanen. Samme
  // `setup_complete`-gate som selve knappen (unngår å reklamere for en
  // handling som uansett ville vært deaktivert).
  const allHolesEnteredForEveryone =
    !completed &&
    round !== null &&
    round.setup_complete &&
    holeOrder.length > 0 &&
    (isSharedBall
      ? round.sides.length >= 2 &&
        round.sides.every((s) => {
          const list = holesBySide[s.id]
          return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
        })
      : players.length > 0 &&
        players.every((p) => {
          // ADR-065 -- en individuelt avsluttet spiller teller som "ferdig"
          // for CTA-formål selv om ikke alle hull i holeOrder faktisk er ført.
          if (p.completed) return true
          const list = holesByParticipant[p.id]
          return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true)
        }))

  // Begge feltene (score OG selected_participant_id) sendes ALLTID sammen
  // (samme "full overwrite hvert kall"-kontrakt som played/score alltid har
  // hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede
  // registrert utslags-valg (migrasjon 036), siden backend ikke gjør en
  // delvis PATCH her.
  async function patchSideHole(score: number | null, selectedParticipantId: string | null) {
    if (readOnly || !wizardSideId) return
    const body = { played: true, score, selected_participant_id: selectedParticipantId }
    if (!navigator.onLine) {
      await queueSideHoleWrite(wizardSideId, activeHole, body)
      return
    }
    // Optimistisk versjonssjekk (ADR-057) -- se updateWizardStat.
    const bodyWithVersion = { ...body, expected_version: wizardSideHole?.version ?? null }
    let res: Response
    try {
      res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify(bodyWithVersion),
      })
    } catch {
      await queueSideHoleWrite(wizardSideId, activeHole, body)
      return
    }
    if (!res.ok) {
      if (res.status === 409) {
        setConflictNotice("Noen andre har endret dette hullet i mellomtiden. Viser siste versjon.")
        void loadSideHoles(wizardSideId)
      }
      return
    }
    const updated: ApiSideHole = await res.json()
    setHolesBySide((prev) => ({
      ...prev,
      [wizardSideId]: (prev[wizardSideId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
    }))
    setFormatResultRefreshTick((n) => n + 1)
  }

  function updateWizardSideScore(score: number | null) {
    void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null)
  }

  // Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt
  // valgfritt, egen handling fra selve slagregistreringen slik at man kan
  // sette/endre valget uten å røre et allerede registrert slagtall.
  function updateWizardSideSelection(participantId: string | null) {
    void patchSideHole(wizardSideHole?.score ?? null, participantId)
  }

  function advanceWizardSide() {
    if (!round) return
    // Scramble mot enkeltspiller (2026-08-05): kun ÉN side er noensinne
    // side-veiviser-styrt (laget) -- individualspilleren har sin egen,
    // separate ScoringWizard-kjede (advanceWizardPlayer). Aldri "hopp til
    // neste side" her, i motsetning til ekte delt-ball-formater der begge
    // sider deler samme veiviser-kjede.
    if (scrambleSoloTeamSideId) {
      setWizardSideId(null)
      goNext()
      return
    }
    const idx = round.sides.findIndex((s) => s.id === wizardSideId)
    const isLast = idx === -1 || idx === round.sides.length - 1
    if (!isLast) {
      setWizardSideId(round.sides[idx + 1].id)
    } else {
      setWizardSideId(null)
      goNext()
    }
  }

  async function addGuest(
    firstName: string,
    lastName: string,
    email: string,
    gender: Gender,
    hcp: number | null,
    statLevel: StatLevel,
  ) {
    const res = await fetch(`/rounds/${roundId}/participants`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({
        guest_first_name: firstName,
        guest_last_name: lastName.trim() === "" ? null : lastName,
        guest_email: email.trim() === "" ? null : email.trim(),
        gender: uiGenderToApi(gender),
        handicap_index: hcp,
        stat_level: statLevel,
      }),
    })
    if (!res.ok) {
      setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
      return
    }
    setShowAddGuest(false)
    const created: ApiParticipant = await res.json()
    await loadRound()
    setActivePlayerId(created.id)
  }

  async function addSearchedParticipant(userId: string, statLevel: StatLevel): Promise<string | null> {
    const res = await fetch(`/rounds/${roundId}/participants`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ user_id: userId, stat_level: statLevel }),
    })
    if (!res.ok) {
      const body = await res.json().catch(() => null)
      return body?.detail?.message ?? "Klarte ikke å legge til spilleren."
    }
    setShowAddGuest(false)
    const created: ApiParticipant = await res.json()
    await loadRound()
    setActivePlayerId(created.id)
    return null
  }

  async function removeGuest(id: string) {
    if (!confirm("Fjerne denne spilleren fra runden?")) return
    const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
    if (!res.ok) return
    if (activePlayerId === id) setActivePlayerId(round?.participants.find((p) => p.is_owner)?.id ?? null)
    await loadRound()
  }

  // Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) for en
  // enkelt deltaker (2026-07-26) -- gjelder KUN denne
  // runden, rører aldri spillerens egen profil. Bygget for å dekke at en
  // medspiller kan spille fra et annet utslag enn eieren, og at en foreslått
  // HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen.
  async function patchParticipant(
    participantId: string,
    body: Record<string, unknown>,
  ): Promise<{ ok: true } | { ok: false; message: string }> {
    const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify(body),
    })
    if (!res.ok) {
      const errBody = await res.json().catch(() => null)
      return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere spilleren. Prøv igjen." }
    }
    const updated: ApiParticipant = await res.json()
    setRound((prev) =>
      prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
    )
    return { ok: true }
  }

  // ADR-065 -- fungerer UANSETT fullføringsstatus, også lenge etter at
  // runden (eller kun denne deltakeren) er fullført. Muterer ingen
  // databasetilstand, kun les+send -- derfor ingen lokal round-oppdatering
  // her, ulikt patchParticipant.
  async function sendScorecard(participantId: string): Promise<{ ok: true } | { ok: false; message: string }> {
    const res = await fetch(`/rounds/${roundId}/participants/${participantId}/send-scorecard`, {
      method: "POST",
      credentials: "include",
    })
    if (!res.ok) {
      const errBody = await res.json().catch(() => null)
      const message =
        errBody?.detail?.code === "NO_EMAIL_ADDRESS"
          ? "Denne spilleren har ingen registrert e-postadresse."
          : (errBody?.detail?.message ?? "Klarte ikke å sende scorekortet. Prøv igjen.")
      return { ok: false, message }
    }
    return { ok: true }
  }

  async function finishRound() {
    if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
    setCompleting(true)
    try {
      const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
      if (!res.ok) throw new Error()
      setRound(await res.json())
    } catch {
      setError("Klarte ikke å fullføre runden. Prøv igjen.")
    } finally {
      setCompleting(false)
    }
  }

  async function deleteRound() {
    if (!confirm("Slette denne runden permanent? Dette kan ikke angres, og gjelder for alle spillerne i runden.")) return
    setDeleting(true)
    try {
      const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
      if (!res.ok) throw new Error()
      router.replace("/my-rounds")
    } catch {
      setError("Klarte ikke å slette runden. Prøv igjen.")
      setDeleting(false)
    }
  }

  // Retter opp feil bane/utslag eller feil antall hull underveis i runden
  // (2026-07-24) -- rører ALDRI allerede registrerte slag/putter/etc.,
  // kun rating-grunnlaget. Backend avviser tydelig (400) hvis en deltaker
  // ville mistet HCP-sporing, uten å skrive noe.
  async function patchRound(body: Record<string, unknown>): Promise<{ ok: true } | { ok: false; message: string }> {
    const res = await fetch(`/rounds/${roundId}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify(body),
    })
    if (!res.ok) {
      const errBody = await res.json().catch(() => null)
      return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere runden. Prøv igjen." }
    }
    const updated: ApiRound = await res.json()
    setRound(updated)
    // Par/stroke-indeks kan ha endret seg for ALLE deltakere -- hent hull-
    // dataen på nytt for alle vi allerede har lastet, ikke bare aktiv spiller.
    const participantIds = Object.keys(holesByParticipant)
    for (const pid of participantIds) {
      await loadHoles(pid)
    }
    return { ok: true }
  }

  if (error) {
    return (
      <div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
        <p className="text-base font-medium text-destructive">{error}</p>
        <Link
          href="/my-rounds"
          className="flex min-h-11 items-center text-base font-semibold text-primary underline underline-offset-2"
        >
          Tilbake til egne runder
        </Link>
      </div>
    )
  }

  if (!round || !activePlayer) {
    return (
      <div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
        <div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
      </div>
    )
  }

  return (
    <RoundPageShell roundId={roundId} activeTab="score">
      <main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
        {conflictNotice && (
          <div
            role="alert"
            className="mb-4 flex items-start justify-between gap-3 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3"
          >
            <p className="text-base font-medium text-destructive">{conflictNotice}</p>
            <button
              type="button"
              onClick={() => setConflictNotice(null)}
              className="shrink-0 text-sm font-semibold text-destructive underline underline-offset-2"
            >
              Skjul
            </button>
          </div>
        )}

        {/* To faner: "Score" (default, KUN hull-navigasjon + registrering --
            det eneste de fleste trenger midt i en runde) og "Spillere og
            runde" (alt administrativt, samlet ett sted i stedet for å ligge
            i veien øverst på scoringssiden). */}
        <div className="mb-5 inline-flex gap-1 rounded-full bg-muted p-1">
          <button
            type="button"
            onClick={() => setPageTab("score")}
            aria-pressed={pageTab === "score"}
            className={cn(
              "min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
              pageTab === "score" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
            )}
          >
            Score
          </button>
          <button
            type="button"
            onClick={() => setPageTab("manage")}
            aria-pressed={pageTab === "manage"}
            className={cn(
              "min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
              pageTab === "manage" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
            )}
          >
            Spillere og runde
          </button>
        </div>

        {/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett
            fane, siden offline-tilstanden er global for hele siden. */}
        {!isOnline && (
          <div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
            <WifiOff aria-hidden="true" className="size-4 shrink-0" />
            <span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
          </div>
        )}
        {pendingCount > 0 && (
          <div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
            <span>
              {pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
            </span>
            {isOnline && (
              <button
                type="button"
                onClick={() => void flushPending()}
                disabled={syncing}
                className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
              >
                <RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
                Synkroniser nå
              </button>
            )}
          </div>
        )}

        {pageTab === "manage" && (
          <>
            {/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
                runder med flere enn én deltaker (komponenten returnerer selv
                null ellers), lenke til hele leaderboardet. */}
            <RoundLeaderboardMini roundId={roundId} />

            {/* Løpende matchstatus (to-sidede formater) eller skins-tavle
                (ADR-039) -- avledet ved lesing, aldri lagret. */}
            <FormatResultPanel round={round} players={players} result={formatResult} />

            {/* Flaggturnering: kartoversikt (migrasjon 071, "Del B",
                ADR-067) -- kun for eier/medspiller (samme tilgang som
                resten av denne siden), av/på-bryteren styrer om ALLE
                flagg eller kun spørrerens eget vises (se komponenten). */}
            {round.play_format === "flag" && (
              <FlagMapSection roundId={roundId} formatResultRefreshTick={formatResultRefreshTick} />
            )}

            {/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer
                (front9/back9/overall) av det samme to-sidede formatet,
                kun for 18-hulls runder. Ren tilleggsvisning, ikke et eget
                play_format. */}
            {TWO_SIDED_FORMATS.has(round.play_format) && round.holes_planned === 18 && (
              <NassauPanel roundId={roundId} formatResultRefreshTick={formatResultRefreshTick} />
            )}

            {/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
                gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
                Eieren tar ansvar for å sette opp ALLE flightene i én
                handling ("+ Legg til en flight til" -> gjenbruker hele
                opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
            <FlightGroupPanel roundId={roundId} round={round} isOwnerViewer={isOwnerViewer} />

            <div className="mb-5 flex flex-col gap-3">
              <div className="flex flex-wrap gap-2">
                {isOwnerViewer && (
                  <button
                    type="button"
                    onClick={() => setShowEditRound((v) => !v)}
                    aria-expanded={showEditRound}
                    className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
                  >
                    <Settings2 aria-hidden="true" className="size-4" />
                    Rediger runde
                  </button>
                )}

                {!completed && (
                  <button
                    type="button"
                    disabled={completing || !round.setup_complete}
                    onClick={finishRound}
                    title={!round.setup_complete ? round.setup_message ?? undefined : undefined}
                    className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
                  >
                    <Check aria-hidden="true" className="size-4" />
                    {completing ? "Fullfører…" : "Fullfør runde"}
                  </button>
                )}

                {isOwnerViewer && (
                  <button
                    type="button"
                    disabled={deleting}
                    onClick={deleteRound}
                    className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
                  >
                    <Trash2 aria-hidden="true" className="size-4" />
                    {deleting ? "Sletter…" : "Slett runde"}
                  </button>
                )}
              </div>

              {showEditRound && isOwnerViewer && (
                <EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
              )}
            </div>

            {/* Sider (ADR-039 Beslutning A) -- kun for formater som faktisk
                bruker sider (match/fourball/foursome/greensome/scramble).
                Opprett/slett sider + fordel deltakerne. */}
            {TWO_SIDED_FORMATS.has(round.play_format) && (
              <SidesPanel
                roundId={roundId}
                round={round}
                players={players}
                canManage={isOwnerViewer && !readOnly}
                onSidesChanged={loadRound}
                onPatchParticipant={patchParticipant}
              />
            )}

            {!round.setup_complete && !completed && (
              <p role="status" className="mb-5 rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
                {round.setup_message ?? "Oppsettet er ikke komplett ennå."}
              </p>
            )}

            {/* Spillerliste (2026-07-26, erstatter tidligere horisontal
                fane-rad) -- vertikal, ett kort per spiller. Utslag/HCP/
                statistikknivå + "Rediger" ligger nå INNI hvert kort (samme
                kort er også selve velg-aktiv-spiller-trykkflaten), i stedet
                for en egen rad under listen som kun gjaldt aktiv spiller. */}
            <PlayerList
              roundId={roundId}
              players={players}
              sides={round.sides}
              holesByParticipant={holesByParticipant}
              activePlayerId={activePlayer.id}
              onSelect={setActivePlayerId}
              onRemove={removeGuest}
              onAdd={() => setShowAddGuest((v) => !v)}
              addOpen={showAddGuest}
              readOnly={readOnly}
              canManage={isOwnerViewer}
              editingPlayerId={editingPlayerId}
              onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
              onPatchParticipant={patchParticipant}
              onSendScorecard={sendScorecard}
              onAddGuest={addGuest}
              onAddSearched={addSearchedParticipant}
            />

            {/* Flyttet HIT (2026-07-26, samme redesign-runde) -- lå tidligere
                på selve scoringssiden (først øverst, deretter lenger ned),
                men er detaljert stats-innsyn (fairway/innspill/kølle-
                fordeling for VALGT spiller), ikke selve registrerings-
                oppgaven. Hver spillers rad i scoringslisten viser allerede
                løpende til-par, så dette er nå rendyrket "se mer detaljer"
                -- hører hjemme i administrasjons-fanen, ikke i veien for
                score-registreringen. */}
            {!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
          </>
        )}

        {pageTab === "score" && (
        <>
        {/* Matchstatus/skins-tavle øverst på Score-fanen også (2026-07-29,
            etterspurt av bruker -- fantes fra før KUN under "Spillere og
            runde", lett å overse siden scoring naturlig skjer her).
            Samme komponent, ikke duplisert logikk. */}
        <FormatResultPanel round={round} players={players} result={formatResult} />

        {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon
            (ikke GPS/live-sporet), separat fra selve slag-/putt-
            registreringen (som fortsatt skjer via vanlig ScoringWizard,
            for stat_level/GIR/HCP-formål -- BBB-poengene er UAVHENGIGE
            av gross_strokes). */}
        {round.play_format === "bbb" && !readOnly && (
          <BBBHolePicker
            players={players}
            holeNumber={activeHole}
            selection={bbbHoles[activeHole] ?? null}
            onSet={patchBbbHole}
          />
        )}

        {/* Money Ball (2026-07-30) -- rekkefølgen (0-3) må være tildelt for
            alle fire FØR scoring/fullføring gir mening (avgjør hvem som er
            "money ball" på hvilket hull). Vises kun til rekkefølgen er
            komplett, deretter automatisk skjult. */}
        {round.play_format === "money_ball" && isOwnerViewer && !readOnly && (
          <MoneyBallLineupPanel players={players} onPatchParticipant={patchParticipant} />
        )}

        {/* Auto-prompt "Fullfør runde" (2026-07-30) -- kun synlig når alle
            hull faktisk er ført for alle, forsvinner selv idet runden er
            fullført (completed flipper til true). */}
        {allHolesEnteredForEveryone && <AllHolesEnteredBanner completing={completing} onComplete={finishRound} />}

        {/* Completed summary banner */}
        {completed && (
          <CompletedBanner
            players={players}
            duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
            roundId={roundId}
          />
        )}

        {/* ADR-039 Beslutning C -- delt-ball-formater scorer PER SIDE, ikke
            per spiller. Vises kun når begge sider faktisk er opprettet
            (SidesPanel i "Spillere og runde" setter dette opp). */}
        {isSharedBall && round.sides.length < 2 ? (
          <p className="mt-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
            Opprett begge sidene under "Spillere og runde" før du kan registrere score.
          </p>
        ) : isSharedBall ? (
          round.sides.some((s) => !holesBySide[s.id]) ? (
            <div className="mt-5 flex justify-center py-8">
              <div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
            </div>
          ) : (
            <section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
              <SideScorecardGrid
                sides={round.sides}
                players={players}
                holesBySide={holesBySide}
                holeOrder={holeOrder}
                holesPlanned={round.holes_planned}
                currentHole={activeHole}
                readOnly={readOnly}
                isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
                formatResult={formatResult}
                onSelectHole={(n) => {
                  setHoleAndUrl(n)
                  scrollToHolePanel()
                }}
                onOpenEntry={(sideId, holeNumber) => {
                  setHoleAndUrl(holeNumber)
                  setWizardSideId(sideId)
                }}
              />
              {!readOnly && round.sides.length === 2 && (
                <div className="flex flex-wrap justify-center gap-2">
                  {round.sides.map((s, i) => (
                    <ShotMeasurementEntry
                      key={s.id}
                      roundId={roundId}
                      holeNumber={activeHole}
                      owner={{ kind: "side", id: s.id }}
                      ownBagClubs={ownBagClubs}
                      readOnly={readOnly}
                      variant="badge"
                      label={s.label?.trim() || (i === 0 ? "Side A" : "Side B")}
                    />
                  ))}
                </div>
              )}
              <div className="grid grid-cols-2 gap-3">
                <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
                  <ChevronLeft aria-hidden="true" className="size-5" />
                  Forrige hull
                </Button>
                <Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
                  Neste hull
                  <ChevronRight aria-hidden="true" className="size-5" />
                </Button>
              </div>
            </section>
          )
        ) : (
          <>
            {/* Hull-fokusert kortvisning (2026-08-03, erstatter det brede
                gridet som STANDARDVISNING -- brukeren rapporterte at gridet
                (§1 i teecup-scorekort-og-entry-spec.md, bygget 2026-07-27)
                tok for stor plass i bredden på mobil. Se PlayerHoleCards for
                full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
                "Vis hele scorekortet"-bryter under -- ingen informasjon er
                fjernet. */}
            {hole === null ? (
              <div className="mt-5 flex justify-center py-8">
                <div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
              </div>
            ) : (
              <section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
                <PlayerHoleCards
                  roundId={roundId}
                  players={players}
                  holesByParticipant={holesByParticipant}
                  holeOrder={holeOrder}
                  holesPlanned={round.holes_planned}
                  currentHole={activeHole}
                  readOnly={readOnly}
                  sides={round.sides}
                  isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
                  formatResult={formatResult}
                  ownBagClubs={ownBagClubs}
                  onSelectHole={(n) => {
                    setHoleAndUrl(n)
                    scrollToHolePanel()
                  }}
                  onPrevHole={goPrev}
                  onNextHole={goNext}
                  onOpenEntry={openPlayerOrTeamSideEntry}
                  onPatchParticipant={patchParticipant}
                  onSendScorecard={sendScorecard}
                  playFormat={round.play_format}
                  flagPlantByParticipant={flagPlantByParticipant}
                  flagOverflowByParticipant={flagOverflowByParticipant}
                  onPlantFlag={plantFlag}
                  onUnplantFlag={unplantFlag}
                  onSubmitFlagOverflowHole={updateFlagOverflowHole}
                />

                <div className="overflow-hidden rounded-2xl border border-border bg-card">
                  <button
                    type="button"
                    onClick={() => setShowFullScorecard((v) => !v)}
                    aria-expanded={showFullScorecard}
                    className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
                  >
                    Vis hele scorekortet
                    <ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
                  </button>
                  {showFullScorecard && (
                    <div className="border-t border-border p-3">
                      <ScorecardGrid
                        players={players}
                        holesByParticipant={holesByParticipant}
                        holeOrder={holeOrder}
                        holesPlanned={round.holes_planned}
                        currentHole={activeHole}
                        readOnly={readOnly}
                        sides={round.sides}
                        isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
                        formatResult={formatResult}
                        onSelectHole={(n) => {
                          setHoleAndUrl(n)
                          scrollToHolePanel()
                        }}
                        onOpenEntry={openPlayerOrTeamSideEntry}
                      />
                    </div>
                  )}
                </div>
              </section>
            )}
          </>
        )}

        {round && viewerId && (
          // Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
          // rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat.
          <div id="kommentarer" className="mt-6 scroll-mt-20">
            <RoundMessages
              roundId={roundId}
              currentUserId={viewerId}
              roundOwnerUserId={round.participants.find((p) => p.is_owner)?.user_id ?? ""}
              refreshKey={messagesRefreshTick}
            />
          </div>
        )}
        </>
        )}
      </main>

      {/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
          UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
          skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
      {wizardSide && hole && (
        <SideScoreWizard
          sides={round.sides}
          side={wizardSide}
          sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
          hole={hole}
          score={wizardSideHole?.played ? wizardSideHole.score : null}
          selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
          onSave={updateWizardSideScore}
          onSelectParticipant={updateWizardSideSelection}
          onClose={() => setWizardSideId(null)}
          onAdvance={advanceWizardSide}
          readOnly={readOnly}
          isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)}
        />
      )}
      {wizardPlayer && hole && (
        <ScoringWizard
          roundId={roundId}
          players={players}
          player={wizardPlayer}
          hole={hole}
          stat={wizardStat}
          onUpdate={updateWizardStat}
          onClose={() => setWizardPlayerId(null)}
          onAdvance={advanceWizardPlayer}
          onSwitchPlayer={switchWizardPlayer}
          readOnly={readOnly}
          ownBagClubs={ownBagClubs}
          isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
          playFormat={round.play_format}
          strokesReceived={wizardApiHole?.strokes_received ?? null}
        />
      )}
    </RoundPageShell>
  )
}

// --- Scoringsveiviser (2026-07-26, auto-hopp lagt til 2026-07-28) ----------
// Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av
// en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men
// BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet
// for å kopiere konkurrentens nøyaktige skjermrekkefølge/design:
//   - "strokes_only": ett steg (Slag)
//   - "strokes_and_putts": to steg (Slag, Putter, Avstand første putt)
//   - "full": fire steg (Slag, Putter, Avstand første putt, resten samlet)
// Slag/Putter hopper AUTOMATISK videre i det øyeblikket en verdi velges --
// ingen "Neste"-trykk nødvendig (etterspurt av bruker 2026-07-28: "Avstand
// første putt" lå tidligere PÅ SAMME steg som selve putt-tallet, som ville
// gjort feltet uoppnåelig hvis auto-hopp fyrte idet putt-tallet ble valgt --
// derfor et eget, eget steg for avstanden, som ELLERS ville vært umulig å
// nå). "Avstand"/"detaljer" er valgfrie/flerfelts og auto-hopper derfor
// ikke -- "Neste"/"Forrige"-knappene i bunnen fungerer fortsatt som
// manuell reserve for disse (og for å rette et allerede registrert steg).
// På siste steg blir knappen "Neste: {navn}" (bytter til neste spiller,
// nullstiller til steg 1) eller "Ferdig" (er spilleren den siste, lukker
// veiviseren og varsler forelderen om å gå videre til neste hull).

type WizardStep = "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"

function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
  if (statLevel === "strokes_only") return ["strokes"]
  if (statLevel === "strokes_and_putts") return ["strokes", "putts", "puttDistance"]
  // ADR-078 (2026-08-16): "details" splittet i to egne skjermer -- retning
  // (utslag/innspill, + kølle/slagmåling som hører til utslaget) og
  // Chip/Bunker/Straffeslag/Anywayslag fikk hver sin egen skjerm i stedet
  // for å dele én, etter tilbakemelding om at detaljene druknet under
  // retningsvalget.
  return ["strokes", "putts", "puttDistance", "direction", "holeDetails"]
}

// Hvilket HoleStat-felt (om noen) som "eier" auto-hopp for et gitt steg --
// null for steg uten noe entydig enkeltfelt å trigge på (flerfelts/valgfritt).
function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPuttBucket" | null {
  if (step === "strokes") return "strokes"
  if (step === "putts") return "putts"
  if (step === "puttDistance") return "firstPuttBucket"
  return null
}

function ScoringWizard({
  roundId,
  players,
  player,
  hole,
  stat,
  onUpdate,
  onClose,
  onAdvance,
  onSwitchPlayer,
  readOnly,
  ownBagClubs,
  isPending,
  playFormat,
  strokesReceived,
}: {
  roundId: string
  players: Player[]
  player: Player
  hole: Hole
  stat: HoleStat
  onUpdate: (patch: Partial<HoleStat>) => void
  onClose: () => void
  onAdvance: () => void
  // Tilbake til en TIDLIGERE (eller "Ferdig"-markert) spiller midt i
  // samlebånd-flyten (ADR-077) -- se kontekst-raden under. Trygt fordi
  // hvert felt allerede lagres for seg med en gang (updateWizardStat),
  // ingen fare for å miste uferdige felt ved bytte.
  onSwitchPlayer: (playerId: string) => void
  readOnly: boolean
  ownBagClubs: string[]
  // Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- sann når
  // gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen,
  // ikke bekreftet av serveren ennå.
  isPending: boolean
  // "Plukket opp" (migrasjon 038, Stableford) -- kun meningsfullt for
  // slagspill/Stableford (match/skins/par-lag har egne, ubyggede
  // hull-avgjørelsesmekanismer -- konsedering, ikke dette). Krever en
  // kjent strokes_received for AKTUELT hull for at serveren skal kunne
  // regne ut Net Double Bogey-scoren.
  playFormat: string
  strokesReceived: number | null
}) {
  const playerIndex = players.findIndex((p) => p.id === player.id)
  const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
  const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null

  const steps = wizardStepsFor(player.statLevel)
  const [stepIndex, setStepIndex] = useState(0)
  // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
  // uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller
  // slapp, i stedet for å starte på Slag igjen for den nye spilleren.
  useEffect(() => {
    setStepIndex(0)
  }, [player.id])

  const step = steps[Math.min(stepIndex, steps.length - 1)]
  const isFirstStep = stepIndex === 0
  const isLastStep = stepIndex === steps.length - 1

  const canProceed =
    step === "strokes" ? stat.strokes !== null || stat.pickedUp : step === "putts" ? stat.putts !== null : true

  // Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi,
  // hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith
  // ValueRef` fanger om feltet ALLEREDE hadde en verdi idet vi ankom steget
  // (et allerede utfylt hull skal ikke hoppe videre bare fordi veiviseren
  // åpnes) -- kun en FERSK endring gjort AV BRUKEREN akkurat nå skal utløse
  // hoppet. `firedRef` hindrer at samme hopp trigges to ganger.
  const autoField = autoAdvanceFieldFor(step)
  const enteredWithValueRef = useRef(false)
  const firedRef = useRef(false)
  useEffect(() => {
    firedRef.current = false
    enteredWithValueRef.current = autoField !== null && stat[autoField] !== null
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [step, player.id, hole.holeNumber])
  useEffect(() => {
    if (!autoField || firedRef.current || enteredWithValueRef.current) return
    if (stat[autoField] !== null) {
      firedRef.current = true
      forward()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [stat.strokes, stat.putts, stat.firstPuttBucket])

  // GIR-auto-inferens (2026-07-30): score - putts <= par - 2 GARANTERER
  // greentreff i regulering (samme formel som round-stats.tsx sin isGir/
  // round-detail.tsx sin showGir, som allerede styrer GIR-STATISTIKKEN) --
  // da er det unødvendig å i tillegg kreve et manuelt "Innspill: Traff"-
  // valg i DirectionCross-feltet under. Kun en FORHÅNDSUTFYLLING: rører
  // ALDRI et allerede satt (manuelt ELLER tidligere auto-satt) approach-
  // valg, og gjelder aldri motsatt vei (GIR usann sier ingenting om
  // retning -- en scrambling-birdie gir identisk slag/putt-mønster som en
  // ekte GIR). Trigges når "direction"-steget nås (ADR-078: flyttet fra det
  // tidligere "details"-steget, som ble splittet i to), siden slag+putt
  // begge må være kjent (kommer tidligere i wizard-rekkefølgen).
  useEffect(() => {
    if (step !== "direction") return
    if (stat.approach !== null) return
    if (stat.strokes === null || stat.putts === null) return
    if (stat.strokes - stat.putts <= hole.par - 2) {
      onUpdate({ approach: "hit" })
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [step, stat.strokes, stat.putts, stat.approach, hole.par])

  function back() {
    if (isFirstStep) {
      onClose()
      return
    }
    setStepIndex((i) => i - 1)
  }
  function forward() {
    if (!isLastStep) {
      setStepIndex((i) => i + 1)
      return
    }
    onAdvance()
  }

  return (
    <div className="fixed inset-0 z-50 flex flex-col bg-background">
      <header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
        <button
          type="button"
          onClick={onClose}
          aria-label="Lukk veiviseren"
          className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
        >
          <X aria-hidden="true" className="size-5" />
        </button>
        <div className="flex min-w-0 flex-1 flex-col items-center text-center">
          <span className="truncate text-base font-extrabold text-foreground">
            Hull {hole.holeNumber} · Par {hole.par}
          </span>
          <span className="truncate text-sm font-semibold text-muted-foreground">{player.name}</span>
        </div>
        <div className="size-11 shrink-0" aria-hidden="true" />
      </header>

      {/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
          konkurrentappen: man mister aldri oversikten over hvem som allerede
          er ferdig på hullet. Interaktiv (ADR-077, 2026-08-15) -- trykk på
          en ANNEN spiller (inkl. "Ferdig"-markerte) bytter veiviseren dit,
          for å rette opp allerede registrerte data uten å måtte fullføre
          hele samlebåndet først og lete opp kortet på nytt etterpå. Trygt
          siden hvert felt allerede lagres for seg med en gang (se
          onSwitchPlayer). */}
      <div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
        {players.map((p) => (
          <button
            key={p.id}
            type="button"
            onClick={() => onSwitchPlayer(p.id)}
            aria-current={p.id === player.id ? "true" : undefined}
            className={cn(
              "flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-bold transition-colors",
              p.id === player.id
                ? "border-primary bg-primary text-primary-foreground"
                : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
            )}
          >
            {p.name}
            {p.completed && p.id !== player.id && (
              <span className="text-xs font-semibold">· Ferdig</span>
            )}
          </button>
        ))}
      </div>

      {isPending && (
        <p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
          Lagret lokalt · venter på synk
        </p>
      )}

      <main className="flex min-h-0 flex-1 flex-col">
        <ScrollFade className="flex-1 overflow-y-auto p-5 sm:p-6">
        <div className="mx-auto flex max-w-sm flex-col gap-6">
          {step === "strokes" && (
            <>
              <NumberPicker
                label="Slag"
                value={stat.strokes}
                directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
                expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
                expandLabel="10+"
                parValue={hole.par}
                showGolfTerms
                strokesReceived={strokesReceived}
                onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })}
                readOnly={readOnly}
              />

              {/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) --
                  kun for slagspill/Stableford, og kun når serveren faktisk
                  kan regne ut Net Double Bogey (krever kjent strokes_received). */}
              {(playFormat === "stroke" || playFormat === "stableford") && strokesReceived !== null && (
                <button
                  type="button"
                  disabled={readOnly}
                  aria-pressed={stat.pickedUp}
                  onClick={() => {
                    if (stat.pickedUp) {
                      onUpdate({ pickedUp: false, strokes: null, played: false })
                      return
                    }
                    onUpdate({ pickedUp: true, strokes: null, played: true })
                    forward()
                  }}
                  className={cn(
                    "flex min-h-11 items-center justify-center gap-2 rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-60",
                    stat.pickedUp
                      ? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
                      : "border-dashed border-border bg-card text-muted-foreground hover:bg-accent/50",
                  )}
                >
                  {stat.pickedUp ? "Plukket opp — trykk for å angre" : "Plukket opp (0 poeng)"}
                </button>
              )}
            </>
          )}

          {step === "putts" && (
            <NumberPicker
              label="Putter"
              value={stat.putts}
              directValues={[0, 1, 2, 3, 4, 5, 6]}
              expandValues={[7, 8, 9, 10]}
              expandLabel="7+"
              maxValue={stat.strokes ?? undefined}
              accent="orange"
              onChange={(v) => onUpdate({ putts: v })}
              readOnly={readOnly}
            />
          )}

          {step === "puttDistance" && (
            <ChoiceRow
              label="Avstand første putt"
              // Konsekvent "X-Ym"-mønster på ALLE seks, kun den siste
              // (uunngåelig ubegrenset) bruker "+" -- 2026-08-11, reelt
              // UX-funn: den forrige "<Xm ... 8m+"-blandingen brøt
              // mønsteret på akkurat siste knapp, som da ble lett å
              // overse. `value` uendret (PuttBucket/backend-kontrakt),
              // kun visningsteksten er ny.
              options={[
                { value: "<1m", label: "0-1m" },
                { value: "<2m", label: "1-2m" },
                { value: "<3m", label: "2-3m" },
                { value: "<5m", label: "3-5m" },
                { value: "<8m", label: "5-8m" },
                { value: "8m+", label: "8m+" },
              ]}
              value={stat.firstPuttBucket}
              onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
              readOnly={readOnly}
            />
          )}

          {/* ADR-078 (2026-08-16): "Retning" er nå EGEN skjerm -- inkl.
              kølle/slagmåling, som begge hører til utslaget. Splittet fra
              det tidligere "details"-steget etter tilbakemelding om at
              Chip/Bunker/Straffeslag/Anywayslag druknet under retnings-
              valget. */}
          {step === "direction" && (
            <>
              <ClubPicker
                label="Kølle brukt ved utslaget"
                value={stat.club}
                onChange={(club) => onUpdate({ club })}
                ownBagClubs={player.isSelf ? ownBagClubs : []}
                readOnly={readOnly}
              />

              <ShotMeasurementEntry
                roundId={roundId}
                holeNumber={hole.holeNumber}
                owner={{ kind: "participant", id: player.id }}
                ownBagClubs={player.isSelf ? ownBagClubs : []}
                readOnly={readOnly}
                variant="inline"
              />

              <WizardSection title="Retning">
                {hole.par !== 3 && (
                  <DirectionCross
                    label="Utslag"
                    variant="horizontal"
                    centerLabel="Fairway"
                    value={stat.teeShot}
                    onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
                    readOnly={readOnly}
                  />
                )}

                <DirectionCross
                  label="Innspill"
                  variant="full"
                  centerLabel="Traff"
                  value={stat.approach}
                  onChange={(v) => onUpdate({ approach: v as Approach })}
                  readOnly={readOnly}
                />
              </WizardSection>
            </>
          )}

          {/* ADR-078 (2026-08-16): Chip/Bunker/Straffeslag/Anywayslag har nå
              EGEN skjerm, splittet fra "Retning" (se over). Alle FIRE
              tellerne er konseptuelt samme type data (antall småhendelser
              per hull, typisk 0-3) og bruker ETT konsistent
              +/--teller-flis-mønster (Stepper, hole-stat-inputs.tsx).

              Layout (V0-designet, 2026-08-16): et FAST 2x2-rutenett av
              teller-fliser i stedet for en høy stabel som etterlot mye
              tomrom under nå som feltene har fått en egen skjerm. Fire
              likeverdige felt leses da som ett samlet sett og fyller
              kolonnen bevisst. grid-cols-2 er trygt -- og ALDRI
              grid-cols-3 -- fordi seksjonen er fast max-w-sm (384px, se
              containeren over): to fliser gir ~184px hver, rikelig til
              flisens to 44px-knapper side om side, mens tre ville presset
              hver flis under ~120px og fått knappene til å overlappe.
              Viewport-uavhengig, så det holder på alle skjermbredder. */}
          {step === "holeDetails" && (
            <div className="grid grid-cols-2 gap-3">
              <Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
              <Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
              <Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
              <Stepper
                label="Anywayslag"
                value={stat.anywayStrokes ?? 0}
                onChange={(v) => onUpdate({ anywayStrokes: v })}
                readOnly={readOnly}
                max={stat.strokes ?? undefined}
              />
            </div>
          )}
        </div>
        </ScrollFade>
      </main>

      <footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
        <Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
          {isFirstStep ? "Avbryt" : "Forrige"}
        </Button>
        <Button
          type="button"
          disabled={!canProceed}
          onClick={forward}
          className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
        >
          {isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
        </Button>
      </footer>
    </div>
  )
}

// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- delt mellom
// tre bruksteder: ScoringWizard sitt detalj-steg (inline-variant,
// deltaker-eid hull), PlayerHoleCards sin alltid-synlige merkelapp
// (badge-variant, deltaker-eid, retroaktiv måling), og SideScorecardGrid
// sin tilsvarende merkelapp per side (badge-variant, side-eid hull --
// dekker delt-ball-formatene som ScoringWizard aldri når siden de bruker
// en egen, enklere side-veiviser uten dette steget). Samme
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
// deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en.
type ShotRecord = {
  id: string
  club: string
  distance_meters: number
  shared_round_message_id: string | null
  start_lat: number
  start_lng: number
  end_lat: number
  end_lng: number
}

function ShotMeasurementEntry({
  roundId,
  holeNumber,
  owner,
  ownBagClubs,
  readOnly,
  variant,
  label,
}: {
  roundId: string
  holeNumber: number
  owner: { kind: "participant" | "side"; id: string }
  ownBagClubs: string[]
  readOnly: boolean
  variant: "inline" | "badge"
  label?: string
}) {
  const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`

  const [open, setOpen] = useState(false)
  // Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
  // det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
  // måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
  // (ikke bare .length), viser den i en utvidbar liste, med sletting av et
  // feilmålt slag.
  const [shots, setShots] = useState<ShotRecord[]>([])
  const [listOpen, setListOpen] = useState(false)
  const [submitting, setSubmitting] = useState(false)
  const [submitError, setSubmitError] = useState<string | null>(null)
  const [shareError, setShareError] = useState<string | null>(null)
  useEffect(() => {
    let cancelled = false
    fetch(urlBase, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : []))
      .then((data: ShotRecord[]) => {
        if (!cancelled) setShots(data)
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [urlBase])
  const count = shots.length

  // Kartretning ved slagmåling (brukerønske 2026-08-10): "opp på skjermen"
  // følger spillerens EGEN gangretning på hullet, uten lagrede green-
  // koordinater. Regnet ut fra det SISTE tidligere målte slaget på dette
  // hullet (start→slutt-retningen) -- `shots` er allerede sortert
  // stigende på shot_number av GET-endepunktet (se _list_shots_for_hole i
  // rounds.py), så siste element ER forrige slag. `undefined` (ingen
  // rotasjon regnet ut ennå) for det aller første slaget på hullet --
  // MapPointPicker faller da selv tilbake til enhetens GPS-heading/nord,
  // se den komponentens dokumentasjon. Bevisst en STATISK retning, satt
  // idet arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
  // gangfart), se map-point-picker.tsx.
  const initialBearing = useMemo(() => {
    const previous = shots[shots.length - 1]
    if (!previous) return undefined
    return bearingDegrees(
      { lat: previous.start_lat, lng: previous.start_lng },
      { lat: previous.end_lat, lng: previous.end_lng },
    )
  }, [shots])

  async function deleteShot(shotId: string) {
    const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
    if (res.ok) {
      setShots((prev) => prev.filter((s) => s.id !== shotId))
    }
  }

  // Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
  // distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
  // fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
  // aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
  // holder arket åpent i stedet for å anta suksess.
  async function submitShot(result: {
    club: string
    distanceMeters: number
    startMethod: "gps" | "map_tap"
    startLat: number
    startLng: number
    endMethod: "gps" | "map_tap"
    endLat: number
    endLng: number
    share: boolean
    shareText: string
  }) {
    setSubmitError(null)
    setSubmitting(true)
    try {
      const res = await fetch(urlBase, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({
          club: result.club,
          distance_meters: result.distanceMeters,
          start_method: result.startMethod,
          start_lat: result.startLat,
          start_lng: result.startLng,
          end_method: result.endMethod,
          end_lat: result.endLat,
          end_lng: result.endLng,
        }),
      })
      if (!res.ok) {
        const body = await res.json().catch(() => null)
        const detail = body?.detail
        const message =
          typeof detail === "string"
            ? detail
            : typeof detail?.message === "string"
              ? detail.message
              : "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
        setSubmitError(message)
        return
      }
      let shot = await res.json()
      setShareError(null)
      if (result.share) {
        const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ body: result.shareText }),
        })
        // Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
        // avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
        // gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
        // uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
        // her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
        // en mislykket deling vises separat ved siden av merkelappen i
        // stedet for å holde arket åpent.
        if (shareRes.ok) {
          shot = await shareRes.json()
        } else {
          setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
        }
      }
      setShots((prev) => [...prev, shot])
      setOpen(false)
    } catch {
      setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
    } finally {
      setSubmitting(false)
    }
  }

  async function shareExisting(shot: ShotRecord) {
    const res = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ body: `⛳ ${shot.club}, ${shot.distance_meters} m – hull ${holeNumber}` }),
    })
    if (res.ok) {
      const updated = await res.json()
      setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
      setShareError(null)
    } else {
      setShareError("Delingen feilet. Prøv igjen om litt.")
    }
  }

  if (readOnly) {
    // Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
    // egne målte slag, bare uten å kunne måle/dele/slette nye.
    if (count === 0) return null
    return (
      <div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation()
            setListOpen((o) => !o)
          }}
          aria-expanded={listOpen}
          className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <Ruler className="size-3.5 shrink-0" aria-hidden="true" />
          {label ? `${label}: ` : ""}
          {count} slag målt
          <ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
        </button>
        {listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
      </div>
    )
  }

  const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"

  return (
    <div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
      <div className="flex items-center gap-1.5">
        {count > 0 && (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation()
              setListOpen((o) => !o)
            }}
            aria-expanded={listOpen}
            className={cn(
              "inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
              variant === "inline"
                ? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
                : "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
            )}
          >
            <Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
            {label ? `${label}: ` : ""}
            {count} slag målt
            <ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
          </button>
        )}
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation()
            setSubmitError(null)
            setOpen(true)
          }}
          aria-label={addLabel}
          className={cn(
            "inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
            count > 0
              ? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
              : variant === "inline"
                ? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
                : "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
          )}
        >
          <Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
          {count === 0 && addLabel}
        </button>
      </div>

      {shareError && (
        <p role="alert" className="text-xs font-medium text-destructive">
          {shareError}
        </p>
      )}

      {listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}

      {open && (
        <ShotMeasurementSheet
          holeNumber={holeNumber}
          existingShotCount={count}
          ownBagClubs={ownBagClubs}
          initialBearing={initialBearing}
          submitError={submitError}
          submitting={submitting}
          onSubmit={submitShot}
          onCancel={() => setOpen(false)}
        />
      )}
    </div>
  )
}

// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
  if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
  return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}

function ShotList({
  shots,
  onDelete,
  onShare,
}: {
  shots: ShotRecord[]
  onDelete?: (shotId: string) => void
  onShare?: (shot: ShotRecord) => void
}) {
  return (
    <ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
      {shots.map((shot) => {
        const thumbUrl = shotThumbnailUrl(shot)
        return (
          <li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
            {thumbUrl ? (
              // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
              <img
                src={thumbUrl || "/placeholder.svg"}
                alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
                className="size-14 shrink-0 rounded-lg border border-border object-cover"
              />
            ) : null}
            <span className="flex-1 truncate font-medium text-foreground">
              {shot.club} <span className="tabular-nums text-muted-foreground">— {shot.distance_meters} m</span>
            </span>
            {shot.shared_round_message_id ? (
              <span className="text-[11px] font-semibold text-primary">Delt</span>
            ) : onShare ? (
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  onShare(shot)
                }}
                className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
              >
                Del
              </button>
            ) : null}
            {onDelete && (
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation()
                  onDelete(shot.id)
                }}
                aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
                className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                <Trash2 className="size-4" aria-hidden="true" />
              </button>
            )}
          </li>
        )
      })}
    </ul>
  )
}

// --- Completed banner ------------------------------------------------------

function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
  return (
    <div className="mb-6 flex flex-col gap-3 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:flex-row sm:items-center sm:justify-between sm:p-6">
      <div className="flex items-center gap-3">
        <div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
          <Check aria-hidden="true" className="size-6" />
        </div>
        <div className="flex flex-col">
          <span className="text-xs font-semibold uppercase tracking-wide text-primary">Alle hull er ført</span>
          <h2 className="text-lg font-extrabold tracking-tight text-foreground">Klar til å fullføre runden?</h2>
        </div>
      </div>
      <button
        type="button"
        disabled={completing}
        onClick={onComplete}
        className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:shrink-0"
      >
        {completing ? "Fullfører…" : "Fullfør runden"}
      </button>
    </div>
  )
}

function CompletedBanner({
  players,
  duration,
  roundId,
}: {
  players: Player[]
  duration: string | null
  roundId: string
}) {
  return (
    <div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
      <div className="flex items-center gap-3">
        <div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
          <Trophy aria-hidden="true" className="size-6" />
        </div>
        <div className="flex flex-col">
          <span className="text-xs font-semibold uppercase tracking-wide text-primary">Ferdigspilt</span>
          <h2 className="text-xl font-extrabold tracking-tight text-foreground">Runde fullført</h2>
        </div>
        {duration && (
          <div className="ml-auto flex flex-col items-end">
            <span className="text-xs font-medium text-muted-foreground">Tid brukt</span>
            <span className="flex items-center gap-1.5 text-lg font-extrabold tabular-nums text-foreground">
              <Clock aria-hidden="true" className="size-4 text-primary" />
              {duration}
            </span>
          </div>
        )}
      </div>
      <ul className="flex flex-col divide-y divide-primary/20 overflow-hidden rounded-2xl border border-primary/20 bg-card">
        {players.map((player) => {
          const counts = player.countsForHandicap && player.scoreDifferential !== null
          return (
            <li key={player.id} className="flex items-center justify-between gap-3 px-4 py-3">
              <span className="text-base font-bold text-foreground">{player.name}</span>
              {counts ? (
                <span className="flex items-baseline gap-1.5">
                  <span className="text-xs font-medium text-muted-foreground">Hcp spilt til</span>
                  <span className="text-lg font-extrabold tabular-nums text-foreground">
                    {player.scoreDifferential!.toFixed(1).replace(".", ",")}
                  </span>
                </span>
              ) : (
                <span className="text-sm font-semibold text-muted-foreground">Telte ikke mot HCP</span>
              )}
            </li>
          )
        })}
      </ul>
      {/* Scorekort og statistikk slått sammen til ÉN side (ADR-040
          Beslutning D) -- ett felles knapp i stedet for to separate. */}
      <Link
        href={`/my-rounds/${roundId}/scorecard`}
        className="flex min-h-12 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
      >
        Se scorekort og statistikk
        <ChevronRight aria-hidden="true" className="size-5" />
      </Link>
    </div>
  )
}

// --- Score så langt (kompakt linje + utvidbar full oversikt) --------------
// Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app --
// samme "Vis full oversikt"-mønster som allerede etablert i
// session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt
// spillers frittstående runde (ingen motstander-side å vise her).
//
// Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/
// kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling +
// gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen
// egen "spilleform"-innstilling for frittstående runder -- stableford
// beregnes derfor alltid ut fra netto score der det er mulig (krever
// registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill
// eller stableford -- rent informativt, ikke en offisiell poengsum.

// Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0,
// netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre.
function stablefordPoints(netScore: number, par: number): number {
  return Math.max(0, 2 - (netScore - par))
}

function avgScoreToPar(holes: ApiHole[]): number | null {
  const played = holes.filter((h) => h.played && h.score !== null)
  if (played.length === 0) return null
  const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0)
  return diffSum / played.length
}

function formatSignedAvg(n: number): string {
  const fixed = Math.abs(n).toFixed(2)
  return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
}

function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
  const [expanded, setExpanded] = useState(false)
  if (!holes || holes.length === 0) return null

  const orderedHoles = order
    .map((n) => holes.find((h) => h.hole_number === n))
    .filter((h): h is ApiHole => h !== undefined)
  const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null)
  if (playedSoFar.length === 0) return null

  const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0)
  const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0)
  const toPar = totalStrokes - totalPar
  const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}`

  // Netto/stableford krever at deltakeren har en beregnet course handicap
  // (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten
  // det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
  const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
  const hasNetto = withNetto.length > 0
  const nettoTotal = hasNetto
    ? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
    : null
  const stablefordTotal = hasNetto
    ? withNetto.reduce(
        (sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par),
        0,
      )
    : null

  const puttsPlayed = orderedHoles.filter((h) => h.putts !== null)
  const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null
  const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null)
  const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null
  const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null)
  const bunkerTotal =
    bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null
  const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null)
  const penaltyTotal =
    penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null
  const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null)
  const anywayTotal =
    anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null

  // Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som
  // registrerings-skjemaet -- DirectionCross for Utslag vises ikke der).
  const fairwayTracked = orderedHoles.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
  const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway")
  const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left")
  const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right")
  const fairwayMiss = [...fairwayLeft, ...fairwayRight]
  const avgToParFairwayHit = avgScoreToPar(fairwayHit)
  const avgToParFairwayMiss = avgScoreToPar(fairwayMiss)

  const approachTracked = orderedHoles.filter((h) => h.approach_result !== null)
  const approachHit = approachTracked.filter((h) => h.approach_result === "hit")
  const approachShort = approachTracked.filter((h) => h.approach_result === "short")
  const approachLong = approachTracked.filter((h) => h.approach_result === "long")
  const approachLeft = approachTracked.filter((h) => h.approach_result === "left")
  const approachRight = approachTracked.filter((h) => h.approach_result === "right")
  const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight]
  const avgToParApproachHit = avgScoreToPar(approachHit)
  const avgToParApproachMiss = avgScoreToPar(approachMiss)

  let running = 0

  return (
    <div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        aria-expanded={expanded}
        className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
      >
        <span className="text-sm font-semibold text-foreground text-pretty">
          Så langt: {playedSoFar.length} hull · {totalStrokes} slag · {toParLabel} til par
          {hasNetto && ` · Netto ${nettoTotal! > 0 ? "+" : ""}${nettoTotal}`}
        </span>
        <span className="shrink-0 text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis full oversikt"}</span>
      </button>
      {expanded && (
        <div className="border-t border-border">
          <div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
            <StatPill label="Slag" value={String(totalStrokes)} />
            <StatPill label="Til par" value={toParLabel} />
            {hasNetto && <StatPill label="Netto" value={nettoTotal! > 0 ? `+${nettoTotal}` : String(nettoTotal)} />}
            {hasNetto && <StatPill label="Stableford" value={`${stablefordTotal} poeng`} />}
            {puttsTotal !== null && <StatPill label="Putt" value={String(puttsTotal)} />}
            {chipTotal !== null && <StatPill label="Chip" value={String(chipTotal)} />}
            {bunkerTotal !== null && <StatPill label="Bunker" value={String(bunkerTotal)} />}
            {penaltyTotal !== null && <StatPill label="Straffeslag" value={String(penaltyTotal)} />}
            {anywayTotal !== null && <StatPill label="Anywayslag" value={String(anywayTotal)} />}
          </div>

          <div className="overflow-x-auto border-t border-border">
            <table className="w-full min-w-[420px] text-sm">
              <thead>
                <tr className="border-b border-border text-left text-xs font-bold uppercase tracking-wide text-muted-foreground">
                  <th className="px-3 py-2">Hull</th>
                  <th className="px-3 py-2">Par</th>
                  <th className="px-3 py-2">Score</th>
                  <th className="px-3 py-2">Netto</th>
                  {hasNetto && <th className="px-3 py-2">Stableford</th>}
                  <th className="px-3 py-2">Sum</th>
                </tr>
              </thead>
              <tbody>
                {orderedHoles.map((h) => {
                  const played = h.played && h.score !== null
                  if (played) running += h.score as number
                  const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null
                  const points = played && netto !== null ? stablefordPoints(netto, h.par) : null
                  return (
                    <tr key={h.hole_number} className="border-b border-border last:border-b-0">
                      <td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
                      <td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
                      <td className="px-3 py-2 tabular-nums font-semibold text-foreground">
                        {played ? (h.picked_up ? "PU" : h.score) : "–"}
                      </td>
                      <td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? "–"}</td>
                      {hasNetto && (
                        <td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? "–"}</td>
                      )}
                      <td className="px-3 py-2 tabular-nums font-bold text-foreground">{played ? running : "–"}</td>
                    </tr>
                  )
                })}
              </tbody>
            </table>
          </div>

          {fairwayTracked.length > 0 && (
            <div className="flex flex-col gap-2 border-t border-border p-4">
              <span className="text-sm font-bold text-foreground">Fairwaytreff</span>
              <DistributionBar
                segments={[
                  { label: "Fairway", count: fairwayHit.length, colorClass: "bg-emerald-500" },
                  { label: "Venstre", count: fairwayLeft.length, colorClass: "bg-amber-500" },
                  { label: "Høyre", count: fairwayRight.length, colorClass: "bg-rose-500" },
                ]}
              />
              {(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
                <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
                  {avgToParFairwayHit !== null && (
                    <span>
                      Til par ved fairwaytreff:{" "}
                      <strong className="text-foreground">{formatSignedAvg(avgToParFairwayHit)}</strong>
                    </span>
                  )}
                  {avgToParFairwayMiss !== null && (
                    <span>
                      Til par ved bom på fairway:{" "}
                      <strong className="text-foreground">{formatSignedAvg(avgToParFairwayMiss)}</strong>
                    </span>
                  )}
                </div>
              )}
            </div>
          )}

          {approachTracked.length > 0 && (
            <div className="flex flex-col gap-2 border-t border-border p-4">
              <span className="text-sm font-bold text-foreground">Innspill</span>
              <DistributionBar
                segments={[
                  { label: "Traff", count: approachHit.length, colorClass: "bg-emerald-500" },
                  { label: "Kort", count: approachShort.length, colorClass: "bg-amber-500" },
                  { label: "Langt", count: approachLong.length, colorClass: "bg-orange-500" },
                  { label: "Venstre", count: approachLeft.length, colorClass: "bg-sky-500" },
                  { label: "Høyre", count: approachRight.length, colorClass: "bg-rose-500" },
                ]}
              />
              {(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
                <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
                  {avgToParApproachHit !== null && (
                    <span>
                      Til par ved treff på innspill:{" "}
                      <strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
                    </span>
                  )}
                  {avgToParApproachMiss !== null && (
                    <span>
                      Til par ved bom på innspill:{" "}
                      <strong className="text-foreground">{formatSignedAvg(avgToParApproachMiss)}</strong>
                    </span>
                  )}
                </div>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  )
}

function StatPill({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex flex-col items-center justify-center gap-0.5 rounded-xl border border-border bg-background px-2 py-2.5 text-center">
      <span className="text-lg font-extrabold tabular-nums text-foreground">{value}</span>
      <span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
    </div>
  )
}

// Ett rektangel delt i fargede segmenter proporsjonalt med antall --
// samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men
// generalisert til N kategorier (ikke bare to lag).
function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) {
  const total = segments.reduce((sum, s) => sum + s.count, 0)
  if (total === 0) return null
  return (
    <div className="flex flex-col gap-2">
      <div className="flex h-5 w-full overflow-hidden rounded-full border border-border">
        {segments
          .filter((s) => s.count > 0)
          .map((s) => (
            <div key={s.label} aria-hidden="true" style={{ width: `${(s.count / total) * 100}%` }} className={s.colorClass} />
          ))}
      </div>
      <div className="flex flex-wrap gap-x-4 gap-y-1">
        {segments
          .filter((s) => s.count > 0)
          .map((s) => (
            <span key={s.label} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
              <span aria-hidden="true" className={cn("inline-block size-2.5 rounded-full", s.colorClass)} />
              {s.label} {Math.round((s.count / total) * 100)}% ({s.count})
            </span>
          ))}
      </div>
    </div>
  )
}

// --- Sider (ADR-039 Beslutning A) -------------------------------------------
// Kun for match/fourball/foursome/greensome/scramble -- nøyaktig to sider,
// hver med et forventet spillerantall avhengig av format. Sidene opprettes
// tomme (POST .../sides), deltakere tildeles/flyttes deretter via PATCH
// .../participants/{id} med round_side_id.

function SidesPanel({
  roundId,
  round,
  players,
  canManage,
  onSidesChanged,
  onPatchParticipant,
}: {
  roundId: string
  round: ApiRound
  players: Player[]
  canManage: boolean
  onSidesChanged: () => Promise<void>
  onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [labelDrafts, setLabelDrafts] = useState<Record<number, string>>({})
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const expected = SIDE_PLAYER_COUNT[round.play_format] ?? 1
  const slots: (ApiSide | null)[] = [round.sides[0] ?? null, round.sides[1] ?? null]
  const unassigned = players.filter((p) => p.roundSideId === null)

  async function createSide(slotIndex: number) {
    setBusy(true)
    setError(null)
    const res = await fetch(`/rounds/${roundId}/sides`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ label: labelDrafts[slotIndex]?.trim() || null }),
    })
    if (!res.ok) {
      const body = await res.json().catch(() => null)
      setError(body?.detail?.message ?? "Klarte ikke å opprette siden.")
      setBusy(false)
      return
    }
    await onSidesChanged()
    setBusy(false)
  }

  async function deleteSide(sideId: string) {
    if (!confirm("Slette denne siden? Deltakerne blir liggende i runden, uten side-tilhørighet.")) return
    setBusy(true)
    setError(null)
    const res = await fetch(`/rounds/${roundId}/sides/${sideId}`, { method: "DELETE", credentials: "include" })
    if (!res.ok) {
      const body = await res.json().catch(() => null)
      setError(body?.detail?.message ?? "Klarte ikke å slette siden.")
    }
    await onSidesChanged()
    setBusy(false)
  }

  async function assign(participantId: string, sideId: string | null) {
    setBusy(true)
    setError(null)
    const result = await onPatchParticipant(participantId, { round_side_id: sideId })
    if (!result.ok) {
      setError(result.message)
    } else {
      // patchParticipant oppdaterer kun deltaker-listen lokalt -- setup_complete/
      // setup_message/playing_handicap er beregnet SERVER-side og krever en
      // full runde-refetch for å ikke bli stående utdatert (2026-07-28, funnet
      // under browserverifisering: meldingen "ikke tildelt en side" ble
      // stående etter en vellykket tildeling).
      await onSidesChanged()
    }
    setBusy(false)
  }

  if (!canManage && round.sides.length === 0) return null

  return (
    <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <div className="flex items-center justify-between gap-2">
        <span className="text-sm font-bold text-foreground">
          Sider · {expected} spiller{expected !== 1 ? "e" : ""} per side
        </span>
      </div>

      {error && <p className="text-sm font-medium text-destructive">{error}</p>}

      <div className="grid gap-3 sm:grid-cols-2">
        {slots.map((side, slotIndex) => {
          const assignedPlayers = side ? players.filter((p) => p.roundSideId === side.id) : []
          return (
            <div key={slotIndex} className="flex flex-col gap-2 rounded-xl border border-dashed border-border p-3">
              {side ? (
                <>
                  <div className="flex items-center justify-between gap-2">
                    <span className="text-sm font-bold text-foreground">
                      {side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
                    </span>
                    {canManage && (
                      <button
                        type="button"
                        disabled={busy}
                        onClick={() => deleteSide(side.id)}
                        aria-label="Slett siden"
                        className="flex min-h-8 min-w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
                      >
                        <Trash2 aria-hidden="true" className="size-4" />
                      </button>
                    )}
                  </div>
                  <span className="text-xs font-semibold tabular-nums text-muted-foreground">
                    {assignedPlayers.length}/{expected} spillere
                    {assignedPlayers.some((p) => p.playingHandicap !== null) && (
                      <>
                        {" · "}
                        Match-hcp{" "}
                        {assignedPlayers
                          .filter((p) => p.playingHandicap !== null)
                          .map((p) => p.playingHandicap)
                          .join("/")}
                      </>
                    )}
                  </span>
                  {assignedPlayers.length === 0 ? (
                    <p className="text-sm text-muted-foreground">Ingen spillere ennå.</p>
                  ) : (
                    <ul className="flex flex-wrap gap-1.5">
                      {assignedPlayers.map((p) => (
                        <li
                          key={p.id}
                          className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 text-sm font-semibold text-foreground"
                        >
                          {p.name}
                          {canManage && (
                            <button
                              type="button"
                              disabled={busy}
                              onClick={() => assign(p.id, null)}
                              aria-label={`Fjern ${p.name} fra siden`}
                              className="flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-destructive"
                            >
                              <X aria-hidden="true" className="size-3" />
                            </button>
                          )}
                        </li>
                      ))}
                    </ul>
                  )}
                </>
              ) : canManage ? (
                <div className="flex flex-col gap-2">
                  <Input
                    value={labelDrafts[slotIndex] ?? ""}
                    onChange={(e) => setLabelDrafts((prev) => ({ ...prev, [slotIndex]: e.target.value }))}
                    placeholder={`Navn på side (valgfritt)`}
                    className="h-10 rounded-xl text-sm"
                  />
                  <Button
                    type="button"
                    variant="outline"
                    disabled={busy}
                    onClick={() => createSide(slotIndex)}
                    className="h-10 rounded-xl text-sm font-bold"
                  >
                    Opprett side
                  </Button>
                </div>
              ) : (
                <p className="text-sm text-muted-foreground">Ikke opprettet ennå.</p>
              )}
            </div>
          )
        })}
      </div>

      {canManage && unassigned.length > 0 && (
        <div className="flex flex-col gap-2 border-t border-border pt-3">
          <span className="text-sm font-semibold text-foreground">Ikke tildelt en side</span>
          <ul className="flex flex-col gap-2">
            {unassigned.map((p) => (
              <li key={p.id} className="flex flex-wrap items-center gap-2">
                <span className="text-sm font-semibold text-foreground">{p.name}</span>
                {slots.map((side, slotIndex) => {
                  if (!side) return null
                  const count = players.filter((x) => x.roundSideId === side.id).length
                  const full = count >= expected
                  return (
                    <button
                      key={slotIndex}
                      type="button"
                      disabled={busy || full}
                      onClick={() => assign(p.id, side.id)}
                      className="inline-flex min-h-8 items-center rounded-full border border-border bg-card px-3 text-xs font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
                    >
                      → {side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
                    </button>
                  )
                })}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}

// --- Løpende matchstatus / skins-tavle (ADR-039) ----------------------------
// Avledet ved LESING (aldri lagret) fra GET .../format-result -- samme
// filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger
// ingen egen visning her (eksisterende leaderboard dekker det).

// Per-hull-oppløsning (2026-07-29, samme kontrakt som round-scorecard.tsx sin
// ApiFormatHoleEntry/ApiFormatHoleOut) -- gjør det mulig å farge hver
// scorecelle etter hvem som VANT hullet, og å regne en løpende Stilling-rad,
// i stedet for kun et sluttresultat.
type ApiFormatHoleEntry = {
  id: string
  label: string
  side: "a" | "b" | null
  gross: number | null
  net: number | null
  strokes_received: number | null
  counted: boolean | null
  // Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE hullet.
  points: number | null
}

type ApiFormatHoleOut = {
  hole_number: number
  par: number
  stroke_index: number
  entries: ApiFormatHoleEntry[]
  result: string | null
  winner_ids: string[] | null
  carried: boolean | null
}

type ApiFlagResult = {
  holes_completed: number
  ran_out: boolean
  strokes_remaining: number
  label: string
}

type ApiBBBHole = {
  hole_number: number
  bingo_participant_id: string | null
  bango_participant_id: string | null
  bongo_participant_id: string | null
}

// Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069, 2026-08-14).
type ApiFlagPlant = {
  id: string
  lap: number
  hole_number: number
  lat: number
  lng: number
  on_green: boolean
  distance_to_pin_cm: number | null
  planted_by_user_id: string
  planted_at: string
}

type ApiFlagOverflowHole = {
  lap: number
  hole_number: number
  score: number | null
  played: boolean
}

// Speiler _flag_current_position (app/routers/rounds.py) presist -- brukt
// KUN til å vise riktig "hull N"/"lap L" i UI-et FØR innsending; serveren
// er alltid den autoritative kilden og validerer på nytt (se plantFlag).
function computeFlagCurrentPosition(
  holeOrder: number[],
  holes: ApiHole[] | undefined,
  overflowHoles: ApiFlagOverflowHole[] | undefined,
): { lap: number; holeNumber: number } {
  const lap1Played = new Set((holes ?? []).filter((h) => h.played).map((h) => h.hole_number))
  for (const h of holeOrder) {
    if (!lap1Played.has(h)) return { lap: 1, holeNumber: h }
  }
  let lap = 2
  while (true) {
    const lapPlayed = new Set(
      (overflowHoles ?? []).filter((h) => h.lap === lap && h.played).map((h) => h.hole_number),
    )
    for (const h of holeOrder) {
      if (!lapPlayed.has(h)) return { lap, holeNumber: h }
    }
    lap += 1
  }
}

type ApiFormatResult = {
  play_format: string
  ready: boolean
  match_lead: number | null
  match_holes_played: number | null
  match_holes_remaining: number | null
  match_is_closed: boolean | null
  match_is_dormie: boolean | null
  match_status_text: string | null
  hole_results: string[] | null
  skins_won: Record<string, number> | null
  // Åtte nye formater (2026-07-30) -- se app/routers/rounds.py sin
  // RoundFormatResultOut for full kontrakt.
  copenhagen_points: Record<string, number> | null
  bbb_points: Record<string, number> | null
  flag_results: Record<string, ApiFlagResult> | null
  shamble_team_score: number | null
  money_ball_team_score: number | null
  hlh_points_a: number | null
  hlh_points_b: number | null
  // Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
  // side "a" = laget, side "b" = individualspilleren. margin > 0 = laget leder.
  scramble_solo_team_net: number | null
  scramble_solo_individual_net: number | null
  scramble_solo_margin: number | null
  holes: ApiFormatHoleOut[] | null
}

// Kort statustekst UTEN backend sin bokstavelige "(A)"/"(B)"-suffiks --
// identitetsbanneret viser navnet separat (se IdentitySide), så suffikset
// ville vært en duplikat. Speiler handicap_engine.py sin
// MatchState.describe() presist.
function formatMatchStatus(lead: number, holesRemaining: number, isClosed: boolean, isDormie: boolean): string {
  const margin = Math.abs(lead)
  if (isClosed) return holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}`
  if (lead === 0) return "AS"
  return isDormie ? `Dormie ${margin}` : `${margin} UP`
}

// Speiler samme funksjon i handicap_engine.py/round-scorecard.tsx, men
// regnet ETT PREFIKS om gangen langs `holeOrder` (spillerekkefølgen, ikke
// nødvendigvis fysisk hullnummer) -- gir en løpende stilling PER HULL i
// stedet for kun sluttresultatet. Stopper (viser ingen verdi videre) ved
// første hull uten et registrert resultat, siden hull avgjøres sekvensielt.
function matchStillingByHole(
  holeOrder: number[],
  formatResult: ApiFormatResult | null,
  totalHoles: number,
): Map<number, { label: string; tone: "a" | "b" | "neutral" }> {
  const out = new Map<number, { label: string; tone: "a" | "b" | "neutral" }>()
  if (!formatResult?.holes) return out
  const byHole = new Map(formatResult.holes.map((h) => [h.hole_number, h]))
  let lead = 0
  let holesPlayed = 0
  for (const n of holeOrder) {
    const h = byHole.get(n)
    if (!h || !h.result) break
    holesPlayed += 1
    lead += h.result === "a" ? 1 : h.result === "b" ? -1 : 0
    const holesRemaining = totalHoles - holesPlayed
    const isClosed = Math.abs(lead) > holesRemaining
    const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining
    out.set(n, {
      label: formatMatchStatus(lead, holesRemaining, isClosed, isDormie),
      tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral",
    })
  }
  return out
}

// Identitet (navn+HCP) for en side i banneret/gridet -- viser SPILLERENS
// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome-
// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers
// sidens eget (evt. generiske) navn uten noen entydig HCP å vise.
// Samme adapter-logikk som round-leaderboard.tsx sin buildScrambleSoloResult
// (lokal kopi, prosjektets etablerte mønster) -- bygger ScrambleSoloResultView
// sin props-form fra det generiske ApiFormatResult-svaret.
function buildScrambleSoloResult(round: ApiRound, result: ApiFormatResult): ScrambleSoloResult {
  const teamSide = round.sides.find((s) => s.side_role === "team")
  const individualSide = round.sides.find((s) => s.side_role === "individual")
  const firstHoleEntries = result.holes?.[0]?.entries ?? []
  const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
  const individualLabel =
    firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"

  const holes: ScrambleSoloResult["holes"] = (result.holes ?? []).map((h) => {
    const teamEntry = h.entries.find((e) => e.side === "a")
    const individualEntry = h.entries.find((e) => e.side === "b")
    const holeResult: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
    return {
      holeNumber: h.hole_number,
      par: h.par,
      strokeIndex: h.stroke_index,
      result: holeResult,
      team: {
        gross: teamEntry?.gross ?? 0,
        net: teamEntry?.net ?? 0,
        strokesReceived: teamEntry?.strokes_received ?? 0,
      },
      individual: {
        gross: individualEntry?.gross ?? 0,
        net: individualEntry?.net ?? 0,
        strokesReceived: individualEntry?.strokes_received ?? 0,
      },
    }
  })

  return {
    ready: result.ready,
    teamLabel,
    individualLabel,
    teamNet: result.scramble_solo_team_net,
    individualNet: result.scramble_solo_individual_net,
    margin: result.scramble_solo_margin,
    holes,
  }
}

function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } {
  const idx = side === "a" ? 0 : 1
  const sideId = round.sides[idx]?.id
  const sidePlayers = players.filter((p) => p.roundSideId === sideId)
  if (sidePlayers.length === 1) return { name: sidePlayers[0].name, hcp: sidePlayers[0].playingHandicap }
  return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null }
}

function IdentitySide({ identity, side }: { identity: { name: string; hcp: number | null }; side: "a" | "b" }) {
  return (
    <div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", side === "a" ? "items-start" : "items-end")}>
      <div className={cn("flex min-w-0 items-center gap-1.5", side === "b" && "flex-row-reverse")}>
        <span
          aria-hidden="true"
          className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
        />
        <span className="min-w-0 truncate text-sm font-extrabold text-foreground">{identity.name}</span>
      </div>
      {identity.hcp !== null && (
        <span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {identity.hcp}</span>
      )}
    </div>
  )
}

// Territorium-bar (2026-07-29, erstatter det tidligere flate, likt-delte
// banneret) -- den ledende sidens sone strekker seg PROPORSJONALT forbi
// midtlinjen inn på motstanderens halvdel (brukerens eget forslag: "hele
// den halve delen som tilhører den som er en opp er markert over på
// motstanderens halvdel"), i stedet for en statisk 50/50-boks med tekst i
// midten. Ved AS (eller ingen hull spilt ennå) er begge soner nøyaktig like
// store OG likt (nøytralt) farget -- ingen side skal se ut til å lede.
// Bruker samme proporsjonale to-fargede-bar-idé som SegmentedBar
// (tournament-leaderboard.tsx), tilpasset lead-i-hull i stedet for poeng.
function leadZoneFraction(lead: number, totalHoles: number): number {
  if (totalHoles <= 0) return 0.5
  const fraction = 0.5 + (lead / totalHoles) * 0.5
  return Math.min(0.82, Math.max(0.18, fraction))
}

function LeadZone({
  identity,
  side,
  dominant,
}: {
  identity: { name: string; hcp: number | null }
  side: "a" | "b"
  dominant: boolean
}) {
  const textTone = dominant ? (side === "a" ? "text-primary-foreground" : "text-brand-orange-foreground") : "text-foreground"
  const subTone = dominant
    ? side === "a"
      ? "text-primary-foreground/75"
      : "text-brand-orange-foreground/75"
    : "text-muted-foreground"
  // Merk: den ytre boksen STREKKER SEG (ingen items-start/items-end her --
  // det ville sizet boksen etter innholdets egen bredde, ikke etter sonens
  // faktiske tildelte bredde, og kunne visuelt lekke inn i midt-kolonnen på
  // smale skjermer). Venstre/høyre-justering skjer i stedet med
  // justify-start/justify-end og text-align INNI en boks som alltid har
  // sonens fulle, korrekte bredde -- truncate virker da presist.
  return (
    <div className="flex h-full min-w-0 w-full flex-col justify-center gap-0.5 px-3">
      <div
        className={cn(
          "flex min-w-0 items-center gap-1.5",
          side === "a" ? "justify-start" : "justify-end",
          side === "b" && "flex-row-reverse",
        )}
      >
        {!dominant && (
          <span
            aria-hidden="true"
            className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
          />
        )}
        <span className={cn("min-w-0 truncate text-sm font-extrabold", textTone)}>{identity.name}</span>
      </div>
      {identity.hcp !== null && (
        <span
          className={cn(
            "truncate text-xs font-bold tabular-nums",
            subTone,
            side === "a" ? "text-left" : "text-right",
          )}
        >
          HCP {identity.hcp}
        </span>
      )}
    </div>
  )
}

type ApiFlightSummary = {
  round_id: string
  name: string | null
  course_name_snapshot: string
  played_at: string
  started_at: string | null
  completed_at: string | null
  participant_count: number
  is_anchor: boolean
}

// Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs
// gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg
// til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten
// (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne
// flightens dato/starthull/hullantall/spilleform via URL-parametre, men
// banen velges på nytt (samme lave friksjon som normal bane-søk ellers).
function FlightGroupPanel({
  roundId,
  round,
  isOwnerViewer,
}: {
  roundId: string
  round: ApiRound
  isOwnerViewer: boolean
}) {
  const router = useRouter()
  const [flights, setFlights] = useState<ApiFlightSummary[] | null>(null)
  const [adding, setAdding] = useState(false)

  useEffect(() => {
    let cancelled = false
    fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => {
        if (!cancelled) setFlights(data?.flights ?? null)
      })
      .catch(() => {
        if (!cancelled) setFlights(null)
      })
    return () => {
      cancelled = true
    }
  }, [roundId, round.flight_group_id])

  async function addFlight() {
    if (!isOwnerViewer) return
    setAdding(true)
    try {
      let groupId = round.flight_group_id
      if (!groupId) {
        groupId = crypto.randomUUID()
        await fetch(`/rounds/${roundId}`, {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          credentials: "include",
          body: JSON.stringify({ flight_group_id: groupId }),
        })
      }
      const params = new URLSearchParams({
        flightGroupId: groupId,
        playedAt: round.played_at,
        startHole: String(round.start_hole),
        holes: String(round.holes_planned),
        playFormat: round.play_format,
      })
      router.push(`/my-rounds/new?${params.toString()}`)
    } finally {
      setAdding(false)
    }
  }

  const others = (flights ?? []).filter((f) => f.round_id !== roundId)

  return (
    <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <div className="flex items-center justify-between gap-2">
        <span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
          <Users aria-hidden="true" className="size-4 text-primary" />
          Flighter
        </span>
        {isOwnerViewer && (
          <button
            type="button"
            onClick={addFlight}
            disabled={adding}
            className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
          >
            <Plus aria-hidden="true" className="size-4" />
            {adding ? "Setter opp…" : "Legg til en flight til"}
          </button>
        )}
      </div>

      {others.length === 0 ? (
        <p className="text-sm text-muted-foreground">
          Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag --
          de deler da leaderboard med denne flighten.
        </p>
      ) : (
        <>
          <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
            {(flights ?? []).map((f) => (
              <li key={f.round_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
                {f.round_id === roundId ? (
                  <span className="text-sm font-semibold text-foreground">
                    {f.name || f.course_name_snapshot}{" "}
                    <span className="font-normal text-muted-foreground">(denne)</span>
                  </span>
                ) : (
                  <Link
                    href={`/my-rounds/${f.round_id}`}
                    className="text-sm font-semibold text-primary hover:underline"
                  >
                    {f.name || f.course_name_snapshot}
                  </Link>
                )}
                <span className="text-xs font-semibold tabular-nums text-muted-foreground">
                  {f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
                  {f.completed_at ? " · Ferdig" : ""}
                </span>
              </li>
            ))}
          </ul>
          <Link
            href={`/my-rounds/${roundId}/flights`}
            className="inline-flex min-h-11 items-center gap-1.5 self-start text-sm font-bold text-primary hover:underline"
          >
            Se samlet leaderboard for flightene
            <ArrowRight aria-hidden="true" className="size-4" />
          </Link>
        </>
      )}
    </div>
  )
}

function FormatResultPanel({
  round,
  players,
  result,
}: {
  round: ApiRound
  players: Player[]
  result: ApiFormatResult | null
}) {
  if (round.play_format === "stroke" || round.play_format === "stableford" || !result) return null

  if (round.play_format === "skins") {
    const won = result.skins_won ?? {}
    const rows = players
      .map((p) => ({ player: p, skins: won[p.id] ?? 0 }))
      .sort((a, b) => b.skins - a.skins)
    const totalAwarded = Object.values(won).reduce((sum, v) => sum + v, 0)
    return (
      <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
        <div className="flex items-center justify-between gap-2">
          <span className="text-sm font-bold text-foreground">Skins-tavle</span>
          <span className="text-xs font-semibold tabular-nums text-muted-foreground">
            {totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
          </span>
        </div>
        <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
          {rows.map(({ player, skins }) => (
            <li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
              <span className="text-sm font-semibold text-foreground">{player.name}</span>
              <span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
            </li>
          ))}
        </ul>
      </div>
    )
  }

  // Københavner (2026-07-30) -- flatt felt, poengtabell (6 poeng deles
  // per hull mellom nøyaktig 3 spillere -- flest poeng totalt vinner).
  if (round.play_format === "copenhagen") {
    const points = result.copenhagen_points ?? {}
    const rows = players
      .map((p) => ({ player: p, points: points[p.id] ?? 0 }))
      .sort((a, b) => b.points - a.points)
    return (
      <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
        <span className="text-sm font-bold text-foreground">Københavner -- poengtavle</span>
        <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
          {rows.map(({ player, points: pts }) => (
            <li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
              <span className="text-sm font-semibold text-foreground">{player.name}</span>
              <span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
            </li>
          ))}
        </ul>
      </div>
    )
  }

  // Bingo Bango Bongo (2026-07-30) -- flatt felt, poengtabell (registreres
  // per hull via egen BBBHolePicker, se lenger ned i Score-fanen).
  if (round.play_format === "bbb") {
    const points = result.bbb_points ?? {}
    const rows = players
      .map((p) => ({ player: p, points: points[p.id] ?? 0 }))
      .sort((a, b) => b.points - a.points)
    return (
      <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
        <span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- poengtavle</span>
        <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
          {rows.map(({ player, points: pts }) => (
            <li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
              <span className="text-sm font-semibold text-foreground">{player.name}</span>
              <span className="text-base font-extrabold tabular-nums text-foreground">{pts}</span>
            </li>
          ))}
        </ul>
      </div>
    )
  }

  // Flaggturnering (2026-07-30) -- flatt felt, individuelt slagbudsjett.
  // Flest fullførte hull vinner (ran_out=true, gikk tom for slag).
  if (round.play_format === "flag") {
    const results = result.flag_results ?? {}
    const rows = Object.entries(results)
      .map(([id, r]) => ({ id, ...r }))
      .sort((a, b) => b.holes_completed - a.holes_completed)
    return (
      <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
        <span className="text-sm font-bold text-foreground">Flaggturnering</span>
        {rows.length === 0 ? (
          <p className="text-sm text-muted-foreground">Venter på at handicap beregnes for minst én spiller.</p>
        ) : (
          <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
            {rows.map((r) => (
              <li key={r.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
                <span className="text-sm font-semibold text-foreground">{r.label}</span>
                <span className="flex flex-col items-end">
                  <span className="text-base font-extrabold tabular-nums text-foreground">
                    {r.holes_completed} hull
                  </span>
                  <span className="text-xs tabular-nums text-muted-foreground">
                    {r.ran_out
                      ? "gikk tom for slag"
                      : `${r.strokes_remaining} slag igjen`}
                  </span>
                </span>
              </li>
            ))}
          </ul>
        )}
      </div>
    )
  }

  // Shamble/Money Ball (2026-07-30) -- flatt felt, ETT lag = hele rundens
  // deltakersett (ingen motstander innad -- flere lag kobles via
  // flight_group_id-leaderboardet). Viser kun lagets samlede sum.
  if (round.play_format === "shamble" || round.play_format === "money_ball") {
    const teamScore = round.play_format === "shamble" ? result.shamble_team_score : result.money_ball_team_score
    return (
      <div className="mb-5 flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-5 text-center">
        <span className="text-sm font-bold text-foreground">
          {FORMAT_LABELS[round.play_format]} -- lagets samlede score
        </span>
        <span className="text-4xl font-extrabold tabular-nums text-foreground">{teamScore ?? "–"}</span>
        {teamScore === null && (
          <p className="text-xs text-muted-foreground">Venter på at alle spillerne registrerer på minst ett hull.</p>
        )}
      </div>
    )
  }

  // High-low-high (2026-07-30) -- literal poengsum (IKKE match-play
  // "X UP" -- poeng fordeles 0-2 per hull via to uavhengige duelløer,
  // se handicap_engine.py sin high_low_high_points_for_hole). Samme
  // territorium-bar-visuelle språk som resten av to-sidede formater,
  // men banneret viser "{poeng A}-{poeng B}" i stedet for en "X UP"-tekst.
  if (round.play_format === "high_low_high") {
    if (!result.ready) {
      return (
        <div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
          Venter på at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises.
        </div>
      )
    }
    const pa = result.hlh_points_a ?? 0
    const pb = result.hlh_points_b ?? 0
    const holesPlayed = result.match_holes_played ?? 0
    const identityA = sideIdentity(round, players, "a")
    const identityB = sideIdentity(round, players, "b")
    const tone: "a" | "b" | "neutral" = pa > pb ? "a" : pb > pa ? "b" : "neutral"
    const widthA = pa + pb > 0 ? (pa / (pa + pb)) * 100 : 50
    const widthB = 100 - widthA
    const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
    const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
    return (
      <div className="mb-5 flex flex-col gap-2">
        <span className="text-sm font-bold text-foreground">High-low-high</span>
        <div
          className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
          style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
        >
          <div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
            <LeadZone identity={identityA} side="a" dominant={tone === "a"} />
          </div>
          <div className="flex h-full items-center justify-center px-2">
            <div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
              {holesPlayed > 0 ? (
                <>
                  <span
                    className={cn(
                      "text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
                      tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
                    )}
                  >
                    {pa}-{pb}
                  </span>
                  <span className="mt-1 text-[11px] font-semibold text-muted-foreground">{holesPlayed} hull spilt</span>
                </>
              ) : (
                <span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
              )}
            </div>
          </div>
          <div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
            <LeadZone identity={identityB} side="b" dominant={tone === "b"} />
          </div>
        </div>
      </div>
    )
  }

  // Scramble mot enkeltspiller (migrasjon 056, 2026-08-05) -- netto
  // slagspill-sammenligning, ikke matchspill -- MÅ avskjæres FØR den
  // generiske to-sidede fallback-koden under, som forutsetter et
  // symmetrisk match-play-format (ville krasjet/vist feil for dette
  // formatet ellers). Gjenbruker samme ScrambleSoloResultView som den
  // frittstående leaderboard-siden (round-leaderboard.tsx), ikke en egen
  // kondensert visning.
  if (round.play_format === "scramble_solo") {
    return (
      <div className="mb-5">
        <ScrambleSoloResultView result={buildScrambleSoloResult(round, result)} />
      </div>
    )
  }

  // To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
  if (!result.ready) {
    return (
      <div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
        Venter på at begge sider er komplette og har beregnet handicap før {FORMAT_LABELS[round.play_format] ?? round.play_format}-status kan vises.
      </div>
    )
  }

  // Identitetsbanner (2026-07-29, territorium-bar-versjon -- se leadZoneFraction
  // over). Navn+HCP i hver sone, stor sentrert løpende status flytende oppå
  // begge soner, "Ferdig"-merke når avgjort -- samme visuelle språk (nå) som
  // MatchScorecardGrid (round-scorecard.tsx) og TournamentMatchGrid
  // (session-scorecard.tsx), egen lokal kopi per prosjektets konvensjon.
  const identityA = sideIdentity(round, players, "a")
  const identityB = sideIdentity(round, players, "b")
  const lead = result.match_lead ?? 0
  const holesPlayed = result.match_holes_played ?? 0
  const holesRemaining = result.match_holes_remaining ?? 0
  const isClosed = result.match_is_closed ?? false
  const isDormie = result.match_is_dormie ?? false
  const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
  const statusLabel = holesPlayed > 0 ? formatMatchStatus(lead, holesRemaining, isClosed, isDormie) : null
  const totalHoles = holesPlayed + holesRemaining
  const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
  const widthB = 100 - widthA
  const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
  const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"

  return (
    <div className="mb-5 flex flex-col gap-2">
      <span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span>
      {/* CSS Grid, ikke absolutt-posisjonert overlegg (2026-07-29-fiks) --
          en tidligere versjon la statusen som en absolutt sentrert boks OPPÅ
          navnetekst, som kunne overlappe et langt navn på smale skjermer.
          Med grid-kolonner `minmax(0,X fr) auto minmax(0,Y fr)` reserverer
          nettleseren ALLTID nøyaktig midt-kolonnens egen bredde til status-
          boksen -- soneandelen forblir proporsjonal (X:Y), men kan aldri
          overlappe, og lange navn trunkeres i stedet (samme `truncate`-
          mekanisme som ellers). */}
      <div
        className="grid h-[76px] w-full overflow-hidden rounded-2xl shadow-md shadow-black/8 sm:h-20"
        style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
      >
        <div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneAClass)}>
          <LeadZone identity={identityA} side="a" dominant={tone === "a"} />
        </div>
        <div className="flex h-full items-center justify-center px-2">
          <div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
            {statusLabel ? (
              <>
                <span
                  className={cn(
                    "text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
                    tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
                  )}
                >
                  {statusLabel}
                </span>
                {isClosed ? (
                  <span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
                    Ferdig
                  </span>
                ) : (
                  <span className="mt-1 text-[11px] font-semibold text-muted-foreground">
                    {holesPlayed} hull spilt
                    {holesRemaining > 0 && isDormie && " · dormie"}
                  </span>
                )}
              </>
            ) : (
              <span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
            )}
          </div>
        </div>
        <div className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", zoneBClass)}>
          <LeadZone identity={identityB} side="b" dominant={tone === "b"} />
        </div>
      </div>
    </div>
  )
}

// --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data --
// tre parallelle vinduer av et EKSISTERENDE to-sidet format, ingen egen
// lagring (se app/routers/rounds.py sin get_round_nassau_result).

type ApiNassauWindow = {
  lead: number
  holes_played: number
  holes_remaining: number
  is_closed: boolean
  is_dormie: boolean
  status_text: string
}
type ApiNassauResult = {
  ready: boolean
  front9: ApiNassauWindow | null
  back9: ApiNassauWindow | null
  overall: ApiNassauWindow | null
}

function NassauWindowCard({ label, window }: { label: string; window: ApiNassauWindow | null }) {
  return (
    <div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-background px-3 py-3">
      <span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
      {window && window.holes_played > 0 ? (
        <>
          <span className="text-lg font-extrabold tabular-nums text-foreground">{window.status_text}</span>
          <span className="text-[11px] text-muted-foreground">{window.holes_played} hull spilt</span>
        </>
      ) : (
        <span className="text-sm text-muted-foreground">–</span>
      )}
    </div>
  )
}

function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) {
  const [result, setResult] = useState<ApiNassauResult | null>(null)
  useEffect(() => {
    let cancelled = false
    fetch(`/rounds/${roundId}/format-result/nassau`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then((data: ApiNassauResult | null) => {
        if (!cancelled) setResult(data)
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [roundId, formatResultRefreshTick])

  if (!result?.ready) return null

  return (
    <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <span className="text-sm font-bold text-foreground">Nassau</span>
      <div className="grid grid-cols-3 gap-2">
        <NassauWindowCard label="Ut (1-9)" window={result.front9} />
        <NassauWindowCard label="Inn (10-18)" window={result.back9} />
        <NassauWindowCard label="Totalt" window={result.overall} />
      </div>
    </div>
  )
}

// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --------
// Selvstendig, samme "egen fetch + refreshTick"-mønster som NassauPanel over.
// Rendres kun i Administrer-fanen (samme sted som leaderboard-forhåndsvisning/
// Nassau) -- kun synlig for eier/medspiller (samme tilgang som resten av
// Score-siden), ikke tilskuere (de har sin egen /watch/[id]-visning, se
// egen komponent der).

const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })

type ApiFlagMap = { visible_to_all: boolean; flags: FlagMapEntry[] }

function FlagMapSection({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) {
  const [data, setData] = useState<ApiFlagMap | null>(null)
  const [expanded, setExpanded] = useState(false)

  useEffect(() => {
    let cancelled = false
    fetch(`/rounds/${roundId}/flag-map`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : null))
      .then((json: { visible_to_all: boolean; flags: Array<Record<string, unknown>> } | null) => {
        if (cancelled || !json) return
        setData({
          visible_to_all: json.visible_to_all,
          flags: json.flags.map((f) => ({
            participantId: f.participant_id as string,
            displayName: f.display_name as string,
            lap: f.lap as number,
            holeNumber: f.hole_number as number,
            lat: f.lat as number,
            lng: f.lng as number,
            onGreen: f.on_green as boolean,
            distanceToPinCm: f.distance_to_pin_cm as number | null,
          })),
        })
      })
      .catch(() => {})
    return () => {
      cancelled = true
    }
  }, [roundId, formatResultRefreshTick])

  if (!data) return null

  return (
    <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        className="flex min-h-11 items-center justify-between gap-2 text-left"
        aria-expanded={expanded}
      >
        <span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
        <span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
      </button>
      {expanded && (
        <div className="h-80 overflow-hidden rounded-xl border border-border">
          <FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
        </div>
      )}
    </div>
  )
}

// --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) -----------------
// Tre uavhengige kategorier (først på green/nærmest hull/først i hull),
// manuelt registrert av den som fører score -- ikke GPS/live-sporet. Én
// spiller kan vinne flere/alle kategorier på samme hull (sistnevnte utløser
// evt. sweep-bonusen, se compute_bbb). Klikk på en allerede valgt spiller
// fjerner valget (samme "toggle av/på"-mønster som resten av appen).

function BBBCategoryRow({
  label,
  players,
  selectedId,
  onSelect,
}: {
  label: string
  players: Player[]
  selectedId: string | null
  onSelect: (id: string | null) => void
}) {
  return (
    <div className="flex flex-col gap-2">
      <span className="text-sm font-semibold text-foreground">{label}</span>
      <div className="flex flex-wrap gap-2">
        {players.map((p) => {
          const active = selectedId === p.id
          return (
            <button
              key={p.id}
              type="button"
              onClick={() => onSelect(active ? null : p.id)}
              aria-pressed={active}
              className={cn(
                "min-h-11 rounded-xl border px-3 text-sm font-semibold transition-colors",
                active
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-border bg-background text-foreground hover:bg-accent/50",
              )}
            >
              {p.name}
            </button>
          )
        })}
      </div>
    </div>
  )
}

function BBBHolePicker({
  players,
  holeNumber,
  selection,
  onSet,
}: {
  players: Player[]
  holeNumber: number
  selection: ApiBBBHole | null
  onSet: (holeNumber: number, body: Record<string, string | null>) => Promise<void>
}) {
  return (
    <div className="mb-5 flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <span className="text-sm font-bold text-foreground">Bingo Bango Bongo -- hull {holeNumber}</span>
      <BBBCategoryRow
        label="Bingo (først på green)"
        players={players}
        selectedId={selection?.bingo_participant_id ?? null}
        onSelect={(id) => void onSet(holeNumber, { bingo_participant_id: id })}
      />
      <BBBCategoryRow
        label="Bango (nærmest hull, når alle er på green)"
        players={players}
        selectedId={selection?.bango_participant_id ?? null}
        onSelect={(id) => void onSet(holeNumber, { bango_participant_id: id })}
      />
      <BBBCategoryRow
        label="Bongo (først i hull)"
        players={players}
        selectedId={selection?.bongo_participant_id ?? null}
        onSelect={(id) => void onSet(holeNumber, { bongo_participant_id: id })}
      />
    </div>
  )
}

// --- Money Ball -- lineup_order-tildeling (2026-07-30) ----------------------
// Fast rotasjonsrekkefølge (0-3), avgjør hvem som er "money ball" på hvilket
// hull. Gjenbruker eksisterende patchParticipant (samme generiske PATCH som
// resten av spiller-redigeringen) -- ingen egen fetch-logikk her.

function MoneyBallLineupPanel({
  players,
  onPatchParticipant,
}: {
  players: Player[]
  onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [error, setError] = useState<string | null>(null)
  const allAssigned = players.length === 4 && players.every((p) => p.lineupOrder !== null)

  async function assign(playerId: string, order: number) {
    setError(null)
    const res = await onPatchParticipant(playerId, { lineup_order: order })
    if (!res.ok) setError(res.message)
  }

  if (allAssigned) return null

  return (
    <div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <span className="text-sm font-bold text-foreground">Money Ball -- rekkefølge</span>
      <p className="text-sm text-muted-foreground">
        Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull.
      </p>
      {error && <p className="text-sm font-medium text-destructive">{error}</p>}
      <ul className="flex flex-col gap-2">
        {players.map((p) => (
          <li key={p.id} className="flex items-center justify-between gap-3 rounded-xl border border-border px-4 py-2.5">
            <span className="text-sm font-semibold text-foreground">{p.name}</span>
            <div className="flex gap-1.5">
              {[0, 1, 2, 3].map((n) => (
                <button
                  key={n}
                  type="button"
                  onClick={() => void assign(p.id, n)}
                  aria-pressed={p.lineupOrder === n}
                  className={cn(
                    "flex size-9 items-center justify-center rounded-lg border text-sm font-bold transition-colors",
                    p.lineupOrder === n
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-border bg-background text-foreground hover:bg-accent/50",
                  )}
                >
                  {n}
                </button>
              ))}
            </div>
          </li>
        ))}
      </ul>
    </div>
  )
}

// --- Player tabs -----------------------------------------------------------

// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
// Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv
// spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/
// statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar
// underseksjon. Skalerer til mange spillere uten fane-scroll.

function PlayerList({
  roundId,
  players,
  sides,
  holesByParticipant,
  activePlayerId,
  onSelect,
  onRemove,
  onAdd,
  addOpen,
  readOnly,
  canManage,
  editingPlayerId,
  onToggleEdit,
  onPatchParticipant,
  onSendScorecard,
  onAddGuest,
  onAddSearched,
}: {
  roundId: string
  players: Player[]
  // ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
  // tildelingen skjer i SidesPanel, ikke her.
  sides: ApiSide[]
  // ADR-065 -- kun brukt til "Ferdig · N hull"-tellingen i
  // ParticipantCompletionActions.
  holesByParticipant: Record<string, ApiHole[]>
  activePlayerId: string
  onSelect: (id: string) => void
  onRemove: (id: string) => void
  onAdd: () => void
  addOpen: boolean
  readOnly: boolean
  // Kun runde-eieren kan legge til/fjerne/redigere medspillere (ADR-036
  // fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt
  // VELGE kort for å registrere score for hele flighten.
  canManage: boolean
  editingPlayerId: string | null
  onToggleEdit: (id: string) => void
  onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
  onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
  onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
}) {
  return (
    <div className="flex flex-col gap-3" role="tablist" aria-label="Velg spiller">
      {players.map((player) => {
        const active = player.id === activePlayerId
        const editing = editingPlayerId === player.id
        const canRemove = !player.isSelf && !readOnly && canManage
        // ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er
        // fortsatt eier-only OG sperret etter fullføring. Men enhver
        // lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal
        // kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status
        // -- dekket av en RESTRIKTERT variant av samme panel, kun
        // meningsfullt for en spiller med egen konto (userId).
        const canEditFull = canManage && !readOnly
        const canEditRestricted = (canManage || player.isSelf) && player.userId !== null
        const canEdit = canEditFull || canEditRestricted
        return (
          <div
            key={player.id}
            className={cn(
              "overflow-hidden rounded-2xl border transition-colors",
              active ? "border-primary" : "border-border",
            )}
          >
            <div className={cn("flex items-stretch gap-1", active ? "bg-primary/10" : "bg-card")}>
              <button
                type="button"
                role="tab"
                aria-selected={active}
                onClick={() => onSelect(player.id)}
                className="flex min-h-[68px] flex-1 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-xl px-4 py-2 text-left transition-colors hover:bg-accent/50"
              >
                {/* Fast kort-høyde uansett innhold (2026-07-26, rapportert av
                    bruker) -- begge rader er ÉN linje med trunkering (ikke
                    wrap), slik at kort med ulikt antall merker/lange
                    navn/utslag likevel blir like høye, og listen samlet tar
                    minst mulig plass over score-tastaturet lenger ned. */}
                <span className="flex w-full min-w-0 items-center gap-2">
                  <span className="min-w-0 truncate text-base font-bold text-foreground">{player.name}</span>
                  {player.isSelf && (
                    <Badge variant="default" className="shrink-0">
                      Deg
                    </Badge>
                  )}
                  {player.isOwner && (
                    <Badge variant="outline" className="shrink-0">
                      Eier
                    </Badge>
                  )}
                  {player.roundSideId && (
                    <Badge variant="outline" className="shrink-0">
                      {(() => {
                        const idx = sides.findIndex((s) => s.id === player.roundSideId)
                        const side = sides[idx]
                        return side?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
                      })()}
                    </Badge>
                  )}
                </span>
                <span className="w-full truncate text-sm text-muted-foreground">
                  Utslag: <span className="font-semibold text-foreground">{player.teeName}</span>
                  {" · "}
                  HCP:{" "}
                  <span className="font-semibold tabular-nums text-foreground">
                    {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
                  </span>
                  {/* Tildelte slag (course handicap) -- KUN vist når HCP
                      faktisk spores for spilleren (samme betingelse som
                      "spilles med hcp" -- er den null, spores ikke HCP her). */}
                  {player.courseHandicap !== null && (
                    <>
                      {" · "}
                      Tildelte slag: <span className="font-semibold tabular-nums text-foreground">{player.courseHandicap}</span>
                    </>
                  )}
                </span>
              </button>
              <div className="flex shrink-0 flex-col items-center justify-center gap-1 py-1.5 pr-1.5">
                {canEdit && (
                  <button
                    type="button"
                    onClick={() => onToggleEdit(player.id)}
                    aria-expanded={editing}
                    aria-label={`Rediger ${player.name} for denne runden`}
                    className={cn(
                      "flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-xl px-2.5 text-sm font-semibold transition-colors",
                      editing ? "bg-primary text-primary-foreground" : "text-foreground hover:bg-accent",
                    )}
                  >
                    <Settings2 aria-hidden="true" className="size-4" />
                    <span className="hidden sm:inline">Rediger</span>
                  </button>
                )}
                {canRemove && (
                  <button
                    type="button"
                    onClick={() => onRemove(player.id)}
                    aria-label={`Fjern ${player.name}`}
                    className="flex min-h-11 min-w-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
                  >
                    <X aria-hidden="true" className="size-4" />
                  </button>
                )}
              </div>
            </div>

            <ParticipantCompletionActions
              player={player}
              holesPlayed={holesByParticipant[player.id]?.filter((h) => h.played).length ?? 0}
              readOnly={readOnly}
              onPatch={onPatchParticipant}
              onSendScorecard={onSendScorecard}
            />

            {editing && canEdit && (
              <EditParticipantPanel
                roundId={roundId}
                player={player}
                restricted={!canEditFull}
                onPatch={onPatchParticipant}
                onClose={() => onToggleEdit(player.id)}
              />
            )}
          </div>
        )
      })}

      {!readOnly && canManage && (
        addOpen ? (
          <AddParticipantForm onAddGuest={onAddGuest} onAddSearched={onAddSearched} onCancel={onAdd} />
        ) : (
          <button
            type="button"
            onClick={onAdd}
            aria-label="Legg til medspiller"
            className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-dashed border-border bg-card text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
          >
            <Plus aria-hidden="true" className="size-5" />
            Medspiller
          </button>
        )
      )}
    </div>
  )
}

// --- Legg til medspiller: søk etter ekte bruker, eller gjest uten konto ----
// (ADR-036 fase 3-utvidelsen, 2026-07-26 -- "+ Gjest" søkte tidligere ikke
// etter spillere i det hele tatt, kun et rent tekstfelt.)

function AddParticipantForm({
  onAddGuest,
  onAddSearched,
  onCancel,
}: {
  onAddGuest: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
  onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
  onCancel: () => void
}) {
  const [mode, setMode] = useState<"search" | "guest">("search")
  const [query, setQuery] = useState("")
  const [results, setResults] = useState<PersonMatch[]>([])
  const [searching, setSearching] = useState(false)
  const [adding, setAdding] = useState(false)
  const [searchError, setSearchError] = useState<string | null>(null)

  useEffect(() => {
    const trimmed = query.trim()
    if (trimmed.length < 2) {
      setResults([])
      setSearching(false)
      return
    }
    setSearching(true)
    const handle = setTimeout(() => {
      fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" })
        .then((res) => (res.ok ? res.json() : []))
        .then((data: PersonMatch[]) => setResults(data))
        .catch(() => setResults([]))
        .finally(() => setSearching(false))
    }, 250)
    return () => clearTimeout(handle)
  }, [query])

  async function handlePick(person: PersonMatch) {
    setAdding(true)
    setSearchError(null)
    const errorMessage = await onAddSearched(person.id, "strokes_only")
    setAdding(false)
    if (errorMessage) setSearchError(errorMessage)
  }

  if (mode === "guest") {
    return <AddGuestForm initialName={query} onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
  }

  return (
    <div className="mt-3 flex flex-col gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
      <div className="flex flex-col gap-2">
        <Label htmlFor="participant-search" className="text-base font-semibold">
          Søk etter medspiller
        </Label>
        <div className="relative">
          <Search aria-hidden="true" className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
          <Input
            id="participant-search"
            autoFocus
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Navn på medspilleren"
            className="h-12 rounded-2xl pl-11 text-base"
          />
        </div>
        {query.trim().length > 0 && query.trim().length < 2 && (
          <p className="text-sm text-muted-foreground">Skriv minst 2 tegn for å søke.</p>
        )}
      </div>

      {searchError && (
        <p role="alert" className="text-sm font-medium text-destructive">
          {searchError}
        </p>
      )}

      {results.length > 0 && (
        <ul className="flex flex-col gap-2">
          {results.map((person) => (
            <li key={person.id}>
              <button
                type="button"
                disabled={adding}
                onClick={() => handlePick(person)}
                className="flex min-h-14 w-full items-center gap-3 rounded-2xl border border-border bg-card px-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/50 disabled:opacity-60"
              >
                <span
                  aria-hidden="true"
                  className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground"
                >
                  {person.first_name[0]}
                  {person.last_name[0]}
                </span>
                <span className="flex min-w-0 flex-col">
                  <span className="truncate text-base font-bold text-foreground">
                    {person.first_name} {person.last_name}
                  </span>
                  <span className="truncate text-sm text-muted-foreground">
                    {person.home_club}
                    {person.home_club && person.handicap_index !== null && " · "}
                    {person.handicap_index !== null && `HCP ${person.handicap_index.toFixed(1).replace(".", ",")}`}
                  </span>
                </span>
              </button>
            </li>
          ))}
        </ul>
      )}

      {!searching && query.trim().length >= 2 && results.length === 0 && (
        <p className="text-sm text-muted-foreground">Fant ingen med det navnet.</p>
      )}

      <div className="flex items-center justify-between gap-3 border-t border-border pt-4">
        <button
          type="button"
          onClick={() => setMode("guest")}
          className="flex min-h-11 items-center text-sm font-semibold text-foreground underline-offset-2 hover:underline"
        >
          Legg til uten konto (gjest)
        </button>
        <Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-2xl px-5 text-base font-semibold">
          Avbryt
        </Button>
      </div>
    </div>
  )
}

// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
// Navnesplitt/e-post/"gjenkjenn gjest"-utvidelse 2026-08-03 -- se
// CHANGELOG.md for full bakgrunn (retroaktiv e-post-kobling, rundeoppsummer-
// ing per e-post ved fullføring).

// Samme "første ord = fornavn, resten = etternavn"-heuristikk som
// migrasjon 052 sin backfill -- brukt til å autofylle fra søkefeltet.
function splitName(full: string): { first: string; last: string } {
  const trimmed = full.trim()
  const spaceIdx = trimmed.indexOf(" ")
  if (spaceIdx === -1) return { first: trimmed, last: "" }
  return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() }
}

type KnownGuest = {
  guest_first_name: string
  guest_last_name: string | null
  gender: ApiGender | null
  handicap_index: number | null
}

function AddGuestForm({
  initialName,
  onAdd,
  onCancel,
  onBack,
}: {
  initialName: string
  onAdd: (firstName: string, lastName: string, email: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
  onCancel: () => void
  onBack?: () => void
}) {
  const initialSplit = useMemo(() => splitName(initialName), [initialName])
  const [firstName, setFirstName] = useState(initialSplit.first)
  const [lastName, setLastName] = useState(initialSplit.last)
  const [email, setEmail] = useState("")
  const [gender, setGender] = useState<Gender>("male")
  const [hcp, setHcp] = useState("")
  const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
  const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(null)
  const [knownGuestDismissed, setKnownGuestDismissed] = useState(false)

  // "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere
  // registrerte gjester (håndhevet server-side, se find_known_guest i
  // app/routers/rounds.py -- aldri et globalt oppslag).
  useEffect(() => {
    const trimmed = email.trim()
    setKnownGuestDismissed(false)
    if (trimmed.length < 5 || !trimmed.includes("@")) {
      setKnownGuest(null)
      return
    }
    const handle = setTimeout(() => {
      fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" })
        .then((res) => (res.ok ? res.json() : null))
        .then((data: KnownGuest | null) => setKnownGuest(data))
        .catch(() => setKnownGuest(null))
    }, 350)
    return () => clearTimeout(handle)
  }, [email])

  function useKnownGuest() {
    if (!knownGuest) return
    setFirstName(knownGuest.guest_first_name)
    setLastName(knownGuest.guest_last_name ?? "")
    if (knownGuest.gender) setGender(apiGenderToUi(knownGuest.gender))
    if (knownGuest.handicap_index !== null) setHcp(String(knownGuest.handicap_index).replace(".", ","))
    setKnownGuestDismissed(true)
  }

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const trimmedFirst = firstName.trim()
    if (!trimmedFirst) return
    const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
    onAdd(
      trimmedFirst,
      lastName.trim(),
      email.trim(),
      gender,
      parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null,
      statLevel,
    )
  }

  return (
    <form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
      {onBack && (
        <button
          type="button"
          onClick={onBack}
          className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
        >
          ← Søk i stedet
        </button>
      )}
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
        <div className="flex flex-col gap-2">
          <Label htmlFor="guest-first-name" className="text-base font-semibold">
            Fornavn
          </Label>
          <Input
            id="guest-first-name"
            autoFocus
            value={firstName}
            onChange={(e) => setFirstName(e.target.value)}
            placeholder="Fornavn"
            className="h-12 rounded-2xl text-base"
          />
        </div>
        <div className="flex flex-col gap-2">
          <Label htmlFor="guest-last-name" className="text-base font-semibold">
            Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
          </Label>
          <Input
            id="guest-last-name"
            value={lastName}
            onChange={(e) => setLastName(e.target.value)}
            placeholder="Etternavn"
            className="h-12 rounded-2xl text-base"
          />
        </div>
      </div>

      <div className="flex flex-col gap-2">
        <Label htmlFor="guest-email" className="text-base font-semibold">
          E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
        </Label>
        <Input
          id="guest-email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="For scorekort og statistikk etter runden"
          className="h-12 rounded-2xl text-base"
        />
        {knownGuest && !knownGuestDismissed && (
          <div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
            <p className="text-sm text-foreground">
              Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
              fra en tidligere runde du satte opp.
            </p>
            <Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
              Bruk disse opplysningene
            </Button>
          </div>
        )}
      </div>

      <ChoiceRow
        label="Kjønn"
        options={[
          { value: "male", label: "Mann" },
          { value: "female", label: "Kvinne" },
          { value: "other", label: "Annet" },
        ]}
        value={gender}
        onChange={(v) => setGender(v as Gender)}
        readOnly={false}
      />

      <div className="flex flex-col gap-2">
        <Label htmlFor="guest-hcp" className="text-base font-semibold">
          HCP <span className="font-normal text-muted-foreground">(valgfritt)</span>
        </Label>
        <Input
          id="guest-hcp"
          // type="text" (ikke "number") -- komma-desimal ("12,4") blokkeres
          // av native number-inputs i de fleste nettlesere før JS-parsingen
          // (som allerede håndterer komma korrekt) i det hele tatt ser den.
          // 2026-08-19.
          inputMode="decimal"
          value={hcp}
          onChange={(e) => setHcp(e.target.value)}
          placeholder="F.eks. 18"
          className="h-12 rounded-2xl text-base"
        />
      </div>

      <ChoiceRow
        label="Statistikk for denne spilleren"
        options={[
          { value: "strokes_only", label: "Kun slag" },
          { value: "strokes_and_putts", label: "Slag og putter" },
          { value: "full", label: "All statistikk" },
        ]}
        value={statLevel}
        onChange={(v) => setStatLevel(v as StatLevel)}
        readOnly={false}
      />

      <div className="flex gap-3">
        <Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
          Legg til
        </Button>
        <Button type="button" variant="ghost" onClick={onCancel} className="h-12 rounded-2xl px-6 text-base font-semibold">
          Avbryt
        </Button>
      </div>
    </form>
  )
}

// --- Rediger runde (bane/utslag/antall hull) -------------------------------
// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI
// allerede registrerte slag/putter/etc. (backend garanterer dette, se
// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført.

type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
type ApiPersonalCourseSummary = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
// GolfAPI.io (ADR-064) -- for baner utenfor teeoffs dekning (Tjøme Golfklubb var
// den konkrete anledningen, bekreftet IKKE i teeoff 2026-08-12).
type ApiInternationalCourseOption = { course_id: string; course_name: string; num_holes: number; has_gps: boolean }
type ApiInternationalClub = { club_id: string; club_name: string; city: string | null; country: string | null; courses: ApiInternationalCourseOption[] }

// Konverterer til/fra <input type="datetime-local">-verdiformatet
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
// tidsstempler (UTC), kun visningen er lokal.
function toDatetimeLocalValue(iso: string | null): string {
  if (!iso) return ""
  const d = new Date(iso)
  if (Number.isNaN(d.getTime())) return ""
  const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
  return local.toISOString().slice(0, 16)
}
function fromDatetimeLocalValue(value: string): string | null {
  if (!value) return null
  const d = new Date(value)
  return Number.isNaN(d.getTime()) ? null : d.toISOString()
}

// Samme fulle format-liste/etiketter som new-round.tsx sin opprettelses-
// veiviser (FORMATS) -- API-verdiene her er RoundUpdate.play_format sine
// egne (samme streng-sett som RoundCreate), ikke wizardens lokale FormatId.
const EDITABLE_PLAY_FORMATS: { value: string; label: string }[] = [
  { value: "stroke", label: "Slagspill" },
  { value: "stableford", label: "Stableford" },
  { value: "match", label: "Match" },
  { value: "skins", label: "Skins" },
  { value: "fourball", label: "Fourball" },
  { value: "foursome", label: "Foursome" },
  { value: "greensome", label: "Greensome" },
  { value: "scramble_2", label: "Scramble (2)" },
  { value: "scramble_4", label: "Scramble (4)" },
  { value: "chapman", label: "Chapman" },
  { value: "copenhagen", label: "Københavner" },
  { value: "bbb", label: "Bingo Bango Bongo" },
  { value: "flag", label: "Flaggturnering" },
  { value: "shamble", label: "Shamble" },
  { value: "money_ball", label: "Money Ball" },
  { value: "high_low_high", label: "High-low-high" },
]

function EditRoundPanel({
  round,
  onPatch,
  onClose,
}: {
  round: ApiRound
  onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onClose: () => void
}) {
  const isCompleted = round.completed_at !== null
  const [name, setName] = useState(round.name ?? "")
  const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
  const [startHole, setStartHole] = useState(String(round.start_hole))
  const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
  const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
  // ADR-038, utvidet med Stableford (migrasjon 038), utvidet igjen
  // 2026-08-03 til ALLE format (samme sett som new-round.tsx sin
  // opprettelses-veiviser -- var kun blitt hengende igjen på de tre
  // opprinnelige verdiene her, en ren forglemmelse fra ADR-039-rundene,
  // ikke en bevisst begrensning) -- ren metadata, aldri sperret av
  // fullført-status (rører aldri AGS/differensial-matematikken selv).
  const currentEditablePlayFormat = round.play_format
  const [playFormat, setPlayFormat] = useState<string>(currentEditablePlayFormat)
  // ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
  // av fullført-status (samme begrunnelse som playFormat over).
  const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
    round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private",
  )
  const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
  // Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
  const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
  const [saving, setSaving] = useState(false)
  const [showChangeCourse, setShowChangeCourse] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const sameCategories =
    visibleCategories.length === round.visible_categories.length &&
    visibleCategories.every((c) => round.visible_categories.includes(c))

  const hasMetadataChanges =
    name.trim() !== (round.name ?? "") ||
    (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
    toDatetimeLocalValue(round.started_at) !== startedAt ||
    (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) ||
    playFormat !== currentEditablePlayFormat ||
    visibilityMode !== round.visibility_mode ||
    (visibilityMode === "friends" && !sameCategories) ||
    flagMapVisible !== round.flag_map_visible

  async function saveMetadata() {
    setSaving(true)
    setError(null)
    const body: Record<string, unknown> = {}
    // Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor
    // alltid med når feltet faktisk er endret, aldri utelatt for å tømme.
    if (name.trim() !== (round.name ?? "")) body.name = name.trim()
    if (!isCompleted) {
      if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
      if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
    }
    if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt)
    if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
      body.completed_at = fromDatetimeLocalValue(completedAt)
    }
    if (playFormat !== currentEditablePlayFormat) body.play_format = playFormat
    if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
    if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
    if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
    const result = await onPatch(body)
    if (!result.ok) setError(result.message)
    setSaving(false)
  }

  return (
    <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
      <div className="flex items-center justify-between gap-2">
        <span className="text-sm font-bold text-foreground">Rediger runde</span>
        <Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-lg text-muted-foreground" aria-label="Lukk">
          <X aria-hidden="true" className="size-4" />
        </Button>
      </div>

      {error && <p className="text-sm font-medium text-destructive">{error}</p>}

      <div className="flex flex-col gap-2">
        <Label htmlFor="edit-round-name" className="text-sm font-semibold">
          Navn på runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
        </Label>
        <Input
          id="edit-round-name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder={round.course_name_snapshot}
          maxLength={200}
          className="h-11 rounded-xl text-base"
        />
      </div>

      <div className="flex flex-col gap-2">
        <span className="text-sm font-semibold text-foreground">Spilleform</span>
        {/* Bytte til et to-sidet/delt-ball-format som ennå ikke har sider
            opprettet, eller et format som trenger egen oppsett (Shamble sin
            best_n, Money Ball sin lineup_order), vises allerede av
            eksisterende "sett opp X først"-meldinger andre steder i
            "Spillere og runde" -- ingen egen validering trengs her. */}
        <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
          {EDITABLE_PLAY_FORMATS.map((opt) => (
            <button
              key={opt.value}
              type="button"
              onClick={() => setPlayFormat(opt.value)}
              aria-pressed={playFormat === opt.value}
              className={cn(
                "min-h-11 rounded-xl border px-2 py-2 text-sm font-bold leading-tight transition-colors",
                playFormat === opt.value
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-border bg-background text-foreground hover:bg-accent/50",
              )}
            >
              {opt.label}
            </button>
          ))}
        </div>
      </div>

      {/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
          innsyn (venner/offentligheten), aldri en faktisk lagt-til
          medspiller (som alltid ser runden uansett). */}
      <div className="flex flex-col gap-2">
        <span className="text-sm font-semibold text-foreground">Synlighet</span>
        <div className="flex gap-2">
          {(
            [
              { value: "private" as const, label: "Privat" },
              { value: "friends" as const, label: "Venner" },
              { value: "public" as const, label: "Offentlig" },
            ]
          ).map((opt) => (
            <button
              key={opt.value}
              type="button"
              onClick={() => setVisibilityMode(opt.value)}
              aria-pressed={visibilityMode === opt.value}
              className={cn(
                "h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
                visibilityMode === opt.value
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-border bg-background text-foreground hover:bg-accent/50",
              )}
            >
              {opt.label}
            </button>
          ))}
        </div>
        {visibilityMode === "friends" && (
          <div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
            <p className="text-xs font-semibold text-muted-foreground">
              Hvilke av dine venne-kategorier skal kunne se runden?
            </p>
            <div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
              <button
                type="button"
                onClick={() => setVisibleCategories([])}
                aria-pressed={visibleCategories.length === 0}
                className={cn(
                  "min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
                  visibleCategories.length === 0
                    ? "border-primary bg-primary text-primary-foreground"
                    : "border-border bg-card text-muted-foreground hover:bg-accent/50",
                )}
              >
                Ingen
              </button>
              <button
                type="button"
                onClick={() => setVisibleCategories(VISIBILITY_CATEGORY_OPTIONS.map((c) => c.code))}
                aria-pressed={visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length}
                className={cn(
                  "min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
                  visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length
                    ? "border-primary bg-primary text-primary-foreground"
                    : "border-border bg-card text-muted-foreground hover:bg-accent/50",
                )}
              >
                Alle
              </button>
            </div>
            <div className="flex flex-wrap gap-2">
              {VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
                const active = visibleCategories.includes(code)
                return (
                  <button
                    key={code}
                    type="button"
                    onClick={() =>
                      setVisibleCategories((prev) =>
                        active ? prev.filter((c) => c !== code) : [...prev, code],
                      )
                    }
                    aria-pressed={active}
                    className={cn(
                      "min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
                      active
                        ? "border-primary bg-primary text-primary-foreground"
                        : "border-border bg-card text-muted-foreground hover:bg-accent/50",
                    )}
                  >
                    {label}
                  </button>
                )
              })}
            </div>
            {visibleCategories.length === 0 && (
              <p className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-semibold text-destructive">
                Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
              </p>
            )}
          </div>
        )}
      </div>

      {/* Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B",
          ADR-067) -- kun relevant for spilleformatet Flaggturnering.
          Standard AV -- ingen ser hverandres flagg før eieren slår
          dette på (samme "skjult til avslørt"-prinsipp som Synlighet
          over). Spilleren ser alltid sitt eget flagg uansett. */}
      {playFormat === "flag" && (
        <div className="flex flex-col gap-2">
          <span className="text-sm font-semibold text-foreground">Kartoversikt over flagg</span>
          <div className="flex gap-2">
            {(
              [
                { value: false, label: "Av" },
                { value: true, label: "På" },
              ]
            ).map((opt) => (
              <button
                key={String(opt.value)}
                type="button"
                onClick={() => setFlagMapVisible(opt.value)}
                aria-pressed={flagMapVisible === opt.value}
                className={cn(
                  "h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
                  flagMapVisible === opt.value
                    ? "border-primary bg-primary text-primary-foreground"
                    : "border-border bg-background text-foreground hover:bg-accent/50",
                )}
              >
                {opt.label}
              </button>
            ))}
          </div>
          <p className="text-xs text-muted-foreground">
            Når kartoversikten er PÅ, kan alle spillere se hvor alle andre har plantet flagget. Av som standard.
          </p>
        </div>
      )}

      {!isCompleted && (
        <>
          <div className="flex flex-col gap-2">
            <span className="text-sm font-semibold text-foreground">Antall hull</span>
            <div className="flex gap-2">
              {([9, 18] as const).map((n) => (
                <button
                  key={n}
                  type="button"
                  onClick={() => setHolesPlanned(n)}
                  aria-pressed={holesPlanned === n}
                  className={cn(
                    "h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
                    holesPlanned === n
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-border bg-background text-foreground hover:bg-accent/50",
                  )}
                >
                  {n} hull
                </button>
              ))}
            </div>
          </div>

          <div className="flex flex-col gap-2">
            <Label htmlFor="edit-start-hole" className="text-sm font-semibold">
              Starthull
            </Label>
            <select
              id="edit-start-hole"
              value={startHole}
              onChange={(e) => setStartHole(e.target.value)}
              className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
            >
              {Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
                <option key={n} value={String(n)}>
                  Hull {n}
                </option>
              ))}
            </select>
          </div>
        </>
      )}

      <div className="flex flex-col gap-2">
        <Label htmlFor="edit-started-at" className="text-sm font-semibold">
          Utslagstid
        </Label>
        <Input
          id="edit-started-at"
          type="datetime-local"
          value={startedAt}
          onChange={(e) => setStartedAt(e.target.value)}
          className="h-11 rounded-xl text-base"
        />
      </div>

      {isCompleted && (
        <div className="flex flex-col gap-2">
          <Label htmlFor="edit-completed-at" className="text-sm font-semibold">
            Fullført-tidspunkt
          </Label>
          <Input
            id="edit-completed-at"
            type="datetime-local"
            value={completedAt}
            onChange={(e) => setCompletedAt(e.target.value)}
            className="h-11 rounded-xl text-base"
          />
        </div>
      )}

      {hasMetadataChanges && (
        <Button type="button" disabled={saving} onClick={saveMetadata} className="h-11 rounded-xl text-sm font-bold">
          {saving ? "Lagrer…" : "Lagre endringer"}
        </Button>
      )}

      {!isCompleted && (
        <div className="border-t border-border pt-4">
          {showChangeCourse ? (
            <ChangeCourseForm onPatch={onPatch} onCancel={() => setShowChangeCourse(false)} />
          ) : (
            <button
              type="button"
              onClick={() => setShowChangeCourse(true)}
              className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-primary"
            >
              <Search aria-hidden="true" className="size-4" />
              Bytt bane
            </button>
          )}
        </div>
      )}
    </div>
  )
}

// --- Rediger utslag+HCP for én deltaker, KUN for denne runden (2026-07-26) -

function EditParticipantPanel({
  roundId,
  player,
  restricted,
  onPatch,
  onClose,
}: {
  roundId: string
  player: Player
  // ADR-038 -- true når viewer IKKE har full redigeringsrett her (en
  // ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er
  // fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre
  // feltene under.
  restricted: boolean
  onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onClose: () => void
}) {
  // En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil
  // -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin
  // egen konto, og disse feltene gir ingen mening (backend avviser dem også
  // eksplisitt, 2026-07-26).
  const isGuest = player.userId === null

  const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null)
  const [teeName, setTeeName] = useState(player.teeName)
  const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
  const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
  const [guestFirstName, setGuestFirstName] = useState(player.guestFirstName ?? player.name)
  const [guestLastName, setGuestLastName] = useState(player.guestLastName ?? "")
  const [gender, setGender] = useState<Gender>(player.gender)
  const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
  const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    if (restricted) return
    let cancelled = false
    fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
      .then((res) => (res.ok ? res.json() : []))
      .then((data: ApiTeeOption[]) => {
        if (!cancelled) setTeeOptions(data)
      })
      .catch(() => {
        if (!cancelled) setTeeOptions([])
      })
    return () => {
      cancelled = true
    }
  }, [roundId, restricted])

  // Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da
  // umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
  // bruker er `gender` uendret gjennom hele panelets levetid.
  const apiGender = uiGenderToApi(gender)
  const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender))

  async function save() {
    setSaving(true)
    setError(null)
    const body: Record<string, unknown> = {}
    if (!restricted) {
      if (teeName !== player.teeName) body.tee_name = teeName
      const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
      const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
      if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
      if (statLevel !== player.statLevel) body.stat_level = statLevel
      if (isGuest) {
        const trimmedFirst = guestFirstName.trim()
        const trimmedLast = guestLastName.trim()
        if (trimmedFirst && trimmedFirst !== (player.guestFirstName ?? "")) body.guest_first_name = trimmedFirst
        const normalizedLast = trimmedLast === "" ? null : trimmedLast
        if (normalizedLast !== (player.guestLastName ?? null)) body.guest_last_name = normalizedLast
        if (gender !== player.gender) body.gender = apiGender
        const trimmedEmail = guestEmail.trim()
        const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
        if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail
      }
    }
    if (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) {
      body.exclude_from_handicap = excludeFromHandicap
    }
    if (Object.keys(body).length === 0) {
      setSaving(false)
      onClose()
      return
    }
    const result = await onPatch(player.id, body)
    setSaving(false)
    if (!result.ok) {
      setError(result.message)
      return
    }
    onClose()
  }

  return (
    <div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
      {error && <p className="text-sm font-medium text-destructive">{error}</p>}

      {restricted && (
        <p className="text-sm text-muted-foreground">
          Runden er fullført, eller du har ikke rett til å endre de andre feltene her -- du kan
          likevel endre om {player.isSelf ? "din egen deltakelse" : "spillerens deltakelse"} skal
          telle mot {player.isSelf ? "ditt" : "spillerens"} faktiske HCP.
        </p>
      )}

      {!restricted && (
        <>
          {isGuest && (
            <>
              <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
                <div className="flex flex-col gap-2">
                  <Label htmlFor={`edit-guest-first-name-${player.id}`} className="text-sm font-semibold">
                    Fornavn
                  </Label>
                  <Input
                    id={`edit-guest-first-name-${player.id}`}
                    value={guestFirstName}
                    onChange={(e) => setGuestFirstName(e.target.value)}
                    className="h-11 rounded-xl text-base"
                  />
                </div>
                <div className="flex flex-col gap-2">
                  <Label htmlFor={`edit-guest-last-name-${player.id}`} className="text-sm font-semibold">
                    Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
                  </Label>
                  <Input
                    id={`edit-guest-last-name-${player.id}`}
                    value={guestLastName}
                    onChange={(e) => setGuestLastName(e.target.value)}
                    className="h-11 rounded-xl text-base"
                  />
                </div>
              </div>

              <ChoiceRow
                label="Kjønn"
                options={[
                  { value: "male", label: "Mann" },
                  { value: "female", label: "Kvinne" },
                  { value: "other", label: "Annet" },
                ]}
                value={gender}
                onChange={(v) => setGender(v as Gender)}
                readOnly={false}
              />

              <div className="flex flex-col gap-2">
                <Label htmlFor={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
                  E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
                </Label>
                <Input
                  id={`edit-guest-email-${player.id}`}
                  type="email"
                  value={guestEmail}
                  onChange={(e) => setGuestEmail(e.target.value)}
                  placeholder="navn@eksempel.no"
                  className="h-11 rounded-xl text-base"
                />
              </div>

              <div className="border-t border-border" />
            </>
          )}

          <p className="text-sm text-muted-foreground">
            Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
          </p>

          <div className="flex flex-col gap-2">
            <Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold">
              Utslagssted
            </Label>
            <select
              id={`edit-tee-${player.id}`}
              value={teeName}
              onChange={(e) => setTeeName(e.target.value)}
              disabled={teeOptions === null}
              className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60"
            >
              {!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>}
              {availableTees.map((t) => (
                <option key={t.name} value={t.name}>
                  {t.name}
                </option>
              ))}
            </select>
            {teeOptions !== null && availableTees.length === 0 && (
              <p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet på denne banen.</p>
            )}
          </div>

          <div className="flex flex-col gap-2">
            <Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
              HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
            </Label>
            <Input
              id={`edit-hcp-${player.id}`}
              // type="text" (ikke "number") -- se guest-hcp over, samme
              // begrunnelse. 2026-08-19.
              inputMode="decimal"
              value={hcp}
              onChange={(e) => setHcp(e.target.value)}
              placeholder="F.eks. 18"
              className="h-12 rounded-2xl text-base"
            />
          </div>

          <ChoiceRow
            label="Statistikk for denne spilleren"
            options={[
              { value: "strokes_only", label: "Kun slag" },
              { value: "strokes_and_putts", label: "Slag og putter" },
              { value: "full", label: "All statistikk" },
            ]}
            value={statLevel}
            onChange={(v) => setStatLevel(v as StatLevel)}
            readOnly={false}
          />
        </>
      )}

      {!isGuest && (
        <label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-card px-4 py-3">
          <input
            type="checkbox"
            checked={excludeFromHandicap}
            onChange={(e) => setExcludeFromHandicap(e.target.checked)}
            className="mt-1 size-5 shrink-0 accent-primary"
          />
          <span className="flex flex-col gap-0.5">
            <span className="text-sm font-bold text-foreground">
              Ekskluder fra {player.isSelf ? "mitt" : "spillerens"} faktiske HCP
            </span>
            <span className="text-sm text-muted-foreground">
              F.eks. ved matchspill, eller andre runder som ikke bør telle med i HCP-grunnlaget.
            </span>
          </span>
        </label>
      )}

      <div className="flex justify-end gap-2 pt-1">
        <Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold">
          Avbryt
        </Button>
        <Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl px-5 text-sm font-bold">
          {saving ? "Lagrer…" : "Lagre endringer"}
        </Button>
      </div>
    </div>
  )
}

function ChangeCourseForm({
  onPatch,
  onCancel,
}: {
  onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onCancel: () => void
}) {
  const [source, setSource] = useState<"teeoff" | "custom" | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [submitting, setSubmitting] = useState(false)

  // Teeoff-søk
  const [query, setQuery] = useState("")
  const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
  const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
  const [officialCourses, setOfficialCourses] = useState<ApiOfficialCourseOption[] | null>(null)
  const [selectedOfficialCourse, setSelectedOfficialCourse] = useState<ApiOfficialCourseOption | null>(null)

  // Egen bane-søk
  const [customQuery, setCustomQuery] = useState("")
  const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
  const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)

  // GolfAPI-søk (ADR-064) -- for baner utenfor teeoffs dekning. Importerte
  // baner havner i den samme personal_course-katalogen som "egen bane"
  // over, så selve valg-/utslag-/submit-flyten under trenger INGEN endring
  // -- kun et alternativt inngangspunkt til `selectedCustomCourse`.
  const [internationalOpen, setInternationalOpen] = useState(false)
  const [internationalQuery, setInternationalQuery] = useState("")
  const [internationalClubs, setInternationalClubs] = useState<ApiInternationalClub[] | null>(null)
  const [selectedInternationalClub, setSelectedInternationalClub] = useState<ApiInternationalClub | null>(null)
  const [internationalImporting, setInternationalImporting] = useState(false)

  const [teeName, setTeeName] = useState("")

  async function searchInternational() {
    if (!internationalQuery.trim()) return
    setError(null)
    try {
      const res = await fetch(`/personal-courses/international-search?q=${encodeURIComponent(internationalQuery.trim())}`, { credentials: "include" })
      if (!res.ok) throw new Error()
      setInternationalClubs(await res.json())
    } catch {
      setError("Klarte ikke å søke hos GolfAPI akkurat nå.")
    }
  }

  async function importInternational(course: ApiInternationalCourseOption) {
    setInternationalImporting(true)
    setError(null)
    try {
      const importRes = await fetch(`/personal-courses/international-import`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({ golfapi_course_id: course.course_id }),
      })
      if (!importRes.ok) throw new Error()
      const imported: { id: string } = await importRes.json()
      const detailRes = await fetch(`/personal-courses/${imported.id}`, { credentials: "include" })
      if (!detailRes.ok) throw new Error()
      setSelectedCustomCourse(await detailRes.json())
    } catch {
      setError("Klarte ikke å importere banen fra GolfAPI. Prøv igjen.")
    } finally {
      setInternationalImporting(false)
    }
  }

  useEffect(() => {
    if (source !== "custom") return
    let cancelled = false
    const timer = setTimeout(async () => {
      try {
        const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" })
        if (res.ok && !cancelled) setCustomResults(await res.json())
      } catch {
        // Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
      }
    }, 250)
    return () => {
      cancelled = true
      clearTimeout(timer)
    }
  }, [source, customQuery])

  async function searchFacilities() {
    setError(null)
    try {
      const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
      if (!res.ok) throw new Error()
      setFacilities(await res.json())
    } catch {
      setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
    }
  }

  async function pickFacility(facility: ApiFacility) {
    setSelectedFacility(facility)
    setError(null)
    try {
      const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
      if (!res.ok) throw new Error()
      const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
      setOfficialCourses(detail.courses)
    } catch {
      setError("Klarte ikke å hente baner for dette anlegget.")
    }
  }

  async function pickCustomCourse(course: ApiPersonalCourseSummary) {
    setError(null)
    try {
      const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
      if (!res.ok) throw new Error()
      setSelectedCustomCourse(await res.json())
    } catch {
      setError("Klarte ikke å hente banedetaljer.")
    }
  }

  const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? []

  async function handleSubmit() {
    if (!teeName) return
    setSubmitting(true)
    setError(null)
    const body =
      source === "teeoff" && selectedFacility && selectedOfficialCourse
        ? {
            course_source: "teeoff",
            teeoff_facility_slug: selectedFacility.slug,
            teeoff_course_id: selectedOfficialCourse.teeoff_course_id,
            tee_name: teeName,
          }
        : selectedCustomCourse
          ? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName }
          : null
    if (!body) {
      setSubmitting(false)
      return
    }
    const result = await onPatch(body)
    setSubmitting(false)
    if (!result.ok) {
      setError(result.message)
      return
    }
    onCancel()
  }

  return (
    <div className="flex flex-col gap-3">
      <p className="text-xs leading-relaxed text-muted-foreground text-pretty">
        Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.
      </p>
      {error && <p className="text-sm font-medium text-destructive">{error}</p>}

      {source === null && (
        <div className="grid grid-cols-2 gap-2">
          <Button type="button" variant="outline" onClick={() => setSource("teeoff")} className="h-11 rounded-xl text-sm font-bold">
            Offisiell bane
          </Button>
          <Button type="button" variant="outline" onClick={() => setSource("custom")} className="h-11 rounded-xl text-sm font-bold">
            Egen bane
          </Button>
        </div>
      )}

      {source === "teeoff" && !selectedFacility && (
        <div className="flex flex-col gap-2">
          <div className="flex gap-2">
            <Input
              autoFocus
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") {
                  e.preventDefault()
                  searchFacilities()
                }
              }}
              placeholder="Søk klubbnavn…"
              className="h-11 flex-1 rounded-xl text-base"
            />
            <Button type="button" onClick={searchFacilities} className="h-11 shrink-0 rounded-xl text-sm font-bold">
              Søk
            </Button>
          </div>
          {facilities && (
            <ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
              {facilities.map((f) => (
                <li key={f.slug} className="border-b border-border last:border-b-0">
                  <button
                    type="button"
                    onClick={() => pickFacility(f)}
                    className="flex min-h-11 w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
                  >
                    <span className="text-sm font-semibold text-foreground">{f.name}</span>
                    {(f.city || f.county) && (
                      <span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
                    )}
                  </button>
                </li>
              ))}
              {facilities.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
            </ul>
          )}
        </div>
      )}

      {source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
        <div className="flex flex-col gap-2">
          <button
            type="button"
            onClick={() => setSelectedFacility(null)}
            className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground"
          >
            ← {selectedFacility.name}
          </button>
          {officialCourses === null ? (
            <p className="text-sm text-muted-foreground">Laster baner…</p>
          ) : (
            <ul className="flex flex-col overflow-hidden rounded-xl border border-border">
              {officialCourses.map((c) => (
                <li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
                  <button
                    type="button"
                    onClick={() => setSelectedOfficialCourse(c)}
                    className="flex min-h-11 w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
                  >
                    {c.name}
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}

      {source === "custom" && !selectedCustomCourse && !internationalOpen && (
        <div className="flex flex-col gap-2">
          <Input
            autoFocus
            value={customQuery}
            onChange={(e) => setCustomQuery(e.target.value)}
            placeholder="Søk egendefinert bane…"
            className="h-11 rounded-xl text-base"
          />
          <ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
            {customResults.map((c) => (
              <li key={c.id} className="border-b border-border last:border-b-0">
                <button
                  type="button"
                  onClick={() => pickCustomCourse(c)}
                  className="flex min-h-11 w-full items-center px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
                >
                  {c.name}
                </button>
              </li>
            ))}
            {customResults.length === 0 && (
              <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
            )}
          </ul>
          <button
            type="button"
            onClick={() => setInternationalOpen(true)}
            className="flex min-h-11 items-center self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
          >
            Fant ikke banen? Søk internasjonalt (GolfAPI)
          </button>
        </div>
      )}

      {source === "custom" && !selectedCustomCourse && internationalOpen && (
        <div className="flex flex-col gap-2">
          <button
            type="button"
            onClick={() => {
              setInternationalOpen(false)
              setSelectedInternationalClub(null)
              setInternationalClubs(null)
            }}
            className="flex min-h-11 items-center self-start text-sm font-semibold text-muted-foreground"
          >
            ← Egen bane-søk
          </button>
          {!selectedInternationalClub ? (
            <>
              <div className="flex gap-2">
                <Input
                  autoFocus
                  value={internationalQuery}
                  onChange={(e) => setInternationalQuery(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      e.preventDefault()
                      searchInternational()
                    }
                  }}
                  placeholder="Søk klubbnavn…"
                  className="h-11 flex-1 rounded-xl text-base"
                />
                <Button type="button" onClick={searchInternational} className="h-11 shrink-0 rounded-xl text-sm font-bold">
                  Søk
                </Button>
              </div>
              {internationalClubs && (
                <ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
                  {internationalClubs.map((c) => (
                    <li key={c.club_id} className="border-b border-border last:border-b-0">
                      <button
                        type="button"
                        onClick={() => setSelectedInternationalClub(c)}
                        className="flex min-h-11 w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
                      >
                        <span className="text-sm font-semibold text-foreground">{c.club_name}</span>
                        {(c.city || c.country) && (
                          <span className="text-xs text-muted-foreground">{[c.city, c.country].filter(Boolean).join(", ")}</span>
                        )}
                      </button>
                    </li>
                  ))}
                  {internationalClubs.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
                </ul>
              )}
            </>
          ) : (
            <ul className="flex flex-col overflow-hidden rounded-xl border border-border">
              {selectedInternationalClub.courses.map((c) => (
                <li key={c.course_id} className="border-b border-border last:border-b-0">
                  <button
                    type="button"
                    disabled={internationalImporting}
                    onClick={() => importInternational(c)}
                    className="flex min-h-11 w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60 disabled:opacity-50"
                  >
                    <span className="flex flex-col">
                      {c.course_name}
                      <span className="text-xs font-normal text-muted-foreground">
                        {c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
                      </span>
                    </span>
                    {internationalImporting && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      )}

      {(selectedOfficialCourse || selectedCustomCourse) && (
        <div className="flex flex-col gap-3">
          <span className="text-sm font-bold text-foreground">
            {selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
          </span>
          <div className="flex flex-col gap-2">
            <span className="text-sm font-semibold text-foreground">Utslag</span>
            <div className="flex flex-wrap gap-2">
              {availableTees.map((t) => (
                <button
                  key={t.name}
                  type="button"
                  onClick={() => setTeeName(t.name)}
                  aria-pressed={teeName === t.name}
                  className={cn(
                    "h-11 rounded-lg border px-3 text-sm font-semibold transition-colors",
                    teeName === t.name
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-border bg-background text-foreground hover:bg-accent/50",
                  )}
                >
                  {t.name}
                </button>
              ))}
            </div>
          </div>
          <Button type="button" disabled={submitting || !teeName} onClick={handleSubmit} className="h-11 rounded-xl text-sm font-bold">
            {submitting ? "Bytter bane…" : "Bytt bane"}
          </Button>
        </div>
      )}

      <Button type="button" variant="ghost" onClick={onCancel} className="h-11 self-start rounded-xl text-sm font-semibold text-muted-foreground">
        Avbryt
      </Button>
    </div>
  )
}

// --- Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, 2026-07-27) ---
// Spillere = rader (sticky venstre navnekolonne), hull = horisontalt
// scrollbare kolonner (sticky Ut/Inn/Sum til høyre) -- erstatter den
// forrige ett-hull-om-gangen-listen. Tapp en kolonneoverskrift for å velge
// "gjeldende" hull (kun navigasjon), tapp en spillers navnecelle eller en
// score-celle for å åpne `ScoringWizard` for AKKURAT den (spiller, hull)-
// kombinasjonen -- samme veiviser som før, bare adressert direkte fra
// gridet i stedet for via ett enkelt "aktivt hull"-panel.
// Par/stroke-indeks er samme fysiske hull for alle spillere -- hentes fra
// HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv
// spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn.

// Sorteringsnøkkel for lagvis gruppering (2026-08-02) -- side A (sides[0])
// først, side B (sides[1]) deretter, ingen/ukjent side sist. Brukt med en
// STABIL sort, så original rekkefølge innad i hver gruppe bevares.
function sideRank(roundSideId: string | null, sides: ApiSide[]): number {
  if (roundSideId === sides[0]?.id) return 0
  if (roundSideId === sides[1]?.id) return 1
  return 2
}

// ADR-065 -- delt mellom PlayerHoleCards (Score-fanen) og PlayerList
// ("Spillere og runde"-fanen), samme handlinger begge steder. "Avslutt
// for [navn]"/"Angre" er FLIGHT-STYRT (patchParticipant, ikke eier-only
// -- se update_participant i rounds.py), skjules når runden selv er
// fullført (readOnly) siden backend uansett avviser med ALREADY_COMPLETED
// da. "Send scorekort" fungerer derimot UANSETT fullføringsstatus.
function ParticipantCompletionActions({
  player,
  holesPlayed,
  readOnly,
  onPatch,
  onSendScorecard,
}: {
  player: Player
  holesPlayed: number
  readOnly: boolean
  onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [busy, setBusy] = useState(false)
  const [busyError, setBusyError] = useState<string | null>(null)
  const [sendState, setSendState] = useState<"idle" | "sending" | "sent" | "error">("idle")
  const [sendError, setSendError] = useState<string | null>(null)

  async function toggleCompleted(next: boolean) {
    setBusy(true)
    setBusyError(null)
    const result = await onPatch(player.id, { completed: next })
    setBusy(false)
    if (!result.ok) setBusyError(result.message)
  }

  async function handleSend() {
    setSendState("sending")
    setSendError(null)
    const result = await onSendScorecard(player.id)
    setSendState(result.ok ? "sent" : "error")
    if (!result.ok) setSendError(result.message)
  }

  return (
    <div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
      {player.completed ? (
        <>
          <span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
            <Check aria-hidden="true" className="size-4" />
            Ferdig{holesPlayed > 0 ? ` · ${holesPlayed} hull` : ""}
          </span>
          {!readOnly && (
            <button
              type="button"
              disabled={busy}
              onClick={() => toggleCompleted(false)}
              className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
            >
              Angre
            </button>
          )}
        </>
      ) : (
        !readOnly && (
          <button
            type="button"
            disabled={busy}
            onClick={() => toggleCompleted(true)}
            className="flex min-h-11 items-center justify-center rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
          >
            Avslutt for {player.name}
          </button>
        )
      )}
      <button
        type="button"
        disabled={sendState === "sending"}
        onClick={handleSend}
        className="ml-auto flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-60"
      >
        {sendState === "sending" ? "Sender…" : sendState === "sent" ? "Sendt ✓" : "Send scorekort"}
      </button>
      {busyError && (
        <p role="alert" className="w-full text-sm font-medium text-destructive">
          {busyError}
        </p>
      )}
      {sendState === "error" && sendError && (
        <p role="alert" className="w-full text-sm font-medium text-destructive">
          {sendError}
        </p>
      )}
    </div>
  )
}

// ADR-066-flagg-tillegget (2026-08-14) -- V0-eksport (zip 11), integrert
// mot samme props-kontrakt som den midlertidige versjonen den erstatter.
// Ren UI: komponenten eier kun steg-/geo-/inputtilstand og gjør aldri
// nettverkskall selv -- kalleren gir `onConfirm`, som gjør det faktiske
// API-kallet og kan AVVISE forsøket (f.eks. hvis registrert scoredata
// sier spilleren fortsatt er på et annet hull). Avvisning vises som en
// vedvarende inline-feilmelding, ikke en forsvinnende toast.
function FlagPlantSheet({
  open,
  onClose,
  expectedHoleNumber,
  expectedLap,
  onConfirm,
}: {
  open: boolean
  onClose: () => void
  expectedHoleNumber: number
  expectedLap: number
  onConfirm: (result: {
    lat: number
    lng: number
    onGreen: boolean
    distanceToPinCm: number | null
  }) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps")
  const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading")
  const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null)

  // Step 2 "Ja" reveals a dead-end guidance card (no submission).
  const [holedOutYes, setHoledOutYes] = useState(false)

  // Step 3 answer + distance inputs (kept as strings for clean input UX).
  const [onGreen, setOnGreen] = useState<boolean | null>(null)
  const [meters, setMeters] = useState("")
  const [centimeters, setCentimeters] = useState("")

  const [submitting, setSubmitting] = useState(false)
  const [serverError, setServerError] = useState<string | null>(null)
  const [confirmedSummary, setConfirmedSummary] = useState<{
    onGreen: boolean
    distanceToPinCm: number | null
  } | null>(null)

  // Reset everything each time the sheet is (re)opened.
  useEffect(() => {
    if (!open) return
    setStep("gps")
    setGeoStatus("loading")
    setPosition(null)
    setHoledOutYes(false)
    setOnGreen(null)
    setMeters("")
    setCentimeters("")
    setSubmitting(false)
    setServerError(null)
    setConfirmedSummary(null)
  }, [open])

  // Step 1 — automatic GPS acquisition. Runs on entering the gps step.
  const geoRequested = useRef(false)
  useEffect(() => {
    if (!open || step !== "gps") {
      geoRequested.current = false
      return
    }
    if (geoRequested.current) return
    geoRequested.current = true

    if (typeof navigator === "undefined" || !navigator.geolocation) {
      setGeoStatus("error")
      return
    }
    setGeoStatus("loading")
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
        setStep("holedOut")
      },
      () => setGeoStatus("error"),
      { enableHighAccuracy: true, timeout: 10000 },
    )
  }, [open, step])

  function retryGeo() {
    geoRequested.current = false
    setGeoStatus("loading")
    if (typeof navigator === "undefined" || !navigator.geolocation) {
      setGeoStatus("error")
      return
    }
    geoRequested.current = true
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
        setStep("holedOut")
      },
      () => setGeoStatus("error"),
      { enableHighAccuracy: true, timeout: 10000 },
    )
  }

  if (!open) return null

  // ---- validation for the on-green distance --------------------------------
  const metersNum = meters === "" ? null : Number(meters)
  const cmNum = centimeters === "" ? null : Number(centimeters)
  const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0
  const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99
  const greenInputValid = metersValid && cmValid

  const canConfirm = onGreen === false || (onGreen === true && greenInputValid)

  async function handleConfirm() {
    if (!position || onGreen === null || submitting) return
    const distanceToPinCm =
      onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null

    setSubmitting(true)
    setServerError(null)
    try {
      const res = await onConfirm({
        lat: position.lat,
        lng: position.lng,
        onGreen,
        distanceToPinCm,
      })
      if (res.ok) {
        setConfirmedSummary({ onGreen, distanceToPinCm })
        setStep("done")
      } else {
        setServerError(res.message)
      }
    } catch {
      setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.")
    } finally {
      setSubmitting(false)
    }
  }

  function goBack() {
    setServerError(null)
    if (step === "green") {
      setOnGreen(null)
      setMeters("")
      setCentimeters("")
      setStep("holedOut")
    }
  }

  const showBack = step === "green"

  return (
    <div
      className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
      role="dialog"
      aria-modal="true"
      aria-label={`Plant flagget, hull ${expectedHoleNumber}`}
    >
      {/* Header */}
      <header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
        <div className="flex items-start gap-2">
          {showBack ? (
            <button
              type="button"
              onClick={goBack}
              className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
              Tilbake
            </button>
          ) : null}
          <div className="flex items-start gap-2 pt-1.5">
            <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
              <Flag className="size-5" aria-hidden="true" />
            </span>
            <div>
              <h2 className="text-lg font-bold leading-tight text-balance">Plant flagget</h2>
              <p className="mt-0.5 text-sm text-muted-foreground">
                {`Hull ${expectedHoleNumber} · runde ${expectedLap}`}
              </p>
            </div>
          </div>
        </div>

        <button
          type="button"
          onClick={onClose}
          className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <X className="size-5 shrink-0" aria-hidden="true" />
          Lukk
        </button>
      </header>

      {/* Main */}
      <main className="flex-1 overflow-y-auto p-4">
        {/* Step 1 — GPS */}
        {step === "gps" ? (
          <div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
            {geoStatus === "loading" ? (
              <>
                <div className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
                <p className="text-lg font-semibold">Finner posisjonen din …</p>
                <p className="text-base text-muted-foreground text-pretty">
                  Stå der du gikk tom for slag mens vi henter GPS-posisjonen.
                </p>
              </>
            ) : (
              <div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left">
                <p role="alert" className="text-base text-destructive text-pretty">
                  Fant ikke posisjonen din. Sjekk at posisjonstjenester er på for
                  TeeCup, og prøv igjen.
                </p>
                <button
                  type="button"
                  onClick={retryGeo}
                  className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Crosshair className="size-5 shrink-0" aria-hidden="true" />
                  Prøv igjen
                </button>
              </div>
            )}
          </div>
        ) : null}

        {/* Step 2 — Holed out on this hole? */}
        {step === "holedOut" ? (
          <div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
            <h3 className="text-2xl font-bold text-balance">
              {`Hullet du ut på hull ${expectedHoleNumber}?`}
            </h3>
            <p className="text-base text-muted-foreground text-pretty">
              Er hullet fullført, må scoren føres inn før du kan plante flagget.
            </p>

            <div className="grid grid-cols-2 gap-3">
              <button
                type="button"
                onClick={() => setHoledOutYes(true)}
                aria-pressed={holedOutYes}
                className={cn(
                  "inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  holedOutYes
                    ? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
                    : "border-border bg-card text-foreground hover:bg-muted",
                )}
              >
                Ja
              </button>
              <button
                type="button"
                onClick={() => {
                  setHoledOutYes(false)
                  setStep("green")
                }}
                className="inline-flex min-h-14 items-center justify-center rounded-xl border-2 border-border bg-card px-6 text-lg font-bold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                Nei
              </button>
            </div>

            {/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */}
            {holedOutYes ? (
              <div className="flex flex-col gap-4 rounded-2xl border border-brand-orange/40 bg-brand-orange/5 p-4">
                <p role="alert" className="text-base text-foreground text-pretty">
                  {`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`}
                </p>
                <button
                  type="button"
                  onClick={onClose}
                  className="inline-flex min-h-14 items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  Lukk
                </button>
              </div>
            ) : null}
          </div>
        ) : null}

        {/* Step 3 — On the green? + distance */}
        {step === "green" ? (
          <div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
            <h3 className="text-2xl font-bold text-balance">Landet du på green?</h3>

            <div className="grid grid-cols-2 gap-3">
              <button
                type="button"
                onClick={() => setOnGreen(true)}
                aria-pressed={onGreen === true}
                className={cn(
                  "inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  onGreen === true
                    ? "border-primary bg-primary text-primary-foreground"
                    : "border-border bg-card text-foreground hover:bg-muted",
                )}
              >
                Ja
              </button>
              <button
                type="button"
                onClick={() => {
                  setOnGreen(false)
                  setMeters("")
                  setCentimeters("")
                }}
                aria-pressed={onGreen === false}
                className={cn(
                  "inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  onGreen === false
                    ? "border-primary bg-primary text-primary-foreground"
                    : "border-border bg-card text-foreground hover:bg-muted",
                )}
              >
                Nei
              </button>
            </div>

            {onGreen === true ? (
              <div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
                <p className="text-sm text-muted-foreground text-pretty">
                  Dette avgjør rekkefølgen mot andre spillere som gikk tom på
                  samme hull.
                </p>
                <div className="grid grid-cols-2 gap-3">
                  <div className="flex flex-col gap-1.5">
                    <label htmlFor="fp-meters" className="text-base font-semibold">
                      Meter
                    </label>
                    <input
                      id="fp-meters"
                      inputMode="numeric"
                      pattern="[0-9]*"
                      value={meters}
                      onChange={(e) => setMeters(e.target.value.replace(/[^0-9]/g, ""))}
                      aria-invalid={meters !== "" && !metersValid}
                      className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      placeholder="0"
                    />
                  </div>
                  <div className="flex flex-col gap-1.5">
                    <label htmlFor="fp-cm" className="text-base font-semibold">
                      Centimeter
                    </label>
                    <input
                      id="fp-cm"
                      inputMode="numeric"
                      pattern="[0-9]*"
                      value={centimeters}
                      onChange={(e) => setCentimeters(e.target.value.replace(/[^0-9]/g, ""))}
                      aria-invalid={centimeters !== "" && !cmValid}
                      className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      placeholder="0"
                    />
                  </div>
                </div>
                {centimeters !== "" && !cmValid ? (
                  <p role="alert" className="text-sm font-medium text-destructive">
                    Centimeter må være mellom 0 og 99.
                  </p>
                ) : null}
              </div>
            ) : null}

            {serverError ? (
              <p
                role="alert"
                className="rounded-xl border border-destructive/40 bg-destructive/10 p-3 text-base font-medium text-destructive text-pretty"
              >
                {serverError}
              </p>
            ) : null}
          </div>
        ) : null}

        {/* Step 4 — Success */}
        {step === "done" && confirmedSummary ? (
          <div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
            <span className="flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary">
              <Check className="size-11" strokeWidth={2.5} aria-hidden="true" />
            </span>
            <h3 className="text-2xl font-bold">Flagget er plantet!</h3>
            <div className="flex flex-col items-center gap-1">
              <span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-base font-semibold">
                <MapPin className="size-4 shrink-0" aria-hidden="true" />
                {`Hull ${expectedHoleNumber}`}
              </span>
              {confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? (
                <span className="text-base text-muted-foreground tabular-nums">
                  {`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${
                    confirmedSummary.distanceToPinCm % 100
                  } cm fra hullet`}
                </span>
              ) : null}
            </div>
          </div>
        ) : null}
      </main>

      {/* Footer — contextual primary action */}
      {step === "green" ? (
        <footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
          <button
            type="button"
            disabled={!canConfirm || submitting}
            onClick={handleConfirm}
            className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
          >
            {submitting ? (
              <>
                <span className="size-5 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" />
                Sender …
              </>
            ) : (
              <>
                <Flag className="size-5 shrink-0" aria-hidden="true" />
                Bekreft plassering
              </>
            )}
          </button>
        </footer>
      ) : null}

      {step === "done" ? (
        <footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
          <button
            type="button"
            onClick={onClose}
            className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            Ferdig
          </button>
        </footer>
      ) : null}
    </div>
  )
}

// Flaggturnering: viser plantet-status + "Plant flagget"-knapp for én
// spiller (2026-08-14). Samme visuelle rad-mønster som
// ParticipantCompletionActions over.
function FlagStatusRow({
  player,
  plant,
  currentPosition,
  readOnly,
  onOpenSheet,
  onUnplant,
}: {
  player: Player
  plant: ApiFlagPlant | null | undefined
  currentPosition: { lap: number; holeNumber: number }
  readOnly: boolean
  onOpenSheet: () => void
  onUnplant: () => Promise<void>
}) {
  const [busy, setBusy] = useState(false)
  async function handleUnplant() {
    setBusy(true)
    await onUnplant()
    setBusy(false)
  }
  return (
    <div className="flex flex-wrap items-center gap-2 border-t border-border px-4 py-2.5">
      {plant ? (
        <>
          <span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
            <Flag aria-hidden="true" className="size-4" />
            Flagg plantet · hull {plant.hole_number}
            {plant.lap > 1 ? ` (runde ${plant.lap})` : ""}
            {plant.on_green && plant.distance_to_pin_cm !== null
              ? ` · ${Math.floor(plant.distance_to_pin_cm / 100)}m ${plant.distance_to_pin_cm % 100}cm fra hullet`
              : ""}
          </span>
          {!readOnly && (
            <button
              type="button"
              disabled={busy}
              onClick={handleUnplant}
              className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 transition-colors hover:text-foreground disabled:opacity-60"
            >
              Angre
            </button>
          )}
        </>
      ) : (
        !readOnly && (
          <button
            type="button"
            onClick={onOpenSheet}
            className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
          >
            <Flag aria-hidden="true" className="size-4" />
            Plant flagget for {player.name}
          </button>
        )
      )}
    </div>
  )
}

// Runde 2+ scoreføring (2026-08-14) -- vises kun når spilleren faktisk har
// fullført lap 1 (currentPosition.lap > 1) og mangler ikke bygd
// ScoringWizard-flyt for dette -- flaggformatet er allerede kun
// hull-granularitet (rå slagtall), en enkel tallinput-liste er
// proporsjonal, ikke en full ny veiviser-flyt.
function FlagOverflowPanel({
  player,
  holeOrder,
  overflowHoles,
  currentPosition,
  readOnly,
  onSubmitHole,
}: {
  player: Player
  holeOrder: number[]
  overflowHoles: ApiFlagOverflowHole[]
  currentPosition: { lap: number; holeNumber: number }
  readOnly: boolean
  onSubmitHole: (lap: number, holeNumber: number, score: number) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [draft, setDraft] = useState<Record<number, string>>({})
  const [error, setError] = useState<string | null>(null)
  if (currentPosition.lap < 2 && overflowHoles.length === 0) return null

  const lap = currentPosition.lap
  const byHole = new Map(overflowHoles.filter((h) => h.lap === lap).map((h) => [h.hole_number, h]))

  async function submit(holeNumber: number) {
    const raw = draft[holeNumber]
    const score = parseInt(raw ?? "", 10)
    if (!raw || Number.isNaN(score) || score <= 0) return
    setError(null)
    const result = await onSubmitHole(lap, holeNumber, score)
    if (!result.ok) setError(result.message)
    else setDraft((prev) => ({ ...prev, [holeNumber]: "" }))
  }

  return (
    <div className="flex flex-col gap-2 border-t border-border px-4 py-3">
      <span className="text-sm font-bold text-foreground">
        {player.name} spiller runde {lap} -- før inn slag per hull
      </span>
      <div className="flex flex-wrap gap-2">
        {holeOrder.map((h) => {
          const existing = byHole.get(h)
          return (
            <div key={h} className="flex items-center gap-1.5">
              <span className="text-xs font-semibold text-muted-foreground">Hull {h}</span>
              {existing?.played ? (
                <span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary tabular-nums">
                  {existing.score}
                </span>
              ) : !readOnly ? (
                <Input
                  type="number"
                  inputMode="numeric"
                  min={1}
                  max={20}
                  value={draft[h] ?? ""}
                  onChange={(e) => setDraft((prev) => ({ ...prev, [h]: e.target.value }))}
                  onBlur={() => submit(h)}
                  className="h-9 w-14 text-center"
                />
              ) : (
                <span className="text-sm text-muted-foreground">–</span>
              )}
            </div>
          )
        })}
      </div>
      {error && <p role="alert" className="text-sm font-medium text-destructive">{error}</p>}
    </div>
  )
}

function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes: number; par: number } | null {
  if (!list || numbers.length === 0) return null
  const played = numbers
    .map((n) => list.find((h) => h.hole_number === n))
    .filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
  if (played.length === 0) return null
  return {
    strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
    par: played.reduce((sum, h) => sum + h.par, 0),
  }
}

// Hull-fokusert kortvisning for Score-fanen (2026-08-03, V0-eksport zip 27
// portert manuelt mot ekte data -- se teecup-scorekort-og-entry-spec.md §1
// og brukerens eksplisitte "gi V0 friere spillerom"-tilbakemelding). Viser
// KUN aktivt hull, spillere som stablede kort i stedet for et bredt grid --
// løser at ScorecardGrid tar for stor plass i bredden på mobil. INGEN
// informasjon fjernes: alt som var synlig i gridet finnes fortsatt her
// (HCP/Ut/Inn/Sum/netto/mottatte slag/vunnet hull/ikke-tellende partner),
// resten (alle 18 hull samtidig) er ScorecardGrid selv, uendret, bak en
// "Vis hele scorekortet"-bryter (se render-stedet i hoved-komponenten).
// Trykk åpner SAMME eksisterende fullskjerm-veiviser som før (onOpenEntry)
// -- selve registreringsvinduet er bevisst IKKE rørt av denne rundens
// V0-prompt.
function PlayerHoleCards({
  roundId,
  players,
  holesByParticipant,
  holeOrder,
  holesPlanned,
  currentHole,
  readOnly,
  sides,
  isTwoSided,
  formatResult,
  ownBagClubs,
  onSelectHole,
  onPrevHole,
  onNextHole,
  onOpenEntry,
  onPatchParticipant,
  onSendScorecard,
  playFormat,
  flagPlantByParticipant,
  flagOverflowByParticipant,
  onPlantFlag,
  onUnplantFlag,
  onSubmitFlagOverflowHole,
}: {
  roundId: string
  players: Player[]
  holesByParticipant: Record<string, ApiHole[]>
  holeOrder: number[]
  holesPlanned: number
  currentHole: number
  readOnly: boolean
  sides: ApiSide[]
  isTwoSided: boolean
  formatResult: ApiFormatResult | null
  ownBagClubs: string[]
  onSelectHole: (holeNumber: number) => void
  onPrevHole: () => void
  onNextHole: () => void
  onOpenEntry: (playerId: string, holeNumber: number) => void
  onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
  onSendScorecard: (participantId: string) => Promise<{ ok: true } | { ok: false; message: string }>
  playFormat: string
  flagPlantByParticipant: Record<string, ApiFlagPlant | null>
  flagOverflowByParticipant: Record<string, ApiFlagOverflowHole[]>
  onPlantFlag: (
    participantId: string,
    body: { lat: number; lng: number; hole_number: number; lap: number; on_green: boolean; distance_to_pin_cm: number | null },
  ) => Promise<{ ok: true } | { ok: false; message: string }>
  onUnplantFlag: (participantId: string) => Promise<void>
  onSubmitFlagOverflowHole: (
    participantId: string, lap: number, holeNumber: number, score: number,
  ) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
  const [flagSheetParticipantId, setFlagSheetParticipantId] = useState<string | null>(null)
  // ADR-079 (2026-08-16): hvilken spillers fulle hull-historikk som vises
  // akkurat nå, om noen -- historikk-oppsummeringen (HoleHistoryPanel)
  // sitter her i kortet (skjermen FØR slagvinduet), åpner denne fullskjerm-
  // visningen ved klikk i stedet for å ekspandere inline.
  const [historyPlayerId, setHistoryPlayerId] = useState<string | null>(null)
  const holeMeta: Record<number, { par: number; index: number }> = {}
  for (const list of Object.values(holesByParticipant)) {
    for (const h of list) {
      if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
    }
  }
  const meta = holeMeta[currentHole]

  const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
  const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
  const formatHole = showMatchColors ? formatHolesByNumber.get(currentHole) : undefined
  function sideOf(playerId: string): "a" | "b" | null {
    const p = players.find((x) => x.id === playerId)
    if (!p?.roundSideId) return null
    return p.roundSideId === sides[0]?.id ? "a" : "b"
  }

  const sortedPlayers =
    sides.length === 2
      ? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
      : players

  const allRegisteredByHole: Record<number, boolean> = {}
  for (const n of holeOrder) {
    allRegisteredByHole[n] = players.every((p) => {
      // ADR-065 -- en avsluttet spiller blokkerer aldri "registrert"-status
      // for et hull de aldri kommer til å spille.
      if (p.completed) return true
      const h = holesByParticipant[p.id]?.find((x) => x.hole_number === n)
      return Boolean(h?.played) && (h?.score !== null || h?.picked_up)
    })
  }

  return (
    <div className="flex flex-col gap-4">
      {/* Hull-navigator: gjenbruker samme goPrev/goNext-logikk (wrap-around)
          som resten av siden allerede har, pluss en hurtig-hopp-stripe --
          erstatter behovet for å scrolle et bredt grid for å bytte hull. */}
      <div className="rounded-2xl border border-border bg-card p-3">
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={onPrevHole}
            aria-label="Forrige hull"
            className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
          >
            <ChevronLeft aria-hidden="true" className="size-5" />
            Forrige
          </button>
          <div className="flex min-w-[7.5rem] flex-col items-center px-1 text-center">
            <span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
              Hull {currentHole}
            </span>
            <span className="mt-1 text-xs font-semibold text-muted-foreground tabular-nums">
              {meta ? `Par ${meta.par} · Indeks ${meta.index}` : " "}
            </span>
          </div>
          <button
            type="button"
            onClick={onNextHole}
            aria-label="Neste hull"
            className="flex h-14 flex-1 items-center justify-center gap-1.5 rounded-xl border border-border bg-background text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
          >
            Neste
            <ChevronRight aria-hidden="true" className="size-5" />
          </button>
        </div>
        <nav aria-label="Hopp til hull" className="-mx-1 mt-3 overflow-x-auto px-1">
          <ul className="flex gap-1.5 pb-0.5">
            {holeOrder.map((n) => {
              const active = n === currentHole
              const registered = allRegisteredByHole[n]
              return (
                <li key={n}>
                  <button
                    type="button"
                    onClick={() => onSelectHole(n)}
                    aria-current={active ? "true" : undefined}
                    aria-label={`Hull ${n}${registered ? ", registrert" : ""}`}
                    className={cn(
                      // size-11 (44px), ikke V0-eksportens size-9 -- CLAUDE.md sitt
                      // ufravikelige 44px-trykkgulv gjelder ALT trykkbart, også en
                      // kompakt hurtig-hopp-stripe.
                      "flex size-11 shrink-0 items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
                      active
                        ? "border-primary bg-primary text-primary-foreground"
                        : registered
                          ? "border-primary/40 bg-primary/10 text-foreground"
                          : "border-border bg-background text-muted-foreground hover:bg-accent/50",
                    )}
                  >
                    {n}
                  </button>
                </li>
              )
            })}
          </ul>
        </nav>
      </div>

      {/* Avstand til grønn (ADR-064, utvidet til ekte teeoff-baner i
          migrasjon 079) -- kun for baner med koordinatdata; ingen synlig
          rad i det hele tatt for andre baner (HoleTargetDistance returnerer
          null da). Full bredde, samme plassering brukeren pekte ut (rett
          under hull-navigatoren, over spillerkortene) -- ikke den smale
          kompaktvarianten. */}
      <HoleTargetDistance baseUrl={`/rounds/${roundId}`} holeNumber={currentHole} size="full" />

      {isTwoSided && sides.length === 2 && (
        <div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-card px-4 py-2.5 text-sm">
          <span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-primary" />
          <span className="font-bold text-foreground">{sides[0]?.label?.trim() || "Side A"}</span>
          <span className="mx-1 text-muted-foreground">mot</span>
          <span className="font-bold text-foreground">{sides[1]?.label?.trim() || "Side B"}</span>
          <span aria-hidden="true" className="size-3 shrink-0 rounded-full bg-brand-orange" />
        </div>
      )}

      <ul className="flex flex-col gap-3">
        {sortedPlayers.map((player) => {
          const list = holesByParticipant[player.id]
          const apiHole = list?.find((h) => h.hole_number === currentHole) ?? null
          const hasScore = Boolean(apiHole?.played) && (apiHole?.score !== null || apiHole?.picked_up)
          const diff = hasScore && meta && apiHole?.score !== null ? (apiHole!.score as number) - meta.par : null
          const net =
            hasScore && apiHole?.score !== null && apiHole?.strokes_received != null
              ? (apiHole!.score as number) - apiHole!.strokes_received
              : null
          const points = net !== null && meta ? stablefordPoints(net, meta.par) : null
          const playerSide = showMatchColors ? sideOf(player.id) : null
          const entry = formatHole?.entries.find((e) => e.id === player.id)
          const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
          const dimmed = Boolean(entry && entry.counted === false)

          const frontSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n <= 9)) : null
          const backSum = holesPlanned === 18 ? sumForPlayer(list, holeOrder.filter((n) => n >= 10)) : null
          const totalSum = sumForPlayer(list, holeOrder)

          return (
            <li key={player.id}>
              <div
                className={cn(
                  "overflow-hidden rounded-3xl border bg-card shadow-sm shadow-black/5 transition-colors",
                  dimmed ? "border-dashed border-border opacity-60" : "border-border",
                )}
                style={
                  playerSide
                    ? { borderLeftWidth: 6, borderLeftColor: playerSide === "a" ? "var(--primary)" : "var(--brand-orange)" }
                    : undefined
                }
              >
                <button
                  type="button"
                  disabled={readOnly}
                  onClick={() => onOpenEntry(player.id, currentHole)}
                  aria-label={
                    hasScore
                      ? `${player.name}, hull ${currentHole}, ${apiHole?.picked_up ? "plukket opp" : `${apiHole?.score} slag`}${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
                      : player.completed
                        ? `${player.name} har avsluttet runden, hull ${currentHole} ikke spilt`
                        : `Registrer ${player.name} sin score på hull ${currentHole}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
                  }
                  className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/30 disabled:opacity-60"
                >
                  <div className="flex min-w-0 flex-1 flex-col gap-1">
                    <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                      {playerSide && (
                        <span
                          aria-hidden="true"
                          className={cn("size-2.5 shrink-0 rounded-full", playerSide === "a" ? "bg-primary" : "bg-brand-orange")}
                        />
                      )}
                      <span className="truncate text-base font-bold tracking-tight text-foreground">{player.name}</span>
                      {player.isSelf && (
                        <Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
                          Deg
                        </Badge>
                      )}
                    </div>
                    <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs font-medium text-muted-foreground tabular-nums">
                      <span>HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "–"}</span>
                      {holesPlanned === 18 ? (
                        <>
                          <span aria-hidden="true">·</span>
                          <span>
                            Ut {frontSum ? frontSum.strokes : "–"} · Inn {backSum ? backSum.strokes : "–"} · Sum{" "}
                            <span className="font-bold text-foreground">{totalSum ? totalSum.strokes : "–"}</span>
                            {totalSum ? ` (${formatToPar(totalSum.strokes - totalSum.par)})` : ""}
                          </span>
                        </>
                      ) : (
                        totalSum && (
                          <>
                            <span aria-hidden="true">·</span>
                            <span>
                              Sum <span className="font-bold text-foreground">{totalSum.strokes}</span> (
                              {formatToPar(totalSum.strokes - totalSum.par)})
                            </span>
                          </>
                        )
                      )}
                    </div>
                    {dimmed && (
                      <span className="text-[11px] font-semibold text-muted-foreground">Teller ikke på dette hullet</span>
                    )}
                    {won && !dimmed && (
                      <span
                        className="mt-0.5 inline-flex w-fit items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-bold text-primary-foreground"
                        style={{ backgroundColor: playerSide === "b" ? "var(--brand-orange)" : "var(--primary)" }}
                      >
                        Vant hullet
                      </span>
                    )}
                  </div>

                  <div className="flex shrink-0 flex-col items-center gap-1">
                    {hasScore ? (
                      <>
                        <ScorecardCell
                          value={apiHole?.picked_up ? null : (apiHole!.score as number)}
                          diff={diff}
                          pickedUp={apiHole?.picked_up}
                          size="lg"
                        />
                        <span className="text-[11px] font-medium text-muted-foreground tabular-nums">
                          {net !== null ? `Netto ${net}` : " "}
                          {points !== null ? ` · ${points} p` : ""}
                        </span>
                      </>
                    ) : player.completed ? (
                      // ADR-065 -- spilleren er individuelt avsluttet, dette
                      // hullet blir aldri spilt. Ingen "Registrer"-oppfordring.
                      <span className="text-[11px] font-semibold text-muted-foreground">Ferdig</span>
                    ) : (
                      <>
                        <span className="flex size-12 items-center justify-center rounded-[10px] border-2 border-dashed border-primary/50 text-primary">
                          <Plus aria-hidden="true" className="size-5" />
                        </span>
                        {apiHole?.strokes_received ? (
                          <StrokeDots count={apiHole.strokes_received} />
                        ) : (
                          <span className="text-[11px] font-semibold text-primary">Registrer</span>
                        )}
                      </>
                    )}
                  </div>
                </button>
                {!readOnly && (
                  <div className="flex justify-end border-t border-border px-4 py-2">
                    <ShotMeasurementEntry
                      roundId={roundId}
                      holeNumber={currentHole}
                      owner={{ kind: "participant", id: player.id }}
                      ownBagClubs={player.isSelf ? ownBagClubs : []}
                      readOnly={readOnly}
                      variant="badge"
                    />
                  </div>
                )}
                {/* Personlig hull-historikk (ADR-072, flyttet hit ut av
                    slagvinduet ADR-079, 2026-08-16) -- skjermen FØR
                    registrering/"Avslutt for {navn}", ikke inni selve
                    slagvinduet. Skjuler seg selv når banen er custom eller
                    spilleren ikke har historikk ennå. */}
                <div className="px-4 pb-2">
                  <HoleHistoryPanel
                    url={`/rounds/${roundId}/participants/${player.id}/holes/${currentHole}/history`}
                    onOpenFull={() => setHistoryPlayerId(player.id)}
                  />
                </div>
                <ParticipantCompletionActions
                  player={player}
                  holesPlayed={list?.filter((h) => h.played).length ?? 0}
                  readOnly={readOnly}
                  onPatch={onPatchParticipant}
                  onSendScorecard={onSendScorecard}
                />
                {playFormat === "flag" && (() => {
                  const currentPosition = computeFlagCurrentPosition(
                    holeOrder, holesByParticipant[player.id], flagOverflowByParticipant[player.id],
                  )
                  return (
                    <>
                      <FlagStatusRow
                        player={player}
                        plant={flagPlantByParticipant[player.id]}
                        currentPosition={currentPosition}
                        readOnly={readOnly}
                        onOpenSheet={() => setFlagSheetParticipantId(player.id)}
                        onUnplant={() => onUnplantFlag(player.id)}
                      />
                      <FlagOverflowPanel
                        player={player}
                        holeOrder={holeOrder}
                        overflowHoles={flagOverflowByParticipant[player.id] ?? []}
                        currentPosition={currentPosition}
                        readOnly={readOnly}
                        onSubmitHole={(lap, holeNumber, score) => onSubmitFlagOverflowHole(player.id, lap, holeNumber, score)}
                      />
                      {flagSheetParticipantId === player.id && (
                        <FlagPlantSheet
                          open
                          onClose={() => setFlagSheetParticipantId(null)}
                          expectedHoleNumber={currentPosition.holeNumber}
                          expectedLap={currentPosition.lap}
                          onConfirm={async (result) => {
                            const res = await onPlantFlag(player.id, {
                              lat: result.lat, lng: result.lng,
                              hole_number: currentPosition.holeNumber, lap: currentPosition.lap,
                              on_green: result.onGreen, distance_to_pin_cm: result.distanceToPinCm,
                            })
                            return res
                          }}
                        />
                      )}
                    </>
                  )
                })()}
              </div>
            </li>
          )
        })}
      </ul>

      {historyPlayerId && (
        <HoleHistoryDetail
          url={`/rounds/${roundId}/participants/${historyPlayerId}/holes/${currentHole}/history`}
          holeNumber={currentHole}
          onClose={() => setHistoryPlayerId(null)}
        />
      )}
    </div>
  )
}

function ScorecardGrid({
  players,
  holesByParticipant,
  holeOrder,
  holesPlanned,
  currentHole,
  readOnly,
  sides,
  isTwoSided,
  formatResult,
  onSelectHole,
  onOpenEntry,
}: {
  players: Player[]
  holesByParticipant: Record<string, ApiHole[]>
  holeOrder: number[]
  holesPlanned: number
  currentHole: number
  readOnly: boolean
  // Match-aware fargelegging + Stilling-rad (2026-07-29) -- kun for
  // to-sidede individuell-ball-formater (match/fourball), aldri slagspill/
  // skins (ingen sider/lead-begrep der).
  sides: ApiSide[]
  isTwoSided: boolean
  formatResult: ApiFormatResult | null
  onSelectHole: (holeNumber: number) => void
  onOpenEntry: (playerId: string, holeNumber: number) => void
}) {
  const holeMeta: Record<number, { par: number; index: number }> = {}
  for (const list of Object.values(holesByParticipant)) {
    for (const h of list) {
      if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
    }
  }

  // Ut(1-9)/Inn(10-18) er alltid gruppert på FYSISK hullnummer (tradisjonell
  // scorekort-konvensjon), ikke på spillerekkefølgen -- riktig uansett
  // hvilket starthull runden faktisk begynte på. Kun meningsfullt å splitte
  // for en 18-hulls runde; en 9-hulls runde får kun én samlet Sum-kolonne.
  const showSplit = holesPlanned === 18
  const front = holeOrder.filter((n) => n <= 9)
  const back = holeOrder.filter((n) => n >= 10)

  function sumFor(playerId: string, numbers: number[]): { strokes: number; par: number } | null {
    const list = holesByParticipant[playerId]
    if (!list || numbers.length === 0) return null
    const played = numbers
      .map((n) => list.find((h) => h.hole_number === n))
      .filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
    if (played.length === 0) return null
    return {
      strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
      par: played.reduce((sum, h) => sum + h.par, 0),
    }
  }

  const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)

  // Match-aware fargelegging (2026-07-29): entries[].id == round_participant.id
  // for individuell-ball-formater (match/fourball) -- se _build_format_result
  // i app/routers/rounds.py. Kun relevant når formatResult faktisk er klart.
  const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
  const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
  const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map()
  function sideOf(playerId: string): "a" | "b" | null {
    const p = players.find((x) => x.id === playerId)
    if (!p?.roundSideId) return null
    return p.roundSideId === sides[0]?.id ? "a" : "b"
  }

  // Lagvis gruppering (2026-08-02, "det store grepet") -- et to-sidet
  // format (fourball/match/foursome osv.) skal vise side A samlet, deretter
  // side B samlet, ikke ren innsettingsrekkefølge (A, B, C, D uansett hvem
  // som faktisk er lagkamerater). Stabil sortering bevarer original
  // rekkefølge INNAD i hver gruppe -- kun selve grupperingen endres. Kun
  // meningsfullt når begge sider faktisk finnes; ellers uendret rekkefølge.
  const sortedPlayers =
    sides.length === 2
      ? [...players].sort((a, b) => sideRank(a.roundSideId, sides) - sideRank(b.roundSideId, sides))
      : players

  return (
    <div className="overflow-x-auto rounded-2xl border border-border">
      <table className="w-full border-separate border-spacing-0 text-sm">
        <colgroup>
          <col className="w-[130px]" />
          {holeOrder.map((n) => (
            <col key={n} className="w-[52px]" />
          ))}
          {showSplit && <col className="w-[60px]" />}
          {showSplit && <col className="w-[60px]" />}
          <col className="w-[60px]" />
        </colgroup>
        <thead>
          <tr className="border-b border-border">
            <th
              scope="col"
              className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
            >
              Spiller
            </th>
            {holeOrder.map((n) => {
              const active = n === currentHole
              return (
                <th key={n} scope="col" className="border-l border-border p-0">
                  <button
                    type="button"
                    onClick={() => onSelectHole(n)}
                    aria-current={active ? "true" : undefined}
                    aria-label={`Velg hull ${n}`}
                    className={cn(
                      "flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
                      active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
                    )}
                  >
                    {n}
                  </button>
                </th>
              )
            })}
            {showSplit && (
              <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
                Ut
              </th>
            )}
            {showSplit && (
              <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
                Inn
              </th>
            )}
            <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
              Sum
            </th>
          </tr>
        </thead>
        <tbody>
          {/* Hcp- og Par-referanserader (samme mønster som den allerede
              shippede round-scorecard.tsx) -- rene informasjonsrader, ikke
              trykkbare. */}
          <tr className="border-b border-border">
            <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
              Hcp
            </th>
            {holeOrder.map((n) => (
              <td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
                {holeMeta[n]?.index ?? "–"}
              </td>
            ))}
            {showSplit && <td className="border-l border-border bg-muted" />}
            {showSplit && <td className="border-l border-border bg-muted" />}
            <td className="border-l border-border bg-muted" />
          </tr>
          <tr className="border-b-2 border-border">
            <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
              Par
            </th>
            {holeOrder.map((n) => (
              <td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
                {holeMeta[n]?.par ?? "–"}
              </td>
            ))}
            {showSplit && (
              <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
                {front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
              </td>
            )}
            {showSplit && (
              <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
                {back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
              </td>
            )}
            <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
              {parRowTotal || "–"}
            </td>
          </tr>

          {sortedPlayers.map((player) => {
            const list = holesByParticipant[player.id]
            const frontSum = showSplit ? sumFor(player.id, front) : null
            const backSum = showSplit ? sumFor(player.id, back) : null
            const totalSum = sumFor(player.id, holeOrder)
            const playerSide = showMatchColors ? sideOf(player.id) : null
            return (
              <tr key={player.id} className="border-b border-border last:border-b-0">
                <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
                  <button
                    type="button"
                    disabled={readOnly}
                    onClick={() => onOpenEntry(player.id, currentHole)}
                    aria-label={`Registrer score for ${player.name} på hull ${currentHole}`}
                    className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
                  >
                    <span className="flex items-center gap-1.5">
                      {playerSide && (
                        <span
                          aria-hidden="true"
                          className={cn(
                            "size-2 shrink-0 rounded-full",
                            playerSide === "a" ? "bg-primary" : "bg-brand-orange",
                          )}
                        />
                      )}
                      <span className="truncate text-sm font-bold text-foreground">{player.name}</span>
                      {player.isSelf && (
                        <Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
                          Deg
                        </Badge>
                      )}
                    </span>
                    <span className="truncate text-xs tabular-nums text-muted-foreground">
                      HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "–"}
                    </span>
                  </button>
                </th>
                {holeOrder.map((n) => {
                  const apiHole = list?.find((h) => h.hole_number === n) ?? null
                  const meta = holeMeta[n]
                  const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
                  const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
                  const net =
                    hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null
                  const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined
                  const entry = formatHole?.entries.find((e) => e.id === player.id)
                  const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
                  const dimmed = Boolean(entry && entry.counted === false)
                  return (
                    <td key={n} className="border-l border-border p-0 text-center">
                      <button
                        type="button"
                        disabled={readOnly}
                        onClick={() => onOpenEntry(player.id, n)}
                        aria-label={
                          hasScore
                            ? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
                            : `Registrer ${player.name} sin score på hull ${n}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
                        }
                        className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
                      >
                        {showMatchColors ? (
                          <MatchScorecardCell
                            value={hasScore ? (apiHole!.score as number) : null}
                            side={playerSide}
                            won={won}
                            dimmed={dimmed}
                          />
                        ) : (
                          <ScorecardCell
                            value={hasScore ? (apiHole!.score as number) : null}
                            diff={diff}
                            pickedUp={apiHole?.picked_up}
                          />
                        )}
                        {hasScore ? (
                          net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>
                        ) : (
                          // Mottatte slag vist FØR hullet er fylt ut (2026-07-29,
                          // etterspurt av bruker) -- kun når spilleren faktisk
                          // mottar minst ett slag her (ingen støy på de andre).
                          // Prikker i stedet for "−N"-tekst (2026-07-30).
                          apiHole?.strokes_received != null &&
                          apiHole.strokes_received > 0 && <StrokeDots count={apiHole.strokes_received} />
                        )}
                      </button>
                    </td>
                  )
                })}
                {showSplit && (
                  <td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
                    {frontSum ? frontSum.strokes : "–"}
                  </td>
                )}
                {showSplit && (
                  <td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
                    {backSum ? backSum.strokes : "–"}
                  </td>
                )}
                <td className="border-l border-border bg-muted px-1 py-1 text-center">
                  {totalSum ? (
                    <span className="flex flex-col items-center leading-tight">
                      <span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
                      <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
                        {formatToPar(totalSum.strokes - totalSum.par)}
                      </span>
                    </span>
                  ) : (
                    "–"
                  )}
                </td>
              </tr>
            )
          })}
          {/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
              fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
              lead-poeng per vunnet hull som resten av to-sidede formater
              (denne raden ville da vist en misvisende match-play-tekst
              basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
              vises i stedet i FormatResultPanel over selve gridet --
              per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
              vises uendret. Formatet er uansett aldri delt-ball, så denne
              sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
          {showMatchColors && formatResult?.play_format !== "high_low_high" && (
            <tr className="border-b border-border bg-muted/30 last:border-b-0">
              <th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
                Stilling
              </th>
              {holeOrder.map((n) => {
                const s = stillingByHole.get(n)
                return (
                  <td
                    key={n}
                    className={cn(
                      "border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
                      !s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
                    )}
                  >
                    {s?.label ?? "–"}
                  </td>
                )
              })}
              {showSplit && <td className="border-l border-border bg-muted" />}
              {showSplit && <td className="border-l border-border bg-muted" />}
              <td className="border-l border-border bg-muted" />
            </tr>
          )}
        </tbody>
      </table>
    </div>
  )
}

// Golfscore-språket for en LITEN tabellcelle -- samme eksakte klassifisering
// og Tailwind-klasser som `ScoreMark`/`classify` i round-scorecard.tsx
// (bekreftet ved å lese kildekoden der direkte, egen kopi her per
// prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se
// DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over
// par, ren tekst på par, fylt ved 2+ slag fra par.
// Mottatte slag FØR hullet er fylt ut, vist som prikker i stedet for tekst
// (2026-07-30, etterspurt av bruker som en kompakt erstatning for "−N") --
// ren visuell hint, den faktiske informasjonen ligger i knappens eget
// aria-label (§tilgjengelighet: aldri kun et visuelt symbol uten tekst for
// noe som faktisk betyr noe).
function StrokeDots({ count }: { count: number }) {
  return (
    <span className="flex items-center justify-center gap-0.5" aria-hidden="true">
      {Array.from({ length: count }, (_, i) => (
        <span key={i} className="size-1.5 rounded-full bg-info" />
      ))}
    </span>
  )
}

function ScorecardCell({
  value,
  diff,
  pickedUp,
  size = "sm",
}: {
  value: number | null
  diff: number | null
  pickedUp?: boolean
  // "lg" brukt av PlayerHoleCards (2026-08-03) -- samme form-/fargespråk,
  // bare skalert opp for den hull-fokuserte kortvisningen.
  size?: "sm" | "lg"
}) {
  const shared = cn(
    "flex items-center justify-center font-extrabold tabular-nums",
    size === "lg" ? "size-12 text-2xl" : "size-7 text-sm",
  )
  // "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte,
  // vises som "PU" i stedet for det (allerede kjente) Net Double Bogey-
  // slagtallet, som ellers ville sett ut som en vilkårlig høy score.
  if (pickedUp) {
    return (
      <span
        className={cn(
          shared,
          "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
          size === "lg" ? "text-sm" : "text-[10px]",
        )}
      >
        PU
      </span>
    )
  }
  if (value === null) return <span className={cn(shared, "text-muted-foreground")}>–</span>
  if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span>
  if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span>
  if (diff === -1) return <span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>{value}</span>
  if (diff === 1) {
    return <span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange")}>{value}</span>
  }
  return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span>
}

// Match-aware variant av ScorecardCell (2026-07-29) -- fargen følger HVEM SOM
// VANT hullet (grønn=side A, oransje=side B), ikke over/under par, siden
// det er det som betyr noe i en match. Nedtonet tekst for en fourball-
// partner sin ikke-tellende score (laveste netto teller for siden).
function MatchScorecardCell({
  value,
  side,
  won,
  dimmed,
}: {
  value: number | null
  side: "a" | "b" | null
  won: boolean
  dimmed: boolean
}) {
  const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
  if (value === null) return <span className={cn(shared, "text-muted-foreground")}>–</span>
  if (won) {
    return (
      <span
        className={cn(
          shared,
          "rounded-full",
          side === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
        )}
      >
        {value}
      </span>
    )
  }
  return <span className={cn(shared, dimmed ? "text-muted-foreground/50" : "font-bold text-foreground")}>{value}</span>
}

// --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
// 2026-07-24 på brukerens eksplisitte bekreftelse.

// --- Number picker ---------------------------------------------------------

// Golf-term per slagtall relativt til par (2026-07-26, inspirert av en
// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt --
// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller
// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren.
// --- Delt-ball-scorekort (ADR-039 Beslutning C) -----------------------------
// Samme grid-idé som ScorecardGrid, men rader = SIDER (ikke spillere) -- kun
// ett tall per hull (ingen putt/netto-linje, en delt ball har ingen entydig
// netto-visning per spiller her).

function SideScorecardGrid({
  sides,
  players,
  holesBySide,
  holeOrder,
  holesPlanned,
  currentHole,
  readOnly,
  isTwoSided,
  formatResult,
  onSelectHole,
  onOpenEntry,
}: {
  sides: ApiSide[]
  players: Player[]
  holesBySide: Record<string, ApiSideHole[]>
  holeOrder: number[]
  holesPlanned: number
  currentHole: number
  readOnly: boolean
  // Match-aware fargelegging + Stilling-rad (2026-07-29) -- delt-ball-
  // formater (foursome/greensome/scramble) er ALLE to-sidede per ADR-039,
  // så denne er reelt alltid sann her, men holdt eksplisitt for symmetri
  // med ScorecardGrid.
  isTwoSided: boolean
  formatResult: ApiFormatResult | null
  onSelectHole: (holeNumber: number) => void
  onOpenEntry: (sideId: string, holeNumber: number) => void
}) {
  const holeMeta: Record<number, { par: number; index: number }> = {}
  for (const list of Object.values(holesBySide)) {
    for (const h of list) {
      if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
    }
  }

  const showSplit = holesPlanned === 18
  const front = holeOrder.filter((n) => n <= 9)
  const back = holeOrder.filter((n) => n >= 10)

  function sumFor(sideId: string, numbers: number[]): { strokes: number; par: number } | null {
    const list = holesBySide[sideId]
    if (!list || numbers.length === 0) return null
    const played = numbers
      .map((n) => list.find((h) => h.hole_number === n))
      .filter((h): h is ApiSideHole => Boolean(h) && h!.played && h!.score !== null)
    if (played.length === 0) return null
    return {
      strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
      par: played.reduce((sum, h) => sum + h.par, 0),
    }
  }

  const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)

  // entries[].id == round_side.id for delt-ball-formater (samme
  // _build_format_result-kontrakt som ScorecardGrid bruker for spillere).
  const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes)
  const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h]))
  const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map()

  return (
    <div className="overflow-x-auto rounded-2xl border border-border">
      <table className="w-full border-separate border-spacing-0 text-sm">
        <colgroup>
          <col className="w-[130px]" />
          {holeOrder.map((n) => (
            <col key={n} className="w-[52px]" />
          ))}
          {showSplit && <col className="w-[60px]" />}
          {showSplit && <col className="w-[60px]" />}
          <col className="w-[60px]" />
        </colgroup>
        <thead>
          <tr className="border-b border-border">
            <th
              scope="col"
              className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
            >
              Side
            </th>
            {holeOrder.map((n) => {
              const active = n === currentHole
              return (
                <th key={n} scope="col" className="border-l border-border p-0">
                  <button
                    type="button"
                    onClick={() => onSelectHole(n)}
                    aria-current={active ? "true" : undefined}
                    aria-label={`Velg hull ${n}`}
                    className={cn(
                      "flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
                      active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
                    )}
                  >
                    {n}
                  </button>
                </th>
              )
            })}
            {showSplit && (
              <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
                Ut
              </th>
            )}
            {showSplit && (
              <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
                Inn
              </th>
            )}
            <th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
              Sum
            </th>
          </tr>
        </thead>
        <tbody>
          <tr className="border-b border-border">
            <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
              Hcp
            </th>
            {holeOrder.map((n) => (
              <td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
                {holeMeta[n]?.index ?? "–"}
              </td>
            ))}
            {showSplit && <td className="border-l border-border bg-muted" />}
            {showSplit && <td className="border-l border-border bg-muted" />}
            <td className="border-l border-border bg-muted" />
          </tr>
          <tr className="border-b-2 border-border">
            <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
              Par
            </th>
            {holeOrder.map((n) => (
              <td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
                {holeMeta[n]?.par ?? "–"}
              </td>
            ))}
            {showSplit && (
              <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
                {front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
              </td>
            )}
            {showSplit && (
              <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
                {back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
              </td>
            )}
            <td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
              {parRowTotal || "–"}
            </td>
          </tr>

          {sides.map((side, sideIndex) => {
            const list = holesBySide[side.id]
            const frontSum = showSplit ? sumFor(side.id, front) : null
            const backSum = showSplit ? sumFor(side.id, back) : null
            const totalSum = sumFor(side.id, holeOrder)
            const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name)
            const sideTone: "a" | "b" = sideIndex === 0 ? "a" : "b"
            return (
              <tr key={side.id} className="border-b border-border last:border-b-0">
                <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
                  <button
                    type="button"
                    disabled={readOnly}
                    onClick={() => onOpenEntry(side.id, currentHole)}
                    aria-label={`Registrer score for ${side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} på hull ${currentHole}`}
                    className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
                  >
                    <span className="flex items-center gap-1.5">
                      {showMatchColors && (
                        <span
                          aria-hidden="true"
                          className={cn("size-2 shrink-0 rounded-full", sideTone === "a" ? "bg-primary" : "bg-brand-orange")}
                        />
                      )}
                      <span className="truncate text-sm font-bold text-foreground">
                        {side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`}
                      </span>
                    </span>
                    <span className="truncate text-xs text-muted-foreground">
                      {assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"}
                    </span>
                  </button>
                </th>
                {holeOrder.map((n) => {
                  const apiHole = list?.find((h) => h.hole_number === n) ?? null
                  const meta = holeMeta[n]
                  const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
                  const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
                  const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined
                  const entry = formatHole?.entries.find((e) => e.id === side.id)
                  const won = Boolean(entry && formatHole?.result === sideTone)
                  return (
                    <td key={n} className="border-l border-border p-0 text-center">
                      <button
                        type="button"
                        disabled={readOnly}
                        onClick={() => onOpenEntry(side.id, n)}
                        aria-label={
                          hasScore
                            ? `${side.label ?? "Side"}, hull ${n}, ${apiHole?.score} slag${won ? ", vant hullet" : ""}`
                            : `Registrer score for hull ${n}${apiHole?.strokes_received ? `, mottar ${apiHole.strokes_received} slag` : ""}`
                        }
                        className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
                      >
                        {showMatchColors ? (
                          <MatchScorecardCell
                            value={hasScore ? (apiHole!.score as number) : null}
                            side={sideTone}
                            won={won}
                            dimmed={false}
                          />
                        ) : (
                          <ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
                        )}
                        {/* Mottatte slag vist FØR hullet er fylt ut (2026-07-29) --
                            samme kombinerte side-verdi som ville blitt trukket fra
                            ved utfylling. Prikker i stedet for "−N"-tekst (2026-07-30). */}
                        {!hasScore && apiHole?.strokes_received != null && apiHole.strokes_received > 0 && (
                          <StrokeDots count={apiHole.strokes_received} />
                        )}
                      </button>
                    </td>
                  )
                })}
                {showSplit && (
                  <td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
                    {frontSum ? frontSum.strokes : "–"}
                  </td>
                )}
                {showSplit && (
                  <td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
                    {backSum ? backSum.strokes : "–"}
                  </td>
                )}
                <td className="border-l border-border bg-muted px-1 py-1 text-center">
                  {totalSum ? (
                    <span className="flex flex-col items-center leading-tight">
                      <span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
                      <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
                        {formatToPar(totalSum.strokes - totalSum.par)}
                      </span>
                    </span>
                  ) : (
                    "–"
                  )}
                </td>
              </tr>
            )
          })}
          {/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng
              fordeles 0-2 per hull (to uavhengige duelløer), ikke ett
              lead-poeng per vunnet hull som resten av to-sidede formater
              (denne raden ville da vist en misvisende match-play-tekst
              basert på hull VUNNET, ikke faktiske poeng). Literal poengsum
              vises i stedet i FormatResultPanel over selve gridet --
              per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og
              vises uendret. Formatet er uansett aldri delt-ball, så denne
              sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */}
          {showMatchColors && formatResult?.play_format !== "high_low_high" && (
            <tr className="border-b border-border bg-muted/30 last:border-b-0">
              <th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
                Stilling
              </th>
              {holeOrder.map((n) => {
                const s = stillingByHole.get(n)
                return (
                  <td
                    key={n}
                    className={cn(
                      "border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
                      !s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
                    )}
                  >
                    {s?.label ?? "–"}
                  </td>
                )
              })}
              {showSplit && <td className="border-l border-border bg-muted" />}
              {showSplit && <td className="border-l border-border bg-muted" />}
              <td className="border-l border-border bg-muted" />
            </tr>
          )}
        </tbody>
      </table>
    </div>
  )
}

// --- Delt-ball-scoreveiviser (ADR-039 Beslutning C) --------------------------
// Enklere enn ScoringWizard -- kun ett tall (slagtall for hele siden), ingen
// putt-/detalj-steg. Samme "Neste side" → "Neste hull"-samlebåndsprinsipp.

function SideScoreWizard({
  sides,
  side,
  sideParticipants,
  hole,
  score,
  selectedParticipantId,
  onSave,
  onSelectParticipant,
  onClose,
  onAdvance,
  readOnly,
  isPending,
}: {
  sides: ApiSide[]
  side: ApiSide
  // Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
  sideParticipants: Player[]
  hole: Hole
  score: number | null
  selectedParticipantId: string | null
  onSave: (score: number | null) => void
  onSelectParticipant: (participantId: string | null) => void
  onClose: () => void
  onAdvance: () => void
  readOnly: boolean
  // Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- se
  // ScoringWizard sin samme prop for full begrunnelse.
  isPending: boolean
}) {
  const sideIndex = sides.findIndex((s) => s.id === side.id)
  const isLast = sideIndex === -1 || sideIndex === sides.length - 1
  const nextSide = !isLast ? sides[sideIndex + 1] : null

  function sideLabel(s: ApiSide, idx: number) {
    return s.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
  }

  // Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
  // slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
  // når det ikke finnes noe utslags-valg å ta stilling til også (migrasjon
  // 036) -- ellers ville auto-hoppet revet brukeren videre FØR de rakk å
  // velge hvem sitt utslag som ble brukt (det valgfrie feltet under).
  const autoAdvance = sideParticipants.length === 0
  const enteredWithValueRef = useRef(score !== null)
  const firedRef = useRef(false)
  useEffect(() => {
    firedRef.current = false
    enteredWithValueRef.current = score !== null
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [side.id, hole.holeNumber])
  useEffect(() => {
    if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
    if (score !== null) {
      firedRef.current = true
      onAdvance()
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [score, autoAdvance])

  return (
    <div className="fixed inset-0 z-50 flex flex-col bg-background">
      <header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
        <button
          type="button"
          onClick={onClose}
          aria-label="Lukk veiviseren"
          className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
        >
          <X aria-hidden="true" className="size-5" />
        </button>
        <div className="flex min-w-0 flex-1 flex-col items-center text-center">
          <span className="truncate text-base font-extrabold text-foreground">
            Hull {hole.holeNumber} · Par {hole.par}
          </span>
          <span className="truncate text-sm font-semibold text-muted-foreground">{sideLabel(side, sideIndex)}</span>
        </div>
        <div className="size-11 shrink-0" aria-hidden="true" />
      </header>

      <div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
        {sides.map((s, i) => (
          <span
            key={s.id}
            className={cn(
              "flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
              s.id === side.id
                ? "border-primary bg-primary text-primary-foreground"
                : "border-border bg-card text-muted-foreground",
            )}
          >
            {sideLabel(s, i)}
          </span>
        ))}
      </div>

      {isPending && (
        <p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
          Lagret lokalt · venter på synk
        </p>
      )}

      <main className="flex-1 overflow-y-auto p-5 sm:p-6">
        <div className="mx-auto flex max-w-sm flex-col gap-6">
          <NumberPicker
            label="Slag"
            value={score}
            directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
            expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
            expandLabel="10+"
            parValue={hole.par}
            showGolfTerms
            onChange={(v) => onSave(v)}
            readOnly={readOnly}
          />

          {/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036,
              FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per
              spiller"). Helt valgfritt -- egen handling, rører aldri
              slagtallet. */}
          {sideParticipants.length > 0 && (
            <fieldset className="flex flex-col gap-2">
              <legend className="mb-1 text-sm font-bold text-foreground">
                Hvem sitt utslag ble brukt? <span className="font-normal text-muted-foreground">(valgfritt)</span>
              </legend>
              <div className="flex flex-wrap gap-2">
                {sideParticipants.map((p) => (
                  <button
                    key={p.id}
                    type="button"
                    disabled={readOnly}
                    onClick={() => onSelectParticipant(selectedParticipantId === p.id ? null : p.id)}
                    aria-pressed={selectedParticipantId === p.id}
                    className={cn(
                      "inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60",
                      selectedParticipantId === p.id
                        ? "border-primary bg-primary text-primary-foreground"
                        : "border-border bg-card text-foreground hover:bg-accent/50",
                    )}
                  >
                    {p.name}
                  </button>
                ))}
              </div>
            </fieldset>
          )}
        </div>
      </main>

      <footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
        <Button type="button" variant="outline" onClick={onClose} className="h-14 rounded-2xl text-base font-bold">
          Avbryt
        </Button>
        <Button
          type="button"
          disabled={score === null}
          onClick={onAdvance}
          className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
        >
          {isLast ? "Ferdig" : `Neste: ${sideLabel(nextSide!, sideIndex + 1)}`}
        </Button>
      </footer>
    </div>
  )
}

// --- Wizard-seksjon (2026-08-11) -------------------------------------------
// Liten seksjonsoverskrift + hårfin divider, brukt til å dele detalj-steget
// i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon
// -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens).
// --- "Mer innhold under"-hint (2026-08-11) ---------------------------------
// Legger en myk bunn-fade + nedoverpil over et scrollbart område SÅ LENGE det
// finnes mer innhold under fold-linjen -- og skjuler seg av seg selv idet man
// har scrollet helt ned. Fanger både resize og innholdsendringer (ulikt
// stat_level/par gir ulik høyde) via en ResizeObserver på selve scrollboksen.
// Høyden (px) på fade-/piloverlayet under -- MÅ matche spacer-diven sin
// h-16 og trekkes fra i hasMore-utregningen, se begrunnelse der.
const SCROLL_FADE_HEIGHT_PX = 64

function ScrollFade({ children, className }: { children: React.ReactNode; className?: string }) {
  const scrollRef = useRef<HTMLDivElement>(null)
  const contentRef = useRef<HTMLDivElement>(null)
  const [hasMore, setHasMore] = useState(false)
  useEffect(() => {
    const scrollEl = scrollRef.current
    const contentEl = contentRef.current
    if (!scrollEl || !contentEl) return
    const update = () => {
      // Trekker fra spacer-divens egen høyde (samme SCROLL_FADE_HEIGHT_PX)
      // i tillegg til 4px slingring -- uten dette ville "hasMore" blitt
      // sant utelukkende fordi spaceren selv gjør scrollHeight større, selv
      // når ALT ekte innhold allerede er synlig (falskt positivt hint).
      setHasMore(scrollEl.scrollTop + scrollEl.clientHeight < scrollEl.scrollHeight - SCROLL_FADE_HEIGHT_PX - 4)
    }
    update()
    scrollEl.addEventListener("scroll", update, { passive: true })
    // Observerer INNHOLDS-diven, IKKE scroll-beholderen selv --
    // beholderens egen boks-størrelse er fast (flex-1, bundet av
    // veiviserens layout), den endrer seg ALDRI når man bytter steg. Uten
    // dette ble "hasMore" aldri regnet på nytt ved stegbytte -- den
    // sprettende pilen dukket rett og slett ikke opp på steg som faktisk
    // trengte scrolling (f.eks. "Retning"-steget med kølle-rutenett).
    const ro = new ResizeObserver(update)
    ro.observe(contentEl)
    return () => {
      scrollEl.removeEventListener("scroll", update)
      ro.disconnect()
    }
  }, [])
  return (
    <div className="relative flex min-h-0 flex-1 flex-col">
      <div ref={scrollRef} className={className}>
        <div ref={contentRef}>{children}</div>
        {/* Usynlig buffer, SAMME høyde som fade-/piloverlayet -- garanterer at
            overlayet aldri dekker et ekte, interaktivt element (f.eks. en
            retningsknapp med sin egen pil-ikon nederst i steget). Uten denne
            kunne den sprettende pilen havne rett oppå en annen pil. */}
        <div aria-hidden="true" className="h-16 shrink-0" />
      </div>
      {/* Rent dekorativt -- selve scrollingen er fullt mulig uten dette. */}
      <div
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute inset-x-0 bottom-0 flex h-16 items-end justify-center bg-gradient-to-t from-background to-transparent pb-1.5 transition-opacity duration-200",
          hasMore ? "opacity-100" : "opacity-0",
        )}
      >
        <ChevronDown className="size-5 animate-bounce text-muted-foreground" />
      </div>
    </div>
  )
}

