"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, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
  ArrowDown,
  ArrowLeft,
  ArrowRight,
  ArrowUp,
  CalendarDays,
  Check,
  ChevronDown,
  Clock,
  Trash2,
  ChevronLeft,
  ChevronRight,
  Flag,
  MapPin,
  Minus,
  Plus,
  RefreshCw,
  Search,
  Settings2,
  Target,
  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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
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
  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
}

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"
}

const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function formatDate(value: string) {
  const parsed = new Date(value)
  if (Number.isNaN(parsed.getTime())) return value
  return dateFormatter.format(parsed)
}

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

type ApiParticipant = {
  id: string
  user_id: string | null
  guest_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
}

type ApiSide = {
  id: string
  label: string | 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
}

// 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.
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4"])
const SIDE_PLAYER_COUNT: Record<string, number> = {
  match: 1,
  fourball: 2,
  foursome: 2,
  greensome: 2,
  scramble_2: 2,
  scramble_4: 4,
}
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)",
}

// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
// lagret varighet, kun utledet ved visning.
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
function formatTime(value: string) {
  const parsed = new Date(value)
  if (Number.isNaN(parsed.getTime())) return value
  return timeFormatter.format(parsed)
}
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
}

// 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
}

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()
  const [round, setRound] = useState<ApiRound | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
  const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
  // 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).
  const [currentHole, setCurrentHole] = useState<number | null>(null)
  // 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)
  // 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">("score")
  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[]>>({})

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

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

  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}`
    await enqueueWrite({ url, method: "PATCH", body, 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}`
    await enqueueWrite({ url, method: "PATCH", body, 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) {
        setError(
          `${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)
    }
    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,
      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,
    })) ?? []

  // 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
  const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
  // 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
  const hole = holes.find((h) => h.holeNumber === activeHole) ?? 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
    }
    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(body),
      })
    } 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) 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)
    setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
    scrollToHolePanel()
  }
  function goNext() {
    const i = holeOrder.indexOf(activeHole)
    setCurrentHole(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.
  function advanceWizardPlayer() {
    const idx = players.findIndex((p) => p.id === wizardPlayerId)
    const isLast = idx === -1 || idx === players.length - 1
    if (!isLast) {
      const next = players[idx + 1]
      setActivePlayerId(next.id)
      setWizardPlayerId(next.id)
    } else {
      setWizardPlayerId(null)
      setActivePlayerId(players[0]?.id ?? null)
      goNext()
    }
  }

  // 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
  const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
  const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
  // Par/index for aktivt hull -- samme for begge sider (samme bane), henter
  // fra hvilken som helst av de to som allerede har lastet inn hullene sine.
  const sharedHole: Hole | null = (() => {
    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
  })()

  // 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
    }
    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(body),
      })
    } catch {
      await queueSideHoleWrite(wizardSideId, activeHole, body)
      return
    }
    if (!res.ok) 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
    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(name: 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_name: name,
        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 }
  }

  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 (
    <div className="flex min-h-[100dvh] flex-col bg-background">
      {/* Sticky header */}
      <header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
        <div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
          <Link
            href="/my-rounds"
            aria-label="Tilbake til egne runder"
            className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
          >
            <ArrowLeft aria-hidden="true" className="size-5" />
          </Link>
          <div className="flex min-w-0 flex-col gap-0.5">
            <h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
              <MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
              <span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
            </h1>
            <div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
              {round.name?.trim() && (
                <span className="flex items-center gap-1.5 truncate">
                  <MapPin aria-hidden="true" className="size-4 shrink-0" />
                  {round.course_name_snapshot}
                </span>
              )}
              <span className="flex items-center gap-1.5">
                <Flag aria-hidden="true" className="size-4 shrink-0" />
                {round.tee_name_snapshot}
              </span>
              <span className="flex items-center gap-1.5 tabular-nums">
                <CalendarDays aria-hidden="true" className="size-4 shrink-0" />
                {formatDate(round.played_at)}
              </span>
              {round.started_at && (
                <span className="flex items-center gap-1.5 tabular-nums">
                  <Clock aria-hidden="true" className="size-4 shrink-0" />
                  {formatTime(round.started_at)}
                </span>
              )}
              {round.play_format === "match" && (
                <Badge variant="outline" className="shrink-0">
                  Matchspill
                </Badge>
              )}
            </div>
          </div>
        </div>
      </header>

      <main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
        {error && (
          <p role="alert" className="mb-4 text-base font-medium text-destructive">
            {error}
          </p>
        )}

        {/* 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} />

            {/* 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}
              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}
              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} />

        {/* 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) => {
                  setCurrentHole(n)
                  scrollToHolePanel()
                }}
                onOpenEntry={(sideId, holeNumber) => {
                  setCurrentHole(holeNumber)
                  setWizardSideId(sideId)
                }}
              />
              <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>
          )
        ) : (
          <>
            {/* Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, bygget
                2026-07-27 -- erstatter den forrige rundens ett-hull-om-gangen-
                liste, bruker eksplisitt bekreftet "JA" på at grid-redesignet er
                en egen, større runde). Spillere som rader (sticky venstre
                navnekolonne), hull som horisontalt scrollbare kolonner --
                HELE runden synlig samtidig, ikke bare det aktive hullet. */}
            {holes.length === 0 ? (
              <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">
                <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) => {
                    setCurrentHole(n)
                    scrollToHolePanel()
                  }}
                  onOpenEntry={(playerId, holeNumber) => {
                    setCurrentHole(holeNumber)
                    setActivePlayerId(playerId)
                    setWizardPlayerId(playerId)
                  }}
                />

                {/* Rask sekvensiell hull-navigasjon -- gridet viser allerede
                    alle hull som kolonner (tapp en kolonneoverskrift for å
                    velge), men disse to gir en rask vei videre uten å måtte
                    scrolle bredt for hvert eneste hull. */}
                <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>
            )}
          </>
        )}
        </>
        )}
      </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 && sharedHole && (
        <SideScoreWizard
          sides={round.sides}
          side={wizardSide}
          sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
          hole={sharedHole}
          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
          players={players}
          player={wizardPlayer}
          hole={hole}
          stat={wizardStat}
          onUpdate={updateWizardStat}
          onClose={() => setWizardPlayerId(null)}
          onAdvance={advanceWizardPlayer}
          readOnly={readOnly}
          ownBagClubs={ownBagClubs}
          isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
          playFormat={round.play_format}
          strokesReceived={wizardApiHole?.strokes_received ?? null}
        />
      )}
    </div>
  )
}

// --- 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" | "details"

function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
  if (statLevel === "strokes_only") return ["strokes"]
  if (statLevel === "strokes_and_putts") return ["strokes", "putts", "puttDistance"]
  return ["strokes", "putts", "puttDistance", "details"]
}

// 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({
  players,
  player,
  hole,
  stat,
  onUpdate,
  onClose,
  onAdvance,
  readOnly,
  ownBagClubs,
  isPending,
  playFormat,
  strokesReceived,
}: {
  players: Player[]
  player: Player
  hole: Hole
  stat: HoleStat
  onUpdate: (patch: Partial<HoleStat>) => void
  onClose: () => void
  onAdvance: () => 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])

  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. Ren informasjon, ikke en snarvei -- bytte
          spiller midt i et steg ville latt uferdige felt henge løst. */}
      <div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
        {players.map((p) => (
          <span
            key={p.id}
            className={cn(
              "flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
              p.id === player.id
                ? "border-primary bg-primary text-primary-foreground"
                : "border-border bg-card text-muted-foreground",
            )}
          >
            {p.name}
          </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">
          {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
                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}
              onChange={(v) => onUpdate({ putts: v })}
              readOnly={readOnly}
            />
          )}

          {step === "puttDistance" && (
            <ChoiceRow
              label="Avstand første putt"
              options={[
                { value: "<1m", label: "<1m" },
                { value: "<2m", label: "<2m" },
                { value: "<3m", label: "<3m" },
                { value: "<5m", label: "<5m" },
                { value: "<8m", label: "<8m" },
                { value: "8m+", label: "8m+" },
              ]}
              value={stat.firstPuttBucket}
              onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
              readOnly={readOnly}
            />
          )}

          {step === "details" && (
            <>
              <div className="flex flex-col gap-2">
                <Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
                {player.isSelf && ownBagClubs.length > 0 ? (
                  <div className="flex flex-wrap gap-2">
                    {ownBagClubs.map((club) => {
                      const selected = stat.club === club
                      return (
                        <button
                          key={club}
                          type="button"
                          disabled={readOnly}
                          onClick={() => onUpdate({ club })}
                          aria-pressed={selected}
                          className={cn(
                            "flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
                            selected
                              ? "border-primary bg-primary text-primary-foreground"
                              : "border-border bg-card text-foreground hover:bg-accent/50",
                          )}
                        >
                          {club}
                        </button>
                      )
                    })}
                  </div>
                ) : (
                  <Input
                    value={stat.club}
                    onChange={(e) => onUpdate({ club: e.target.value })}
                    placeholder="F.eks. Driver, 3-jern"
                    disabled={readOnly}
                    className="h-12 rounded-2xl text-base"
                  />
                )}
              </div>

              {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}
              />

              <div className="grid grid-cols-1 gap-3 sm:grid-cols-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} />
              </div>

              <NumberPicker
                label="Anywayslag"
                value={stat.anywayStrokes}
                directValues={[0, 1, 2, 3, 4, 5, 6]}
                expandValues={[7, 8, 9, 10]}
                expandLabel="7+"
                maxValue={stat.strokes ?? undefined}
                onChange={(v) => onUpdate({ anywayStrokes: v })}
                readOnly={readOnly}
              />
            </>
          )}
        </div>
      </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>
  )
}

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

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>
      <div className="flex flex-col gap-2 sm:flex-row">
        <Link
          href={`/my-rounds/${roundId}/scorecard`}
          className="flex min-h-12 flex-1 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
          <ChevronRight aria-hidden="true" className="size-5" />
        </Link>
        <Link
          href={`/my-rounds/${roundId}/stats`}
          className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
        >
          Se full rundestatistikk
          <ChevronRight aria-hidden="true" className="size-5" />
        </Link>
      </div>
    </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
}

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

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

  // To-sidede formater (match/fourball/foursome/greensome/scramble).
  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>
  )
}

// --- 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,
  activePlayerId,
  onSelect,
  onRemove,
  onAdd,
  addOpen,
  readOnly,
  canManage,
  editingPlayerId,
  onToggleEdit,
  onPatchParticipant,
  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[]
  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 }>
  onAddGuest: (name: 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>

            {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: (name: 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 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-primary/15 text-sm font-bold text-primary"
                >
                  {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) --------------

function AddGuestForm({
  onAdd,
  onCancel,
  onBack,
}: {
  onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
  onCancel: () => void
  onBack?: () => void
}) {
  const [name, setName] = useState("")
  const [gender, setGender] = useState<Gender>("male")
  const [hcp, setHcp] = useState("")
  const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const trimmed = name.trim()
    if (!trimmed) return
    const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
    onAdd(trimmed, 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="flex flex-col gap-2">
        <Label htmlFor="guest-name" className="text-base font-semibold">
          Navn
        </Label>
        <Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
      </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"
          inputMode="decimal"
          type="number"
          step="0.1"
          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 }[] }

// 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()
}

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) -- ren metadata, aldri
  // sperret av fullført-status (rører aldri AGS/differensial-matematikken
  // selv).
  const currentEditablePlayFormat: "stroke" | "match" | "stableford" =
    round.play_format === "match" ? "match" : round.play_format === "stableford" ? "stableford" : "stroke"
  const [playFormat, setPlayFormat] = useState<"stroke" | "match" | "stableford">(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)
  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)

  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
    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>
        <div className="flex gap-2">
          {(
            [
              { value: "stroke" as const, label: "Slagspill" },
              { value: "stableford" as const, label: "Stableford" },
              { value: "match" as const, label: "Matchspill" },
            ]
          ).map((opt) => (
            <button
              key={opt.value}
              type="button"
              onClick={() => setPlayFormat(opt.value)}
              aria-pressed={playFormat === opt.value}
              className={cn(
                "h-11 flex-1 rounded-xl border text-base font-bold 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 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>
          </div>
        )}
      </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 [guestName, setGuestName] = useState(player.name)
  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 trimmedName = guestName.trim()
        if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName
        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="flex flex-col gap-2">
                <Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
                  Navn
                </Label>
                <Input
                  id={`edit-guest-name-${player.id}`}
                  value={guestName}
                  onChange={(e) => setGuestName(e.target.value)}
                  className="h-11 rounded-xl text-base"
                />
              </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}`}
              inputMode="decimal"
              type="number"
              step="0.1"
              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)

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

  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 && (
        <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>
        </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.

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"
  }

  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>

          {players.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}`
                        }
                        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) -- samme verdi som ville blitt
                          // trukket fra ved utfylling, kun når spilleren faktisk
                          // mottar minst ett slag her (ingen støy på de andre).
                          apiHole?.strokes_received != null &&
                          apiHole.strokes_received > 0 && (
                            <span className="text-[10px] font-semibold tabular-nums text-info">
                              −{apiHole.strokes_received}
                            </span>
                          )
                        )}
                      </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>
            )
          })}
          {showMatchColors && (
            <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.
function ScorecardCell({
  value,
  diff,
  pickedUp,
}: {
  value: number | null
  diff: number | null
  pickedUp?: boolean
}) {
  const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
  // "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-[10px] text-brand-orange")}>
        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.
function golfTermForScore(n: number, par: number): string | null {
  const diff = n - par
  if (diff <= -3) return "Albatross"
  if (diff === -2) return "Eagle"
  if (diff === -1) return "Birdie"
  if (diff === 0) return "Par"
  if (diff === 1) return "Bogey"
  if (diff === 2) return "Dobbel bogey"
  return null
}

// --- 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}`
                        }
                        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. */}
                        {!hasScore && apiHole?.strokes_received != null && apiHole.strokes_received > 0 && (
                          <span className="text-[10px] font-semibold tabular-nums text-info">
                            −{apiHole.strokes_received}
                          </span>
                        )}
                      </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>
            )
          })}
          {showMatchColors && (
            <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>
  )
}

