"""
Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering på tvers av
FLERE individuelle turneringer (`tournament.format_type = 'individual'`,
ADR-037) -- IKKE org-lagturneringer (ADR-011, poeng er der knyttet til
hele kamper, ikke enkeltspillere, samme begrensning som
konkurranseklassenes leaderboard-splitting, ADR-041).

To uavhengige akser (GolfBox-inspirert, se plan-loggen 2026-08-04):
HVA telles per lenket turnering (`result_type`: poeng-etter-plassering/
Stableford-sum/brutto/netto/pengeliste) og HVORDAN det summeres over
flere turneringer (`aggregation_mode`: sum/snitt/eclectic -- eclectic
ikke bygget i denne runden, se FEATURE_BACKLOG.md).

Gjenbruker `_compute_individual_standings` fra individual_tournaments.py
for HVER lenket turnering sin egen leaderboard/plassering -- ingen
duplisering av posisjon-/til-par-beregningen. "Regn ut ved lesing" for
selve OOM-leaderboardet også (samme filosofi som Nassau/High-low-high/
individual_leaderboard selv) -- ingen cache-tabell.

Bygget i stadier (se plan-loggen): denne runden dekker spiller-OOM med
poeng/stableford/brutto/netto/pengeliste-resultattyper, sum/snitt-
aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser.
Lag-OOM og Eclectic-aggregering er bevisst IKKE bygget ennå (egne,
senere stadier) -- se FEATURE_BACKLOG.md.
"""

from __future__ import annotations

import json
from typing import Literal

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

from ..auth import get_authorized_org
from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
    allocate_strokes_by_index,
    EclecticHoleValue,
    eclectic_best_per_hole,
    order_of_merit_aggregate,
    order_of_merit_points_for_position,
    stableford_points_for_hole,
)

from .individual_tournaments import _compute_individual_standings

router = APIRouter()

_RESULT_TYPES = Literal["points", "stableford", "gross", "net", "money"]
_AGGREGATION_MODES = Literal["sum", "average", "eclectic"]


# ---------------------------------------------------------------------------
# CRUD: selve OOM-en
# ---------------------------------------------------------------------------

class OrderOfMeritCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    kind: Literal["player", "team"]
    result_type: _RESULT_TYPES
    aggregation_mode: _AGGREGATION_MODES = "sum"
    count_best_n: int | None = Field(default=None, ge=1)
    min_results_required: int | None = Field(default=None, ge=1)
    birth_year_from: int | None = None
    birth_year_to: int | None = None
    public_visible: bool = False


class OrderOfMeritUpdate(BaseModel):
    """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
    TournamentUpdate/SponsorUpdate/TournamentClassUpdate.

    `kind` og `result_type` er IKKE med -- begge låses etter opprettelse.
    `kind` per plan (matcher GolfBox sin egen tilsvarende låsing).
    `result_type` er strukket til samme regel her: å endre den i
    etterkant ville gjort eksisterende lenkers `points_table`/
    `money_payout_table`-konfig meningsløs uten en egen migreringssti --
    opprett en ny OOM i stedet."""

    name: str | None = Field(default=None, min_length=1, max_length=200)
    aggregation_mode: _AGGREGATION_MODES | None = None
    count_best_n: int | None = None
    min_results_required: int | None = None
    birth_year_from: int | None = None
    birth_year_to: int | None = None
    public_visible: bool | None = None


class OrderOfMeritOut(BaseModel):
    id: str
    name: str
    kind: str
    result_type: str
    aggregation_mode: str
    count_best_n: int | None
    min_results_required: int | None
    birth_year_from: int | None
    birth_year_to: int | None
    public_visible: bool


_OOM_COLUMNS = """
    id::text, name, kind, result_type, aggregation_mode, count_best_n,
    min_results_required, birth_year_from, birth_year_to, public_visible
"""


@router.get("/orgs/{organization_id}/order-of-merits", response_model=list[OrderOfMeritOut])
async def list_order_of_merits(
    organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritOut]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(f"SELECT {_OOM_COLUMNS} FROM order_of_merit ORDER BY created_at DESC")
    return [OrderOfMeritOut(**dict(r)) for r in rows]


