"""
Scoring (ADR-012): to moduser per økt, cachet matchstatus.

'stroke'      -> hole_score (brutto per hull, motoren utleder netto/hull-resultat).
'hole_result' -> match_hole_result (bare hvem som vant hullet, ingen motor for
                 selve hull-resultatet -- handicapen er allerede bakt inn i
                 match_participant fra oppsettsrunden).

Autorisasjon: begrenset til den enkelte matchens FAKTISKE deltakere (en
match_participant-rad for brukeren i akkurat denne matchen), ikke bare
"noen på laget" og UAVHENGIG av kapteinmerket -- se app/team_authz.py sin
user_is_match_participant. Byttet fra "rostret på laget" 2026-07-19
(Brukerroller-runden, ADR-023).

Individuell-vs-delt-ball (FEATURE_BACKLOG sitt app-lags-punkt, lukkes her):
singles/fourball MÅ ha match_participant_id; foursome/greensome/scramble MÅ
IKKE ha det. Ingenting i skjemaet håndhever dette.

Upsert i stedet for avvisning ved dobbel-innsending -- bevisst valg for at
live-scoring skal tåle rettelser. Ingen audit-trail på korrigeringer ennå.

Matchstatus-recompute låser match-raden FØRST (FOR UPDATE) for å serialisere
samtidige scoreinnsendinger for SAMME match (typisk to fourball-partnere som
taster inn ulike hull samtidig) -- uten det kan to transaksjoner lese
hverandres data før commit og siste UPDATE kan overskrive en mer komplett
status med en mindre komplett.

"Hopp over uferdige hull": compute_match_state teller POSISJON i listen
(len(hole_results)), ikke hullnummer. Derfor tas kun den SAMMENHENGENDE
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.

Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til
akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/
`leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når
matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske
hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE
ikke-avgjorte matcher laget har, i én operasjon).
"""

import json

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

from handicap_engine import (
    HoleResult,
    allocate_over_played_holes,
    compute_match_state,
    high_low_high_points_for_hole,
    high_low_high_running_score,
    money_ball_hole_score,
    shamble_hole_score,
    stableford_points_for_hole,
)

from ..auth import CurrentUser, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..realtime import broadcast_live_update
from ..hole_history import resolve_match_course_key
from ..target_points import TargetPoint, get_target_points
from ..team_authz import (
    is_org_member,
    user_is_match_participant,
    user_is_team_captain,
    user_is_tournament_participant,
)

router = APIRouter()

_INDIVIDUAL_FORMATS = {"singles", "fourball", "shamble", "money_ball", "high_low_high"}


def played_hole_numbers(hole_config: str) -> list[int]:
    if hole_config == "front_9":
        return list(range(1, 10))
    if hole_config == "back_9":
        return list(range(10, 19))
    return list(range(1, 19))


def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int) -> int | None:
    """Sidens NETTO for ett hull, eller None hvis ikke avgjørbart ennå."""
    if format_ not in _INDIVIDUAL_FORMATS:
        if len(rows) != 1:
            return None
        r = rows[0]
        per_hole = strokes_per_hole.get(r["team_side"])
        if per_hole is None or hole_number not in per_hole:
            return None
        return r["gross_strokes"] - per_hole[hole_number]

    # singles: forventer nøyaktig 1 rad. fourball: forventer nøyaktig 2
    # (begge partnere) og tar den BESTE (laveste) netto -- klassisk
    # better-ball-regel. Ufullstendig antall rader = ikke avgjørbart ennå.
    expected_rows = 1 if format_ == "singles" else 2
    if len(rows) != expected_rows:
        return None
    nets = []
    for r in rows:
        per_hole = strokes_per_hole.get(r["match_participant_id"])
        if per_hole is None or hole_number not in per_hole:
            return None
        nets.append(r["gross_strokes"] - per_hole[hole_number])
    return min(nets)