function NumberPicker({
  label,
  value,
  directValues,
  expandValues,
  expandLabel,
  parValue,
  showGolfTerms,
  maxValue,
  onChange,
  readOnly,
}: {
  label: string
  value: number | null
  directValues: number[]
  expandValues: number[]
  expandLabel: string
  // Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
  parValue?: number
  // Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til
  // parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt
  // for Slag-velgeren, ikke Putter/Anywayslag.
  showGolfTerms?: boolean
  // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
  // antall slag registrert på hullet) -- ingen vits i å tilby et tall som
  // uansett ville vært selvmotsigende.
  maxValue?: number
  onChange: (value: number) => void
  readOnly: boolean
}) {
  const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue))
  const cappedDirect = cap(directValues)
  const cappedExpand = cap(expandValues)

  const valueInExpand = value !== null && cappedExpand.includes(value)
  const [expanded, setExpanded] = useState(false)
  const showExpanded = expanded || valueInExpand

  const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect

  return (
    <div className="flex flex-col gap-2">
      <span className="text-base font-semibold text-foreground">{label}</span>
      {/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
          for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */}
      <div className="grid max-w-sm grid-cols-3 gap-2.5">
        {visibleValues.map((n) => {
          const selected = value === n
          const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
          return (
            <button
              key={n}
              type="button"
              disabled={readOnly}
              onClick={() => onChange(n)}
              aria-label={term ? `${label} ${n}, ${term}` : `${label} ${n}`}
              aria-pressed={selected}
              className={cn(
                "flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
                selected
                  ? "border-primary bg-primary text-primary-foreground shadow-sm"
                  : "border-border bg-card text-foreground hover:bg-accent/50",
                readOnly && !selected && "opacity-40",
              )}
            >
              <span>{n}</span>
              {term && (
                <span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
                  {term}
                </span>
              )}
            </button>
          )
        })}
        {!showExpanded && !readOnly && cappedExpand.length > 0 && (
          <button
            type="button"
            onClick={() => setExpanded(true)}
            className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
          >
            {expandLabel}
          </button>
        )}
      </div>
    </div>
  )
}