@router.post("/orgs/{organization_id}/order-of-merits", response_model=OrderOfMeritOut, status_code=201)
async def create_order_of_merit(
    body: OrderOfMeritCreate,
    organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritOut:
    if body.kind == "team" and body.aggregation_mode == "eclectic":
        raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            f"""
            INSERT INTO order_of_merit
                (organization_id, name, kind, result_type, aggregation_mode,
                 count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
            RETURNING {_OOM_COLUMNS}
            """,
            organization_id,
            body.name,
            body.kind,
            body.result_type,
            body.aggregation_mode,
            body.count_best_n,
            body.min_results_required,
            body.birth_year_from,
            body.birth_year_to,
            body.public_visible,
        )
    return OrderOfMeritOut(**dict(row))


@router.patch("/orgs/{organization_id}/order-of-merits/{oom_id}", response_model=OrderOfMeritOut)
async def update_order_of_merit(
    oom_id: str,
    body: OrderOfMeritUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritOut:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

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

    async with org_connection(organization_id) as conn, translate_db_errors():
        existing = await conn.fetchrow("SELECT kind, result_type FROM order_of_merit WHERE id = $1", oom_id)
        if existing is None:
            raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
        if updates.get("aggregation_mode") == "eclectic" and existing["kind"] == "team":
            raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
        result_type = existing["result_type"]
        effective_mode = updates.get("aggregation_mode")
        if effective_mode == "eclectic" and result_type not in ("stableford", "gross", "net"):
            raise app_error(
                400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
            )
        if effective_mode == "eclectic":
            # Slår PÅ eclectic mens flere turneringer med ulik bane
            # allerede er lenket -- avvis her også, ikke bare ved lenking
            # av en NY turnering (ADR-075).
            links = await conn.fetch(
                "SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
            )
            course_ids = set()
            for link in links:
                cid = await _course_id_for_tournament(conn, link["tournament_id"])
                if cid is not None:
                    course_ids.add(cid)
            if len(course_ids) > 1:
                raise app_error(
                    400,
                    "VALIDATION_FAILED",
                    "Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
                )
        row = await conn.fetchrow(
            f"""
            UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
            WHERE id = ${len(values)}
            RETURNING {_OOM_COLUMNS}
            """,
            *values,
        )
    return OrderOfMeritOut(**dict(row))


@router.delete("/orgs/{organization_id}/order-of-merits/{oom_id}", status_code=204)
async def delete_order_of_merit(
    oom_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        result = await conn.execute("DELETE FROM order_of_merit WHERE id = $1", oom_id)
        if result == "DELETE 0":
            raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")


# ---------------------------------------------------------------------------
# Lenkede turneringer
# ---------------------------------------------------------------------------

class OrderOfMeritLinkCreate(BaseModel):
    tournament_id: str
    # Kun gyldig (og påkrevd) når OOM-ens result_type='points': liste av
    # heltall, indeks 0 = poeng for 1. plass.
    points_table: list[int] | None = None
    # Kun gyldig (og påkrevd) når result_type='money'.
    money_pool_total: float | None = None
    money_payout_table: list[dict] | None = None


class OrderOfMeritLinkOut(BaseModel):
    id: str
    tournament_id: str
    tournament_name: str
    points_table: list[int] | None
    money_pool_total: float | None
    money_payout_table: list[dict] | None


_LINK_COLUMNS = """
    l.id::text, l.tournament_id::text, t.name AS tournament_name,
    l.points_table::text AS points_table, l.money_pool_total,
    l.money_payout_table::text AS money_payout_table
"""


@router.get(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
    response_model=list[OrderOfMeritLinkOut],
)
async def list_order_of_merit_links(
    oom_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritLinkOut]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            f"""
            SELECT {_LINK_COLUMNS}
            FROM order_of_merit_link l
            JOIN tournament t ON t.id = l.tournament_id
            WHERE l.order_of_merit_id = $1
            ORDER BY l.created_at
            """,
            oom_id,
        )
    return [OrderOfMeritLinkOut(**_parse_link_row(r)) for r in rows]


def _parse_link_row(r) -> dict:
    d = dict(r)
    d["points_table"] = json.loads(d["points_table"]) if d.get("points_table") else None
    d["money_payout_table"] = json.loads(d["money_payout_table"]) if d.get("money_payout_table") else None
    if d.get("money_pool_total") is not None:
        d["money_pool_total"] = float(d["money_pool_total"])
    return d


async def _course_id_for_tournament(conn, tournament_id: str) -> str | None:
    """Samme mønster som _attach_eclectic_totals sin egen bane-oppslag
    (individual_tournaments.py) -- LIMIT 1 antar (bevisst, håndhevet av
    resten av appen ved rundeopprettelse) at en turnerings runder deler
    samme bane."""
    return await conn.fetchval(
        "SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
    )


async def _validate_same_course_for_eclectic(conn, oom_id: str, new_tournament_id: str) -> None:
    """Eclectic-aggregering (ADR-075) plukker beste resultat PER HULL på
    tvers av alle lenkede turneringer -- meningsløst hvis banene ikke er
    de samme (hull 7 på én bane er ikke hull 7 på en annen). Håndhevet
    her (lenke-tidspunkt) OG i update_order_of_merit (modus-endring),
    samme "fail loudly, ikke stille feil tall"-filosofi som ADR-068/
    ADR-019 Beslutning C."""
    new_course_id = await _course_id_for_tournament(conn, new_tournament_id)
    if new_course_id is None:
        return  # ingen runder ennå på den nye turneringen -- ingenting å sammenligne mot
    links = await conn.fetch(
        "SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
    )
    for link in links:
        existing_course_id = await _course_id_for_tournament(conn, link["tournament_id"])
        if existing_course_id is not None and existing_course_id != new_course_id:
            raise app_error(
                400,
                "VALIDATION_FAILED",
                "Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
            )


@router.post(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
    response_model=OrderOfMeritLinkOut,
    status_code=201,
)
async def link_tournament_to_order_of_merit(
    oom_id: str,
    body: OrderOfMeritLinkCreate,
    organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritLinkOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id)
        if oom is None:
            raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
        tournament = await conn.fetchrow(
            "SELECT format_type FROM tournament WHERE id = $1", body.tournament_id
        )
        if tournament is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        if tournament["format_type"] != "individual":
            raise app_error(
                400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
            )
        if oom["aggregation_mode"] == "eclectic":
            await _validate_same_course_for_eclectic(conn, oom_id, body.tournament_id)

        result_type = oom["result_type"]
        if result_type == "points" and not body.points_table:
            raise app_error(400, "VALIDATION_FAILED", "points_table er påkrevd for en poeng-basert Order of Merit.")
        if result_type != "points" and body.points_table:
            raise app_error(400, "VALIDATION_FAILED", "points_table er kun gyldig for en poeng-basert Order of Merit.")
        if result_type == "money" and not (body.money_pool_total and body.money_payout_table):
            raise app_error(
                400, "VALIDATION_FAILED", "money_pool_total og money_payout_table er påkrevd for en pengeliste-basert Order of Merit."
            )
        if result_type != "money" and (body.money_pool_total or body.money_payout_table):
            raise app_error(400, "VALIDATION_FAILED", "Pengefelt er kun gyldig for en pengeliste-basert Order of Merit.")

        row = await conn.fetchrow(
            """
            WITH inserted AS (
                INSERT INTO order_of_merit_link
                    (organization_id, order_of_merit_id, tournament_id,
                     points_table, money_pool_total, money_payout_table)
                VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb)
                RETURNING id, tournament_id, points_table, money_pool_total, money_payout_table
            )
            SELECT inserted.id::text, inserted.tournament_id::text, t.name AS tournament_name,
                   inserted.points_table::text AS points_table, inserted.money_pool_total,
                   inserted.money_payout_table::text AS money_payout_table
            FROM inserted JOIN tournament t ON t.id = inserted.tournament_id
            """,
            organization_id,
            oom_id,
            body.tournament_id,
            json.dumps(body.points_table) if body.points_table else None,
            body.money_pool_total,
            json.dumps(body.money_payout_table) if body.money_payout_table else None,
        )
    return OrderOfMeritLinkOut(**_parse_link_row(row))


@router.delete(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments/{link_id}",
    status_code=204,
)
async def unlink_tournament_from_order_of_merit(
    oom_id: str,
    link_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        result = await conn.execute(
            "DELETE FROM order_of_merit_link WHERE id = $1 AND order_of_merit_id = $2", link_id, oom_id
        )
        if result == "DELETE 0":
            raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")


# ---------------------------------------------------------------------------
# Lag-OOM (ADR-043 Beslutning C, bygget ADR-074): sesong-par satt opp
# DIREKTE i OOM-en (IKKE strengmatching mot turnering-lag, ADR-011-lag er
# strukturelt urelatert -- se migrasjon 055 sin kommentar). Kun gyldig for
# `kind='team'`. Selve leaderboard-beregningen (summerer/velger-beste-N-av
# medlemmenes allerede beregnede individuelle OOM-resultater) står lenger
# ned, sammen med spiller-leaderboardet den gjenbruker.
# ---------------------------------------------------------------------------

class OrderOfMeritTeamCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)


class OrderOfMeritTeamMemberOut(BaseModel):
    id: str
    player_id: str
    player_name: str


class OrderOfMeritTeamOut(BaseModel):
    id: str
    name: str
    members: list[OrderOfMeritTeamMemberOut]


class OrderOfMeritTeamMemberCreate(BaseModel):
    player_id: str


async def _require_team_kind(conn, oom_id: str) -> None:
    kind = await conn.fetchval("SELECT kind FROM order_of_merit WHERE id = $1", oom_id)
    if kind is None:
        raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
    if kind != "team":
        raise app_error(400, "VALIDATION_FAILED", "Lag kan kun settes opp for en lag-OOM.")


@router.get(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
    response_model=list[OrderOfMeritTeamOut],
)
async def list_order_of_merit_teams(
    oom_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritTeamOut]:
    async with org_connection(organization_id) as conn:
        team_rows = await conn.fetch(
            "SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1 ORDER BY created_at",
            oom_id,
        )
        member_rows = await conn.fetch(
            """
            SELECT m.id::text, m.order_of_merit_team_id::text AS team_id,
                   m.player_id::text AS player_id, p.display_name AS player_name
            FROM order_of_merit_team_member m
            JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
            JOIN player p ON p.id = m.player_id
            WHERE t.order_of_merit_id = $1
            ORDER BY p.display_name
            """,
            oom_id,
        )
    members_by_team: dict[str, list[OrderOfMeritTeamMemberOut]] = {}
    for m in member_rows:
        members_by_team.setdefault(m["team_id"], []).append(
            OrderOfMeritTeamMemberOut(id=m["id"], player_id=m["player_id"], player_name=m["player_name"])
        )
    return [
        OrderOfMeritTeamOut(id=t["id"], name=t["name"], members=members_by_team.get(t["id"], []))
        for t in team_rows
    ]


@router.post(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
    response_model=OrderOfMeritTeamOut,
    status_code=201,
)
async def create_order_of_merit_team(
    oom_id: str,
    body: OrderOfMeritTeamCreate,
    organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        await _require_team_kind(conn, oom_id)
        row = await conn.fetchrow(
            """
            INSERT INTO order_of_merit_team (organization_id, order_of_merit_id, name)
            VALUES ($1, $2, $3)
            RETURNING id::text, name
            """,
            organization_id, oom_id, body.name,
        )
    return OrderOfMeritTeamOut(id=row["id"], name=row["name"], members=[])


@router.delete(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}",
    status_code=204,
)
async def delete_order_of_merit_team(
    oom_id: str,
    team_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        result = await conn.execute(
            "DELETE FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
        )
        if result == "DELETE 0":
            raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")


@router.post(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members",
    response_model=OrderOfMeritTeamMemberOut,
    status_code=201,
)
async def add_order_of_merit_team_member(
    oom_id: str,
    team_id: str,
    body: OrderOfMeritTeamMemberCreate,
    organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamMemberOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        team = await conn.fetchval(
            "SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
        )
        if team is None:
            raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
        # player.id er IKKE org-scopet i sin FK (migrasjon 055) -- egen
        # eksplisitt sjekk her, samme forsiktighet som andre steder i
        # appen som tar imot en player_id fra klienten.
        player = await conn.fetchrow("SELECT display_name FROM player WHERE id = $1", body.player_id)
        if player is None:
            raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
        row = await conn.fetchrow(
            """
            INSERT INTO order_of_merit_team_member (organization_id, order_of_merit_team_id, player_id)
            VALUES ($1, $2, $3)
            RETURNING id::text
            """,
            organization_id, team_id, body.player_id,
        )
    return OrderOfMeritTeamMemberOut(id=row["id"], player_id=body.player_id, player_name=player["display_name"])


@router.delete(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members/{member_id}",
    status_code=204,
)
async def remove_order_of_merit_team_member(
    oom_id: str,
    team_id: str,
    member_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        team = await conn.fetchval(
            "SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
        )
        if team is None:
            raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
        result = await conn.execute(
            "DELETE FROM order_of_merit_team_member WHERE id = $1 AND order_of_merit_team_id = $2",
            member_id, team_id,
        )
        if result == "DELETE 0":
            raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")


# ---------------------------------------------------------------------------
# Eclectic-aggregering på tvers av lenkede turneringer (ADR-075) -- sesong-
# "drømmerunde": beste resultat PER HULL på tvers av ALLE en spillers
# lenkede turneringer (bekreftet samme bane av _validate_same_course_for_
# eclectic ved lenking/modus-endring). IKKE det samme som ADR-068 sitt
# Eclectic-format (scoping til RUNDENE innenfor ÉN turnering) -- samme
# motorfunksjon (`eclectic_best_per_hole`) gjenbrukes UENDRET, men
# datainnsamlingen her spenner over FLERE turneringers `tournament_round_
# hole`, ikke bare én. Nøkkelen er `player_id` (samme spiller har ulik
# `tournament_participant_id` per turnering), ikke `tournament_
# participant_id` slik innad-i-én-turnering-varianten (`_attach_eclectic_
# totals` i individual_tournaments.py) bruker.
# ---------------------------------------------------------------------------

async def _compute_eclectic_oom_values(
    conn, tournament_ids: list[str], result_type: Literal["gross", "net", "stableford"]
) -> tuple[dict[str, float], dict[str, str], dict[str, int]]:
    """Returnerer (totaler, navn, antall-kilde-turneringer) per spiller.
    Eclectic-aggregering ER selve tvers-av-turneringer-sammenslåingen --
    det finnes ingen ekstra "sum/snitt av N eclectic-totaler" ovenpå
    (`order_of_merit_aggregate` er derfor bevisst IKKE brukt her, se
    kalleren)."""
    if not tournament_ids:
        return {}, {}, {}

    course_id = None
    for tid in tournament_ids:
        course_id = await _course_id_for_tournament(conn, tid)
        if course_id is not None:
            break
    if course_id is None:
        return {}, {}, {}

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

    rows = await conn.fetch(
        """
        SELECT tr.tournament_id::text AS tournament_id, tr.sequence,
               tp.player_id::text AS player_id, p.display_name AS player_name,
               trp.course_handicap, trh.hole_number, trh.gross_strokes
        FROM tournament_round_hole trh
        JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id
        JOIN tournament_round tr ON tr.id = trp.tournament_round_id
        JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
        JOIN player p ON p.id = tp.player_id
        WHERE tr.tournament_id = ANY($1::uuid[]) AND trh.gross_strokes IS NOT NULL
        ORDER BY tr.tournament_id, tr.sequence
        """,
        tournament_ids,
    )

    # Sammensatt kilde-indeks: (turnering-id, sekvens) -> global indeks,
    # siden `sequence` kun er unik INNENFOR én turnering (speiler
    # _attach_eclectic_totals sin sequence_by_index, men på tvers av flere
    # turneringer i stedet for kun én).
    index_by_source: dict[tuple[str, int], int] = {}
    source_by_index: dict[int, tuple[str, int]] = {}
    for r in rows:
        key = (r["tournament_id"], r["sequence"])
        if key not in index_by_source:
            idx = len(index_by_source)
            index_by_source[key] = idx
            source_by_index[idx] = key

    allocation_cache: dict[int, list[int]] = {}
    values_by_player: dict[str, list[list[EclecticHoleValue]]] = {}
    names_by_player: dict[str, str] = {}

    for r in rows:
        if r["course_handicap"] is None:
            continue
        pid = r["player_id"]
        names_by_player[pid] = r["player_name"]
        if pid not in values_by_player:
            values_by_player[pid] = [[] for _ in range(18)]

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

        if result_type == "gross":
            value = r["gross_strokes"]
        elif result_type == "net":
            value = r["gross_strokes"] - strokes_received
        else:
            value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)

        source_index = index_by_source[(r["tournament_id"], r["sequence"])]
        values_by_player[pid][r["hole_number"] - 1].append(
            EclecticHoleValue(round_index=source_index, value=value)
        )

    totals_by_player: dict[str, float] = {}
    tournament_count_by_player: dict[str, int] = {}
    for pid, values_by_hole in values_by_player.items():
        result = eclectic_best_per_hole(values_by_hole, result_type)
        if not result.holes:
            continue
        totals_by_player[pid] = float(result.total)
        tournament_count_by_player[pid] = len({source_by_index[h.round_index][0] for h in result.holes})

    return totals_by_player, names_by_player, tournament_count_by_player


# ---------------------------------------------------------------------------
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
# som Nassau/High-low-high/individual_leaderboard selv).
# ---------------------------------------------------------------------------

class OrderOfMeritLeaderboardEntry(BaseModel):
    player_id: str
    player_name: str
    results_counted: int
    results_available: int
    value: float | None
    position: str | None
    eligible: bool
    ineligible_reason: str | None = None


def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
    """Samme delt-plassering-regel som order_of_merit_points_for_position
    (uavgjorte deler samme utbetaling ved sin felles plassering) -- egen
    liten oppslagsfunksjon her (ikke i handicap_engine.py) siden dette er
    et rent JSON-konfig-oppslag, ikke golf-domenematematikk om uavgjort."""
    position = int(position_label.lstrip("T"))
    for entry in payout_table:
        if entry.get("position") == position:
            return float(entry.get("amount") or 0)
    return 0.0


def _player_eligibility(results_available: int, oom, birth_year: int | None) -> tuple[bool, str | None]:
    """Delt mellom spiller- og lag-leaderboardet (et lags medlem er
    eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom
    sum/snitt (`results_available = len(raw_values)`) og eclectic
    (`results_available` = antall KILDE-turneringer som faktisk bidro et
    hull til sesong-drømmerunden, se _compute_eclectic_oom_values) --
    tallet betyr det samme ("hvor mange turneringer talte") uansett hvilken
    modus som produserte det."""
    if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
        if birth_year is None:
            return False, "Ingen registrert fødselsdato."
        if oom["birth_year_from"] is not None and birth_year < oom["birth_year_from"]:
            return False, "Utenfor aldersgrensen."
        if oom["birth_year_to"] is not None and birth_year > oom["birth_year_to"]:
            return False, "Utenfor aldersgrensen."
    if oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
        return False, f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
    return True, None


def _aggregate_value(raw_values: list[float], oom, lower_is_better: bool) -> float | None:
    """Negerer FØR order_of_merit_aggregate (som alltid regner «størst er
    best») og negerer tilbake etterpå for brutto/netto -- delt av spiller-
    OG lag-nivået. Trygt å kalle EN GANG TIL på lag-nivå med allerede
    korrekt-signerte medlemsverdier: samme frem-og-tilbake-negering gir
    riktig resultat begge ganger (ADR-074 Beslutning: ett lagret
    aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig)."""
    values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
    aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
    return -aggregated if lower_is_better and aggregated is not None else aggregated


@router.get(
    "/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
    response_model=list[OrderOfMeritLeaderboardEntry],
)
async def order_of_merit_leaderboard(
    oom_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritLeaderboardEntry]:
    async with org_connection(organization_id) as conn:
        oom = await conn.fetchrow(
            """
            SELECT result_type, aggregation_mode, count_best_n, min_results_required,
                   birth_year_from, birth_year_to, kind
            FROM order_of_merit WHERE id = $1
            """,
            oom_id,
        )
        if oom is None:
            raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")

        result_type = oom["result_type"]
        lower_is_better = result_type in ("gross", "net")
        is_eclectic = oom["aggregation_mode"] == "eclectic"

        links = await conn.fetch(
            """
            SELECT tournament_id::text, points_table::text AS points_table,
                   money_payout_table::text AS money_payout_table
            FROM order_of_merit_link WHERE order_of_merit_id = $1
            """,
            oom_id,
        )

        # player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
        # (eclectic-modus: nøyaktig ÉN verdi per spiller -- sesongens
        # ferdig-sammenslåtte drømmerunde-total, se _compute_eclectic_oom_
        # values. "results_available" betyr da antall KILDE-turneringer
        # som bidro et hull, ikke antall verdier i denne listen.)
        values_by_player: dict[str, list[float]] = {}
        names_by_player: dict[str, str] = {}
        results_available_by_player: dict[str, int] = {}

        if is_eclectic:
            # ADR-075: krever result_type IN (stableford,gross,net) og
            # kind='player', begge håndhevet ved opprettelse/PATCH --
            # ingen ekstra sjekk nødvendig her.
            tournament_ids = [link["tournament_id"] for link in links]
            totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values(
                conn, tournament_ids, result_type
            )
            for player_id, total in totals.items():
                values_by_player[player_id] = [total]
                results_available_by_player[player_id] = tournament_counts[player_id]
        else:
            for link in links:
                tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
                points_table = _decode_json(link["points_table"])
                money_payout_table = _decode_json(link["money_payout_table"])
                for e in entries:
                    contribution = _contribution_for_entry(
                        e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
                    )
                    if contribution is None:
                        continue
                    values_by_player.setdefault(e.player_id, []).append(contribution)
                    names_by_player[e.player_id] = e.player_name
            for player_id, raw_values in values_by_player.items():
                results_available_by_player[player_id] = len(raw_values)

        # Aldersgrense: filtrert FØR aggregering (spillere utenfor
        # fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
        # bare gitt 0 poeng -- de skal ikke telle med som "sist").
        eligible_player_ids = set(values_by_player.keys())
        birth_years: dict[str, int | None] = {}
        if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
            if eligible_player_ids:
                rows = await conn.fetch(
                    "SELECT id::text, EXTRACT(YEAR FROM birth_date)::int AS birth_year FROM player WHERE id = ANY($1::uuid[])",
                    list(eligible_player_ids),
                )
                birth_years = {r["id"]: r["birth_year"] for r in rows}

        results: list[OrderOfMeritLeaderboardEntry] = []

        if oom["kind"] == "player":
            for player_id, raw_values in values_by_player.items():
                results_available = results_available_by_player[player_id]
                eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id))
                # Eclectic: raw_values er allerede ÉN ferdig-sammenslått
                # sesongtotal (se _compute_eclectic_oom_values) -- ingen
                # order_of_merit_aggregate-kall (som uansett kun kjenner
                # sum/snitt, ikke eclectic, se motoren), og count_best_n
                # gir ingen mening ("beste N av ÉN verdi").
                if is_eclectic:
                    value = raw_values[0]
                    counted = results_available
                else:
                    value = _aggregate_value(raw_values, oom, lower_is_better)
                    counted = (
                        min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
                    )

                results.append(
                    OrderOfMeritLeaderboardEntry(
                        player_id=player_id,
                        player_name=names_by_player[player_id],
                        results_counted=counted,
                        results_available=results_available,
                        value=value,
                        position=None,
                        eligible=eligible,
                        ineligible_reason=ineligible_reason,
                    )
                )
        else:
            # Lag-OOM (ADR-043 Beslutning C, ADR-074): summerer/velger-
            # beste-N-av medlemmenes allerede beregnede individuelle OOM-
            # resultater -- IKKE strengmatching mot turnering-lag.
            # `player_id`/`player_name` på OrderOfMeritLeaderboardEntry
            # bærer her lagets EGEN id/navn (samme uttrykk, uendret
            # skjema -- kalleren vet allerede OOM-ens `kind`).
            team_rows = await conn.fetch(
                "SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1", oom_id
            )
            member_rows = await conn.fetch(
                """
                SELECT m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id
                FROM order_of_merit_team_member m
                JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
                WHERE t.order_of_merit_id = $1
                """,
                oom_id,
            )
            member_ids_by_team: dict[str, list[str]] = {}
            for m in member_rows:
                member_ids_by_team.setdefault(m["team_id"], []).append(m["player_id"])

            for team in team_rows:
                member_ids = member_ids_by_team.get(team["id"], [])
                member_values: list[float] = []
                for pid in member_ids:
                    raw_values = values_by_player.get(pid)
                    if not raw_values:
                        continue
                    member_eligible, _ = _player_eligibility(
                        results_available_by_player[pid], oom, birth_years.get(pid)
                    )
                    if not member_eligible:
                        continue
                    member_value = _aggregate_value(raw_values, oom, lower_is_better)
                    if member_value is not None:
                        member_values.append(member_value)

                eligible_members = len(member_values)
                team_eligible = eligible_members > 0
                team_reason = None if team_eligible else "Ingen av lagets medlemmer har tellende resultater."
                if (
                    team_eligible
                    and oom["min_results_required"] is not None
                    and eligible_members < oom["min_results_required"]
                ):
                    team_eligible = False
                    team_reason = (
                        f"Trenger minst {oom['min_results_required']} medlemmer med tellende resultater, "
                        f"har {eligible_members}."
                    )

                value = _aggregate_value(member_values, oom, lower_is_better) if team_eligible else None
                counted = (
                    min(oom["count_best_n"], eligible_members) if oom["count_best_n"] is not None else eligible_members
                )

                results.append(
                    OrderOfMeritLeaderboardEntry(
                        player_id=team["id"],
                        player_name=team["name"],
                        results_counted=counted,
                        results_available=len(member_ids),
                        value=value,
                        position=None,
                        eligible=team_eligible,
                        ineligible_reason=team_reason,
                    )
                )

        # Rangering: kun blant de kvalifiserte, høyest verdi øverst for
        # poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
        # `value` er allerede tilbake-negert til ekte visningsverdi over,
        # så sorter på `value` med riktig retning per resultattype.
        eligible_results = [r for r in results if r.eligible and r.value is not None]
        ineligible_results = [r for r in results if not (r.eligible and r.value is not None)]
        eligible_results.sort(key=lambda r: r.value, reverse=not lower_is_better)

        i, n = 0, len(eligible_results)
        while i < n:
            j = i
            while j < n and eligible_results[j].value == eligible_results[i].value:
                j += 1
            rank = i + 1
            tied = (j - i) > 1
            for k in range(i, j):
                eligible_results[k].position = f"T{rank}" if tied else str(rank)
            i = j

    return eligible_results + ineligible_results


def _decode_json(value):
    return json.loads(value) if value else None


_ECLECTIC_METHODS = ("eclectic_gross", "eclectic_net", "eclectic_stableford")


def _contribution_for_entry(
    entry, result_type: str, points_table, money_payout_table, scoring_method: str | None = None
) -> float | None:
    """Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
    allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
    denne turneringen" (skal ikke telle som et 0-resultat, kalleren
    hopper over den).

    `entry.position` (posisjon i DENNE turneringen) er KUN populert for
    scoring_method i (stroke_gross, stroke_net, stableford) -- se
    `_attach_stroke_play_columns` i individual_tournaments.py. Er OOM-en
    poeng- eller pengeliste-basert og den lenkede turneringen bruker en
    annen scoring_method (København/BBB/Flagg), er det bevisst ingen
    posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
    "deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.

    Bug fikset 2026-08-15 (ADR-074): for en lenket turnering med
    scoring_method IN eclectic_gross/net/stableford, skal
    stableford/gross/net-resultattype-OOM-er lese `entry.eclectic_total`
    (turneringens FAKTISKE "drømmerunde"-resultat, det som avgjorde DEN
    turneringens egen vinner) -- IKKE `entry.stableford_total`/
    `gross_total`/`net_total` (rå sum på tvers av rundene, et tall som
    aldri var det som ble kåret som resultat i den turneringen)."""
    if entry.rounds_played == 0:
        return None
    if result_type == "points":
        return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
    if result_type == "money":
        return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
    if scoring_method in _ECLECTIC_METHODS and result_type in ("stableford", "gross", "net"):
        return float(entry.eclectic_total) if entry.eclectic_total is not None else 0.0
    if result_type == "stableford":
        return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
    if result_type == "gross":
        return float(entry.gross_total) if entry.gross_total is not None else 0.0
    if result_type == "net":
        return float(entry.net_total) if entry.net_total is not None else 0.0
    return 0.0


# ---------------------------------------------------------------------------
# Offentlig/delt visning (ADR-076, migrasjon 076) -- uautentisert, samme
# SECURITY DEFINER-bro-mønster som public_org_by_slug/public_tournament_
# by_code (registration.py): NULL for BÅDE "finnes ikke" og "finnes, men
# er ikke offentlig" -- anti-enumerering. Egen APIRouter i DENNE filen
# (ikke registration.py) fordi den gjenbruker order_of_merit_leaderboard
# direkte -- unngår å duplisere hele beregningen for en uautentisert
# variant, samme triks som testene i test_order_of_merit.py (kaller
# router-funksjonen med organization_id som et vanlig argument, utenom
# FastAPI sin Depends-injeksjon).
# ---------------------------------------------------------------------------

public_router = APIRouter(prefix="/public/order-of-merits", tags=["public-order-of-merit"])


class PublicOrderOfMeritOut(BaseModel):
    name: str
    kind: str
    result_type: str
    aggregation_mode: str
    entries: list[OrderOfMeritLeaderboardEntry]


@public_router.get("/{oom_id}", response_model=PublicOrderOfMeritOut)
async def get_public_order_of_merit(oom_id: str) -> PublicOrderOfMeritOut:
    async with plain_connection() as conn:
        organization_id = await conn.fetchval("SELECT public_order_of_merit_by_id($1)", oom_id)
    if organization_id is None:
        raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
    organization_id = str(organization_id)

    async with org_connection(organization_id) as conn:
        oom = await conn.fetchrow(
            "SELECT name, kind, result_type, aggregation_mode FROM order_of_merit WHERE id = $1",
            oom_id,
        )

    entries = await order_of_merit_leaderboard(oom_id, organization_id=organization_id)
    return PublicOrderOfMeritOut(
        name=oom["name"],
        kind=oom["kind"],
        result_type=oom["result_type"],
        aggregation_mode=oom["aggregation_mode"],
        entries=entries,
    )