async def _compute_shamble_hole_results(conn, match_id: str, match, played: list[int]) -> list[HoleResult]:
    """Shamble (2026-07-30): lagets hullscore = sum av de `session.
    shamble_best_n` laveste INDIVIDUELLE brutto-scorene blant sidens
    spillere -- rå brutto, INGEN handicap-justering (kilden nevner ingen
    egen allowance for selve hullscoren, kun for hvem som spiller sammen).
    Krever at BEGGE sider har akkurat like mange registrerte spillere som
    faktisk står på laget (variabel 2-4, satt av organisator) -- egen,
    selvstendig sti, IKKE _side_net (som forutsetter en FAST enhetsstørrelse
    per format)."""
    best_n = match["shamble_best_n"]
    if best_n is None:
        return []

    score_rows = await conn.fetch(
        "SELECT hole_number, team_side::text AS team_side, gross_strokes "
        "FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
        match_id,
    )
    by_hole_side: dict[tuple[int, str], list[int]] = {}
    for r in score_rows:
        by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r["gross_strokes"])

    side_size_rows = await conn.fetch(
        "SELECT team_side::text AS team_side, count(*) AS n FROM match_participant "
        "WHERE match_id = $1 GROUP BY team_side",
        match_id,
    )
    expected_by_side = {r["team_side"]: r["n"] for r in side_size_rows}

    results = []
    for h in played:
        scores_a = by_hole_side.get((h, "a"), [])
        scores_b = by_hole_side.get((h, "b"), [])
        if len(scores_a) != expected_by_side.get("a", -1) or len(scores_b) != expected_by_side.get("b", -1):
            break
        if not (1 <= best_n <= len(scores_a)) or not (1 <= best_n <= len(scores_b)):
            break
        score_a = shamble_hole_score(scores_a, best_n)
        score_b = shamble_hole_score(scores_b, best_n)
        if score_a < score_b:
            results.append(HoleResult.SIDE_A)
        elif score_b < score_a:
            results.append(HoleResult.SIDE_B)
        else:
            results.append(HoleResult.HALVED)
    return results


async def _compute_money_ball_hole_results(conn, match_id: str, played: list[int]) -> list[HoleResult]:
    """Money Ball/Lone Ranger (2026-07-30): lagets hullscore = money-ball-
    spillerens brutto + laveste av de tre andre -- rå brutto, INGEN
    handicap-justering (samme begrunnelse som Shamble). Rotasjonen er
    basert på POSISJON i den spilte rekkefølgen (1. spilte hull =
    rotasjonsindeks 0), ikke rått hullnummer."""
    score_rows = await conn.fetch(
        "SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, "
        "gross_strokes FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
        match_id,
    )
    by_hole_side: dict[tuple[int, str], dict[str, int]] = {}
    for r in score_rows:
        by_hole_side.setdefault((r["hole_number"], r["team_side"]), {})[r["match_participant_id"]] = r["gross_strokes"]

    lineup_rows = await conn.fetch(
        "SELECT id::text AS id, team_side::text AS team_side, lineup_order "
        "FROM match_participant WHERE match_id = $1",
        match_id,
    )
    ordered_by_side: dict[str, list[str]] = {"a": [None] * 4, "b": [None] * 4}
    for r in lineup_rows:
        if r["team_side"] in ordered_by_side and r["lineup_order"] is not None:
            ordered_by_side[r["team_side"]][r["lineup_order"]] = r["id"]

    results = []
    for position, h in enumerate(played, start=1):
        side_scores: dict[str, int] = {}
        ok = True
        for side in ("a", "b"):
            order = ordered_by_side[side]
            if any(pid is None for pid in order):
                ok = False
                break
            hole_scores = by_hole_side.get((h, side), {})
            if len(hole_scores) != 4 or any(pid not in hole_scores for pid in order):
                ok = False
                break
            scores_in_order = [hole_scores[pid] for pid in order]
            side_scores[side] = money_ball_hole_score(scores_in_order, position)
        if not ok:
            break
        if side_scores["a"] < side_scores["b"]:
            results.append(HoleResult.SIDE_A)
        elif side_scores["b"] < side_scores["a"]:
            results.append(HoleResult.SIDE_B)
        else:
            results.append(HoleResult.HALVED)
    return results