// --- Retningskors (D-pad for Utslag / Innspill) -----------------------------

function DirButton({
  icon: Icon,
  label,
  selected,
  onClick,
  readOnly,
}: {
  icon: typeof ArrowUp
  label: string
  selected: boolean
  onClick: () => void
  readOnly: boolean
}) {
  return (
    <button
      type="button"
      disabled={readOnly}
      onClick={onClick}
      aria-pressed={selected}
      className={cn(
        "flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
        selected
          ? "border-primary bg-primary text-primary-foreground shadow-sm"
          : "border-border bg-card text-foreground hover:bg-accent/50",
        readOnly && !selected && "opacity-40",
      )}
    >
      <Icon aria-hidden="true" className="size-6" />
      <span className="text-sm font-bold">{label}</span>
    </button>
  )
}

function DirectionCross({
  label,
  value,
  onChange,
  readOnly,
  variant,
  centerLabel,
}: {
  label: string
  value: string | null
  onChange: (value: string) => void
  readOnly: boolean
  // "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill)
  variant: "horizontal" | "full"
  centerLabel: string
}) {
  const spacer = <div aria-hidden="true" />
  return (
    <div className="flex flex-col gap-2">
      <span className="text-base font-semibold text-foreground">{label}</span>
      <div className="grid max-w-sm grid-cols-3 gap-2.5">
        {variant === "full" && (
          <>
            {spacer}
            <DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
            {spacer}
          </>
        )}
        <DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
        <DirButton
          icon={Target}
          label={centerLabel}
          selected={value === (variant === "full" ? "hit" : "fairway")}
          onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
          readOnly={readOnly}
        />
        <DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
        {variant === "full" && (
          <>
            {spacer}
            <DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
            {spacer}
          </>
        )}
      </div>
    </div>
  )
}