async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
    played = played_hole_numbers(match["hole_config"])

    if match["scoring_mode"] == "hole_result":
        rows = await conn.fetch(
            """
            SELECT hole_number, winning_side::text AS winning_side
            FROM match_hole_result WHERE match_id = $1
            """,
            match_id,
        )
        by_hole = {r["hole_number"]: r["winning_side"] for r in rows}
        results = []
        for h in played:
            if h not in by_hole:
                break
            ws = by_hole[h]
            results.append(
                HoleResult.SIDE_A if ws == "a" else HoleResult.SIDE_B if ws == "b" else HoleResult.HALVED
            )
        return results

    if match["format"] == "shamble":
        return await _compute_shamble_hole_results(conn, match_id, match, played)

    if match["format"] == "money_ball":
        return await _compute_money_ball_hole_results(conn, match_id, played)

    if match["format"] == "high_low_high":
        # Passer IKKE inn i den ternære HoleResult-modellen under (som
        # forutsetter ÉN sammenlignbar verdi per side per hull) -- High-
        # low-high trenger BEGGE spilleres individuelle Stableford-poeng
        # for selve høy/lav-rangeringen, og literal poengsum (0/1/2 per
        # hull), ikke bare hvem som vant. Egen, dedikert GET-endepunkt
        # (/high-low-high) dekker den faktiske stillingen -- match sin
        # vanlige status_text/leading_side forblir bevisst null for dette
        # formatet i v1 (samme "regn ut ved lesing i et eget endepunkt"-
        # mønster som Nassau, ikke tvunget inn i denne cachen).
        return []

    # 'stroke'-modus
    allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
    config = parse_allowance_config(match["format"], allowance_override)
    relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
    if not relative:
        return []  # handicap ikke klart for én eller flere enheter ennå

    stroke_index_rows = await conn.fetch(
        "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
        match["course_id"],
    )
    all_18_si = [r["stroke_index"] for r in stroke_index_rows]
    # Uten alle 18 hull (f.eks. en egendefinert bane der hull aldri ble lagt
    # til) kan slagfordelingen (allocate_over_played_holes) ikke gjøres --
    # avvis tydelig FØR beregning i stedet for å krasje rått på tom liste.
    if len(all_18_si) != 18:
        raise app_error(
            400,
            "VALIDATION_FAILED",
            "Banen mangler registrerte hull -- kan ikke beregne slagfordeling for stroke-modus.",
        )

    strokes_per_hole: dict[str, dict[int, int]] = {
        unit: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
        for unit, total in relative.items()
    }

    score_rows = await conn.fetch(
        """
        SELECT hole_number, team_side::text AS team_side,
               match_participant_id::text AS match_participant_id, gross_strokes
        FROM hole_score WHERE match_id = $1
        """,
        match_id,
    )
    by_hole_side: dict[tuple[int, str], list] = {}
    for r in score_rows:
        by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r)

    results = []
    for h in played:
        net_a = _side_net(by_hole_side.get((h, "a"), []), strokes_per_hole, match["format"], h)
        net_b = _side_net(by_hole_side.get((h, "b"), []), strokes_per_hole, match["format"], h)
        if net_a is None or net_b is None:
            break
        if net_a < net_b:
            results.append(HoleResult.SIDE_A)
        elif net_b < net_a:
            results.append(HoleResult.SIDE_B)
        else:
            results.append(HoleResult.HALVED)
    return results


async def recompute_and_cache_match_state(conn, match_id: str) -> None:
    match = await conn.fetchrow(
        """
        SELECT m.id::text, s.format, s.scoring_mode,
               s.hole_config::text AS hole_config, s.course_id::text AS course_id,
               s.allowance_override::text AS allowance_override,
               s.points_per_match::float AS points_per_match,
               s.tournament_id::text AS tournament_id, s.shamble_best_n
        FROM match m JOIN session s ON s.id = m.session_id
        WHERE m.id = $1
        FOR UPDATE OF m
        """,
        match_id,
    )
    results = await _compute_hole_results(conn, match_id, match)
    played = played_hole_numbers(match["hole_config"])
    state = compute_match_state(results, total_holes=len(played))

    # ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
    # (i tillegg til den menneskelesbare status_text), slik at frontend kan
    # style etter et strukturert felt i stedet for å parse tekst. Gjelder
    # BÅDE pågående og avgjorte matcher -- vinnersiden i en avgjort match
    # er alltid den samme som lead sitt fortegn allerede pekte på.
    if state.lead > 0:
        leading_side = "a"
    elif state.lead < 0:
        leading_side = "b"
    else:
        leading_side = None

    complete = state.is_closed or state.holes_remaining == 0
    if complete:
        ppm = match["points_per_match"]
        if state.lead > 0:
            points_a, points_b = ppm, 0.0
        elif state.lead < 0:
            points_a, points_b = 0.0, ppm
        else:
            points_a = points_b = ppm / 2
        await conn.execute(
            """
            UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3,
                              leading_side = $4
            WHERE id = $5
            """,
            state.describe(),
            points_a,
            points_b,
            leading_side,
            match_id,
        )
    else:
        await conn.execute(
            "UPDATE match SET status_text = $1, leading_side = $2 WHERE id = $3",
            state.describe(),
            leading_side,
            match_id,
        )

    # ADR-027: "Følg live"-siden lyttes til over WebSocket -- kringkast et
    # "noe endret seg, hent på nytt"-signal til enhver som følger DENNE
    # turneringen. Trygt å kalle uansett om noen faktisk lytter (no-op da).
    await broadcast_live_update(match["tournament_id"])


class ConcedeOut(BaseModel):
    match_id: str
    status_text: str
    points_side_a: float
    points_side_b: float
    leading_side: str


async def apply_concession(
    conn, match_id: str, conceding_side: str, points_per_match: float, tournament_id: str
) -> ConcedeOut:
    """Setter matchen som avgjort ved walkover -- full poengsum til motstanderen,
    uansett hvor mange hull som ev. allerede er registrert (marginen betyr
    ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv
    ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er
    avgjort FØR denne kalles -- se de to kallstedene (denne filens
    concede_match, tournaments.py sin concede_tournament).
    `tournament_id` trengs KUN for ADR-027 sin "Følg live"-kringkasting."""
    winner = "b" if conceding_side == "a" else "a"
    points_a = 0.0 if conceding_side == "a" else points_per_match
    points_b = points_per_match if conceding_side == "a" else 0.0
    status_text = f"Walkover ({winner.upper()})"
    await conn.execute(
        """
        UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4
        WHERE id = $5
        """,
        status_text,
        points_a,
        points_b,
        winner,
        match_id,
    )
    await broadcast_live_update(tournament_id)
    return ConcedeOut(
        match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner
    )


class ConcedeRequest(BaseModel):
    conceding_team_id: str


@router.post(
    "/orgs/{organization_id}/matches/{match_id}/concede",
    response_model=ConcedeOut,
    status_code=201,
)
async def concede_match(
    match_id: str,
    body: ConcedeRequest,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> ConcedeOut:
    """Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget
    som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier
    på motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen
    bekreftelse fra motparten kreves -- samme tillitsnivå som all annen
    scoring. Kan erklæres uansett hvor mange hull som allerede er
    registrert."""
    async with org_connection(organization_id) as conn, translate_db_errors():
        match = await conn.fetchrow(
            """
            SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
                   m.points_side_a::float AS points_side_a,
                   s.points_per_match::float AS points_per_match,
                   s.tournament_id::text AS tournament_id
            FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            FOR UPDATE OF m
            """,
            match_id,
        )
        if match is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if match["points_side_a"] is not None:
            raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")

        if body.conceding_team_id == match["team_a_id"]:
            conceding_side = "a"
        elif body.conceding_team_id == match["team_b_id"]:
            conceding_side = "b"
        else:
            raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.")

        if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
            raise app_error(
                403,
                "NOT_TEAM_CAPTAIN",
                "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.",
            )

        result = await apply_concession(
            conn, match_id, conceding_side, match["points_per_match"], match["tournament_id"]
        )
    return result


class HoleScoreCreate(BaseModel):
    team_side: str = Field(pattern="^[ab]$")
    match_participant_id: str | None = None
    hole_number: int = Field(ge=1, le=18)
    gross_strokes: int = Field(ge=1, le=20)
    # Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme
    # funksjon som frittstående runder fikk 2026-07-25). KUN meningsfullt for
    # delt-ball-hull (match_participant_id er None) -- helt valgfritt.
    selected_participant_id: str | None = None
    # Optimistisk versjonssjekk (ADR-060, samme mønster som round_hole/
    # ADR-057). None = hopp over sjekken -- eneste rimelige verdi for et
    # hull som ikke er registrert fra før (klienten kan ikke kjenne en
    # versjon som ikke finnes ennå), og fortsatt bakoverkompatibelt med
    # eldre klienter som ikke sender feltet.
    expected_version: int | None = None


class HoleScoreOut(BaseModel):
    hole_number: int
    team_side: str
    match_participant_id: str | None
    gross_strokes: int
    selected_participant_id: str | None = None
    version: int


@router.post(
    "/orgs/{organization_id}/matches/{match_id}/hole-scores",
    response_model=HoleScoreOut,
    status_code=201,
)
async def submit_hole_score(
    match_id: str,
    body: HoleScoreCreate,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> HoleScoreOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        match = await conn.fetchrow(
            """
            SELECT m.points_side_a::float AS points_side_a,
                   s.format, s.scoring_mode, s.hole_config::text AS hole_config
            FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if match is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if match["points_side_a"] is not None:
            raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
        if match["scoring_mode"] != "stroke":
            raise app_error(
                400,
                "WRONG_SCORING_MODE",
                "Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
            )

        played = played_hole_numbers(match["hole_config"])
        if body.hole_number not in played:
            raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")

        is_individual = match["format"] in _INDIVIDUAL_FORMATS
        if is_individual and body.match_participant_id is None:
            raise app_error(
                400,
                "WRONG_PARTICIPANT_MODE",
                "Dette formatet krever match_participant_id (individuell ball).",
            )
        if not is_individual and body.match_participant_id is not None:
            raise app_error(
                400,
                "WRONG_PARTICIPANT_MODE",
                "Dette formatet bruker delt ball -- oppgi ikke match_participant_id.",
            )

        if body.match_participant_id is not None:
            participant = await conn.fetchrow(
                """
                SELECT team_side::text AS team_side FROM match_participant
                WHERE id = $1 AND match_id = $2
                """,
                body.match_participant_id,
                match_id,
            )
            if participant is None:
                raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.")
            if participant["team_side"] != body.team_side:
                raise app_error(
                    400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
                )

        # Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039) --
        # gir kun mening for delt-ball-hull, og må tilhøre samme side som
        # selve scoren registreres for (samme sjekk som round.py sin
        # tilsvarende validering for frittstående runder).
        if body.selected_participant_id is not None:
            if body.match_participant_id is not None:
                raise app_error(
                    400,
                    "WRONG_PARTICIPANT_MODE",
                    "selected_participant_id gjelder kun delt-ball-hull, ikke individuell ball.",
                )
            selected = await conn.fetchrow(
                "SELECT team_side::text AS team_side FROM match_participant WHERE id = $1 AND match_id = $2",
                body.selected_participant_id,
                match_id,
            )
            if selected is None:
                raise app_error(404, "NOT_FOUND", "Den valgte spilleren finnes ikke i denne matchen.")
            if selected["team_side"] != body.team_side:
                raise app_error(
                    400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
                )

        if not await user_is_match_participant(
            conn, organization_id, match_id, user.user_id, team_side=body.team_side
        ):
            raise app_error(
                403,
                "NOT_MATCH_PARTICIPANT",
                "Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
            )

        # Versjonssjekken (ADR-060) trengs KUN på DO UPDATE-grenen -- en helt
        # ny rad (første registrering av dette hullet) tar alltid den rene
        # INSERT-veien og bryr seg aldri om expected_version, uansett hva
        # klienten sendte (det finnes ingen tidligere versjon å sjekke mot).
        # Et `row is None`-resultat herfra betyr derfor ALLTID en ekte
        # versjonskonflikt -- aldri "finnes ikke" (INSERT-grenen dekker det
        # tilfellet transparent), ulikt round_hole som trengte en egen
        # eksistenssjekk for å skille de to.
        if body.match_participant_id is not None:
            row = await conn.fetchrow(
                """
                INSERT INTO hole_score
                    (organization_id, match_id, team_side, match_participant_id, hole_number, gross_strokes)
                VALUES ($1, $2, $3, $4, $5, $6)
                ON CONFLICT (match_participant_id, hole_number) WHERE match_participant_id IS NOT NULL
                DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes, updated_at = now(),
                              version = hole_score.version + 1
                WHERE $7::int IS NULL OR hole_score.version = $7::int
                RETURNING hole_number, team_side::text AS team_side,
                          match_participant_id::text AS match_participant_id, gross_strokes,
                          selected_participant_id::text AS selected_participant_id, version
                """,
                organization_id,
                match_id,
                body.team_side,
                body.match_participant_id,
                body.hole_number,
                body.gross_strokes,
                body.expected_version,
            )
        else:
            row = await conn.fetchrow(
                """
                INSERT INTO hole_score
                    (organization_id, match_id, team_side, match_participant_id, hole_number,
                     gross_strokes, selected_participant_id)
                VALUES ($1, $2, $3, NULL, $4, $5, $6)
                ON CONFLICT (match_id, team_side, hole_number) WHERE match_participant_id IS NULL
                DO UPDATE SET gross_strokes = EXCLUDED.gross_strokes,
                              selected_participant_id = EXCLUDED.selected_participant_id,
                              updated_at = now(), version = hole_score.version + 1
                WHERE $7::int IS NULL OR hole_score.version = $7::int
                RETURNING hole_number, team_side::text AS team_side,
                          match_participant_id::text AS match_participant_id, gross_strokes,
                          selected_participant_id::text AS selected_participant_id, version
                """,
                organization_id,
                match_id,
                body.team_side,
                body.hole_number,
                body.gross_strokes,
                body.selected_participant_id,
                body.expected_version,
            )
        if row is None:
            raise app_error(
                409, "STALE_VERSION",
                "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
            )

        await recompute_and_cache_match_state(conn, match_id)
    return HoleScoreOut(**dict(row))


class HoleResultCreate(BaseModel):
    hole_number: int = Field(ge=1, le=18)
    winning_side: str | None = Field(default=None, pattern="^[ab]$")
    # Se HoleScoreCreate.expected_version -- samme begrunnelse, samme mønster.
    expected_version: int | None = None


class HoleResultOut(BaseModel):
    hole_number: int
    winning_side: str | None
    version: int


@router.post(
    "/orgs/{organization_id}/matches/{match_id}/hole-results",
    response_model=HoleResultOut,
    status_code=201,
)
async def submit_hole_result(
    match_id: str,
    body: HoleResultCreate,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> HoleResultOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        match = await conn.fetchrow(
            """
            SELECT m.points_side_a::float AS points_side_a,
                   s.scoring_mode, s.hole_config::text AS hole_config
            FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if match is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if match["points_side_a"] is not None:
            raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
        if match["scoring_mode"] != "hole_result":
            raise app_error(
                400,
                "WRONG_SCORING_MODE",
                "Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
            )

        played = played_hole_numbers(match["hole_config"])
        if body.hole_number not in played:
            raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")

        # Begge sider kan rapportere et hull-resultat (hvilken som helst side kan
        # vinne/tape/dele) -- brukeren må selv være deltaker i DENNE matchen,
        # på hvilken som helst av de to sidene (team_side=None).
        if not await user_is_match_participant(conn, organization_id, match_id, user.user_id):
            raise app_error(
                403,
                "NOT_MATCH_PARTICIPANT",
                "Du er ikke en av deltakerne i denne matchen (eller organisasjonsadministrator).",
            )

        row = await conn.fetchrow(
            """
            INSERT INTO match_hole_result (organization_id, match_id, hole_number, winning_side)
            VALUES ($1, $2, $3, $4)
            ON CONFLICT (match_id, hole_number)
            DO UPDATE SET winning_side = EXCLUDED.winning_side,
                          version = match_hole_result.version + 1
            WHERE $5::int IS NULL OR match_hole_result.version = $5::int
            RETURNING hole_number, winning_side::text AS winning_side, version
            """,
            organization_id,
            match_id,
            body.hole_number,
            body.winning_side,
            body.expected_version,
        )
        if row is None:
            raise app_error(
                409, "STALE_VERSION",
                "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
            )

        await recompute_and_cache_match_state(conn, match_id)
    return HoleResultOut(**dict(row))


class ScorecardHole(BaseModel):
    hole_number: int
    result: str


class Scorecard(BaseModel):
    match_id: str
    status_text: str | None
    points_side_a: float | None
    points_side_b: float | None
    holes: list[ScorecardHole]
    # Rå oppføringer -- UTEN dette kan ikke skjermen vise/redigere hva som
    # faktisk er tastet inn, kun det utledede vinn/tap/delt-resultatet
    # (utilstrekkelig for en gjenlastet scorekort-side). Nøyaktig ett av de
    # to feltene er fylt ut, avhengig av øktens scoring_mode.
    stroke_entries: list[HoleScoreOut] | None = None
    hole_result_entries: list[HoleResultOut] | None = None


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


async def fetch_scorecard(conn, match_id: str) -> Scorecard:
    """Delt mellom den innloggede get_scorecard under og den offentlige
    /public/tournaments/{id}/matches/{id}/scorecard (registration.py,
    tilskuer-rolle/ADR-026). Ingen egen synlighetsfiltrering her -- den
    offentlige ruten avgjør SELV, FØR denne kalles, om matchen i det hele
    tatt er avslørt (blind draw, ADR-013) og om turneringen er synlig."""
    match = await conn.fetchrow(
        """
        SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
               m.points_side_b::float AS points_side_b,
               s.format, s.scoring_mode, s.hole_config::text AS hole_config,
               s.course_id::text AS course_id, s.allowance_override::text AS allowance_override,
               s.shamble_best_n
        FROM match m JOIN session s ON s.id = m.session_id
        WHERE m.id = $1
        """,
        match_id,
    )
    if match is None:
        raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
    results = await _compute_hole_results(conn, match_id, match)

    stroke_entries = None
    hole_result_entries = None
    if match["scoring_mode"] == "stroke":
        rows = await conn.fetch(
            """
            SELECT hole_number, team_side::text AS team_side,
                   match_participant_id::text AS match_participant_id, gross_strokes,
                   selected_participant_id::text AS selected_participant_id, version
            FROM hole_score WHERE match_id = $1 ORDER BY hole_number
            """,
            match_id,
        )
        stroke_entries = [HoleScoreOut(**dict(r)) for r in rows]
    else:
        rows = await conn.fetch(
            """
            SELECT hole_number, winning_side::text AS winning_side, version
            FROM match_hole_result WHERE match_id = $1 ORDER BY hole_number
            """,
            match_id,
        )
        hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]

    played = played_hole_numbers(match["hole_config"])
    holes = [
        ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
    ]
    return Scorecard(
        stroke_entries=stroke_entries,
        hole_result_entries=hole_result_entries,
        match_id=match["id"],
        status_text=match["status_text"],
        points_side_a=match["points_side_a"],
        points_side_b=match["points_side_b"],
        holes=holes,
    )


@router.get(
    "/orgs/{organization_id}/matches/{match_id}/scorecard",
    response_model=Scorecard,
)
async def get_scorecard(
    match_id: str,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> Scorecard:
    """2026-07-21 (deltaker-tilgang-runden): denne hadde tidligere INGEN
    egen sjekk utover get_authorized_org (org-medlemskap) -- å bare fjerne
    den ville latt EN HVILKEN SOM HELST autentisert bruker se ethvert
    scorekort i organisasjonen. Erstattet med et eksplisitt OR: org-medlem
    (bevarer eksisterende oppførsel for dagens brukere uendret) ELLER
    deltaker i turneringen matchen hører til (ny -- gir en rostret/påmeldt
    spiller uten org-medlemskap lesetilgang til scorekort i EGEN turnering,
    samme brede "kan se andres matcher i egen turnering"-nivå som
    tilskuer-siden ADR-026 allerede gir enhver som kan se turneringen)."""
    async with org_connection(organization_id) as conn:
        tournament_id = await conn.fetchval(
            """
            SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if tournament_id is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
            conn, organization_id, tournament_id, user.user_id
        ):
            raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
        return await fetch_scorecard(conn, match_id)


@router.get(
    "/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points",
    response_model=list[TargetPoint],
)
async def get_match_hole_target_points(
    match_id: str,
    hole_number: int,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> list[TargetPoint]:
    """Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
    som frittstående runders og individuell-turnerings target-points, her
    for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som
    `get_scorecard` over (org-medlem ELLER deltaker i turneringen)."""
    async with org_connection(organization_id) as conn:
        tournament_id = await conn.fetchval(
            """
            SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if tournament_id is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
            conn, organization_id, tournament_id, user.user_id
        ):
            raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
        course_key = await resolve_match_course_key(conn, match_id)
        points = await get_target_points(conn, course_key, hole_number)
    return points


# ---------------------------------------------------------------------------
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
# data (_compute_hole_results, allerede posisjonert 1:1 mot hullnummer for
# hole_config='full_18' -- se played_hole_numbers), hver kjørt gjennom den
# allerede eksisterende compute_match_state. Samme "regn ut ved lesing,
# aldri cache"-filosofi som resten av formatresultat-endepunktene.
# ---------------------------------------------------------------------------

class NassauWindowOut(BaseModel):
    lead: int
    holes_played: int
    holes_remaining: int
    is_closed: bool
    is_dormie: bool
    status_text: str


class NassauResultOut(BaseModel):
    ready: bool
    front9: NassauWindowOut | None = None
    back9: NassauWindowOut | None = None
    overall: NassauWindowOut | None = None


def _nassau_window(hole_results: list[HoleResult], total_holes: int) -> NassauWindowOut:
    state = compute_match_state(hole_results, total_holes=total_holes)
    return NassauWindowOut(
        lead=state.lead,
        holes_played=state.holes_played,
        holes_remaining=state.holes_remaining,
        is_closed=state.is_closed,
        is_dormie=state.is_dormie,
        status_text=state.describe(),
    )


@router.get(
    "/orgs/{organization_id}/matches/{match_id}/nassau",
    response_model=NassauResultOut,
)
async def get_nassau_result(
    match_id: str,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> NassauResultOut:
    """Samme autorisasjon som get_scorecard over -- org-medlem ELLER
    deltaker i turneringen matchen hører til."""
    async with org_connection(organization_id) as conn:
        match = await conn.fetchrow(
            """
            SELECT m.id::text AS id, s.tournament_id::text AS tournament_id,
                   s.format, s.scoring_mode, s.hole_config::text AS hole_config,
                   s.course_id::text AS course_id,
                   s.allowance_override::text AS allowance_override, s.shamble_best_n
            FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if match is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
            conn, organization_id, match["tournament_id"], user.user_id
        ):
            raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
        if match["hole_config"] != "full_18":
            raise app_error(400, "VALIDATION_FAILED", "Nassau krever 18 hull.")
        hole_results = await _compute_hole_results(conn, match_id, match)
    if not hole_results:
        return NassauResultOut(ready=False)
    return NassauResultOut(
        ready=True,
        front9=_nassau_window(hole_results[0:9], total_holes=9),
        back9=_nassau_window(hole_results[9:18], total_holes=9),
        overall=_nassau_window(hole_results[0:18], total_holes=18),
    )


# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- egen, dedikert visning (som Nassau) --
# passer IKKE inn i den ternære HoleResult-/status_text-cachen (se
# _compute_hole_results sin egen kommentar). Beregnes helt ved LESING.
# ---------------------------------------------------------------------------

class HighLowHighHoleOut(BaseModel):
    hole_number: int
    points_a: int
    points_b: int


class HighLowHighResultOut(BaseModel):
    ready: bool
    points_a: int | None = None
    points_b: int | None = None
    holes: list[HighLowHighHoleOut] | None = None


@router.get(
    "/orgs/{organization_id}/matches/{match_id}/high-low-high",
    response_model=HighLowHighResultOut,
)
async def get_high_low_high_result(
    match_id: str,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> HighLowHighResultOut:
    """Samme autorisasjon som get_scorecard/get_nassau_result over."""
    async with org_connection(organization_id) as conn:
        match = await conn.fetchrow(
            """
            SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
                   s.tournament_id::text AS tournament_id, s.format, s.hole_config::text AS hole_config,
                   s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
            FROM match m JOIN session s ON s.id = m.session_id
            WHERE m.id = $1
            """,
            match_id,
        )
        if match is None:
            raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
        if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
            conn, organization_id, match["tournament_id"], user.user_id
        ):
            raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
        if match["format"] != "high_low_high":
            raise app_error(400, "VALIDATION_FAILED", "Denne matchen bruker ikke High-low-high.")

        allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
        config = parse_allowance_config(match["format"], allowance_override)
        relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
        if not relative:
            return HighLowHighResultOut(ready=False)

        participant_rows = await conn.fetch(
            "SELECT id::text AS id, team_side::text AS team_side FROM match_participant WHERE match_id = $1",
            match_id,
        )
        by_side: dict[str, list[str]] = {"a": [], "b": []}
        for p in participant_rows:
            by_side[p["team_side"]].append(p["id"])
        if len(by_side["a"]) != 2 or len(by_side["b"]) != 2:
            return HighLowHighResultOut(ready=False)

        stroke_index_rows = await conn.fetch(
            "SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
            match["course_id"],
        )
        all_18_si = [r["stroke_index"] for r in stroke_index_rows]
        if len(all_18_si) != 18:
            raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")

        played = played_hole_numbers(match["hole_config"])
        strokes_per_hole = {
            pid: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
            for pid, total in relative.items()
        }

        score_rows = await conn.fetch(
            "SELECT hole_number, match_participant_id::text AS match_participant_id, gross_strokes "
            "FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
            match_id,
        )
        par_rows = await conn.fetch(
            "SELECT hole_number, par FROM hole WHERE course_id = $1", match["course_id"]
        )
        par_by_hole = {r["hole_number"]: r["par"] for r in par_rows}
        gross_by_hole: dict[int, dict[str, int]] = {}
        for r in score_rows:
            gross_by_hole.setdefault(r["hole_number"], {})[r["match_participant_id"]] = r["gross_strokes"]

    hole_points: list[tuple[int, int]] = []
    holes_out = []
    for h in played:
        scores_this_hole = gross_by_hole.get(h, {})
        pids_a, pids_b = by_side["a"], by_side["b"]
        if any(pid not in scores_this_hole for pid in pids_a + pids_b):
            break
        points_a_players = tuple(
            stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
            for pid in pids_a
        )
        points_b_players = tuple(
            stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
            for pid in pids_b
        )
        pa, pb = high_low_high_points_for_hole(points_a_players, points_b_players)
        hole_points.append((pa, pb))
        holes_out.append(HighLowHighHoleOut(hole_number=h, points_a=pa, points_b=pb))

    if not hole_points:
        return HighLowHighResultOut(ready=True, points_a=0, points_b=0, holes=[])
    total_a, total_b = high_low_high_running_score(hole_points)
    return HighLowHighResultOut(ready=True, points_a=total_a, points_b=total_b, holes=holes_out)