// --- Choice row (segmented buttons) ----------------------------------------

function ChoiceRow({
  label,
  options,
  value,
  onChange,
  readOnly,
}: {
  label: string
  options: { value: string; label: string }[]
  value: string | null
  onChange: (value: string) => void
  readOnly: boolean
}) {
  return (
    <div className="flex flex-col gap-2">
      <span className="text-base font-semibold text-foreground">{label}</span>
      <div className="flex flex-wrap gap-2">
        {options.map((opt) => {
          const selected = value === opt.value
          return (
            <button
              key={opt.value}
              type="button"
              disabled={readOnly}
              onClick={() => onChange(opt.value)}
              aria-pressed={selected}
              className={cn(
                "flex min-h-12 flex-1 items-center justify-center rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-100",
                selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
                readOnly && !selected && "opacity-40",
              )}
            >
              {opt.label}
            </button>
          )
        })}
      </div>
    </div>
  )
}

// --- Stepper (+/-) ---------------------------------------------------------

function Stepper({
  label,
  value,
  onChange,
  readOnly,
  max,
}: {
  label: string
  value: number
  onChange: (value: number) => void
  readOnly: boolean
  // Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
  // umulig å ha chippet flere ganger enn totalt antall slag.
  max?: number
}) {
  const atMax = max !== undefined && value >= max
  return (
    <div className="flex flex-col gap-2">
      <span className="text-base font-semibold text-foreground">{label}</span>
      <div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
        <button
          type="button"
          disabled={readOnly || value <= 0}
          onClick={() => onChange(Math.max(0, value - 1))}
          aria-label={`Færre ${label}`}
          className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
        >
          <Minus aria-hidden="true" className="size-5" />
        </button>
        <span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
        <button
          type="button"
          disabled={readOnly || atMax}
          onClick={() => onChange(value + 1)}
          aria-label={`Flere ${label}`}
          className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
        >
          <Plus aria-hidden="true" className="size-5" />
        </button>
      </div>
    </div>
  )
}
