"""
Turneringsoppsett: turneringer, lag, roster, økter.

Autorisasjonsgrense (se plan): kun org-medlemskap (get_authorized_org) kreves
her — dette er organisator-arbeid. Å kreve at brukeren allerede er rostret på
et lag ville vært sirkulært for lag-/roster-opprettelse (ingen kan være
rostret på et lag som ikke finnes ennå).
"""

import json
import secrets
import traceback
from datetime import date, datetime, timedelta, timezone

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

from handicap_engine import allocate_over_played_holes

from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids, own_team_ids
from ..config import settings
from ..db import org_connection
from ..email import send_session_result_email
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .auth import _hash_secret
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
from .. import storage

router = APIRouter()


# ---------------------------------------------------------------------------
# Turneringer
# ---------------------------------------------------------------------------

class TournamentCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    start_date: date | None = None
    end_date: date | None = None
    # ADR-037: 'team' (dagens Ryder Cup-modell, uendret default) eller
    # 'individual' (flatt felt, egen tabellstruktur -- se
    # 040_individual_tournaments.sql). scoring_method er kun meningsfullt
    # for individual, håndhevet i app-laget når runder/score bygges på den.
    format_type: str = Field(default="team", pattern="^(team|individual)$")
    scoring_method: str | None = Field(
        default=None,
        pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
        "eclectic_gross|eclectic_net|eclectic_stableford)$",
    )
    # KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
    # organisator-innstilling (samme som frittstående runders round.
    # bbb_sweep_bonus_enabled), ikke fast påslått.
    bbb_sweep_bonus_enabled: bool = False


_TOURNAMENT_COLUMNS = """
    id::text, name, status::text, start_date, end_date,
    visibility, description, registration_deadline, registration_capacity,
    registration_overflow_policy, registration_requires_approval, hero_image_key,
    join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible
"""

# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
_JOIN_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"


def _generate_join_code() -> str:
    return "".join(secrets.choice(_JOIN_CODE_ALPHABET) for _ in range(6))


class Tournament(BaseModel):
    id: str
    name: str
    status: str
    start_date: date | None
    end_date: date | None
    visibility: str
    description: str | None
    registration_deadline: datetime | None
    registration_capacity: int | None
    registration_overflow_policy: str
    registration_requires_approval: bool
    hero_image_key: str | None
    hero_image_url: str | None
    join_code: str
    format_type: str
    scoring_method: str | None
    bbb_sweep_bonus_enabled: bool
    # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
    flag_map_visible: bool


def _tournament_from_row(row) -> Tournament:
    """Samme mønster som auth.py sin avatar_url: DB-kolonnen forblir
    `hero_image_key` (den nøytrale, lagrings-uavhengige verdien), men
    responsen får i tillegg en ferdig-konstruert URL -- organisator-
    frontend skal aldri selv måtte kjenne til MinIO-bucket/base-URL."""
    data = dict(row)
    key = data["hero_image_key"]
    return Tournament(**data, hero_image_url=storage.public_url(key) if key else None)


@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
async def list_tournaments(
    organization_id: str = Depends(get_authorized_org),
) -> list[Tournament]:
    # ADR-030: dashbord-kortet skal vise turneringens FAKTISKE datospenn.
    # `tournament.start_date`/`end_date` er egne, eksplisitte felt (ADR-015)
    # som ingen UI noensinne har hatt en vei til å SETTE -- alltid NULL i
    # praksis. Eksplisitt satt verdi vinner hvis den noensinne blir satt
    # (fremtidig PATCH-vei), ellers utledes spennet fra øktenes
    # `scheduled_at` -- informasjonen finnes jo allerede der.
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            """
            SELECT t.id::text, t.name, t.status::text,
                   COALESCE(t.start_date, s.min_date) AS start_date,
                   COALESCE(t.end_date, s.max_date) AS end_date,
                   t.visibility, t.description, t.registration_deadline,
                   t.registration_capacity, t.registration_overflow_policy,
                   t.registration_requires_approval, t.hero_image_key,
                   t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
                   t.flag_map_visible
            FROM tournament t
            LEFT JOIN (
                SELECT tournament_id,
                       min(scheduled_at)::date AS min_date,
                       max(scheduled_at)::date AS max_date
                FROM session
                WHERE scheduled_at IS NOT NULL
                GROUP BY tournament_id
            ) s ON s.tournament_id = t.id
            ORDER BY t.created_at DESC
            """
        )
    return [_tournament_from_row(r) for r in rows]


@router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201)
async def create_tournament(
    body: TournamentCreate,
    organization_id: str = Depends(get_authorized_org),
) -> Tournament:
    async with org_connection(organization_id) as conn, translate_db_errors():
        # join_code er globalt unikt (migrasjon 011) -- kollisjon er
        # astronomisk usannsynlig (33^6 ≈ 1,3 mrd. kombinasjoner mot et
        # lite antall turneringer totalt), men en liten retry-løkke koster
        # ingenting og gjør oppførselen presis i stedet for å anta.
        row = None
        for _ in range(5):
            try:
                row = await conn.fetchrow(
                    f"""
                    INSERT INTO tournament
                        (organization_id, name, start_date, end_date, join_code, format_type,
                         scoring_method, bbb_sweep_bonus_enabled)
                    VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
                    RETURNING {_TOURNAMENT_COLUMNS}
                    """,
                    organization_id,
                    body.name,
                    body.start_date,
                    body.end_date,
                    _generate_join_code(),
                    body.format_type,
                    body.scoring_method,
                    body.bbb_sweep_bonus_enabled,
                )
                break
            except asyncpg.UniqueViolationError:
                continue
        if row is None:
            raise app_error(500, "VALIDATION_FAILED", "Klarte ikke å generere en unik invitasjonskode. Prøv igjen.")
    return _tournament_from_row(row)


class TournamentUpdate(BaseModel):
    """Alle felt valgfrie -- kun de som faktisk sendes med blir endret
    (PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""

    status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
    visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
    format_type: str | None = Field(default=None, pattern="^(team|individual)$")
    scoring_method: str | None = Field(
        default=None,
        pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
        "eclectic_gross|eclectic_net|eclectic_stableford)$",
    )
    description: str | None = None
    registration_deadline: datetime | None = None
    registration_capacity: int | None = Field(default=None, gt=0)
    registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
    registration_requires_approval: bool | None = None
    bbb_sweep_bonus_enabled: bool | None = None
    # Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
    # -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se
    # get_authorized_org under). Standard AV, samme "skjult til avslørt"-
    # filosofi som visibility.
    flag_map_visible: bool | None = None
    # hero_image_key er BEVISST IKKE med her -- den settes kun via
    # upload_hero_image under, aldri som en fritekst-streng klienten sender,
    # siden den nå alltid kommer fra en ekte, server-konvertert opplasting.


@router.patch("/orgs/{organization_id}/tournaments/{tournament_id}", response_model=Tournament)
async def update_tournament(
    tournament_id: str,
    body: TournamentUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> Tournament:
    # exclude_unset: kun felt klienten FAKTISK sendte skal skrives -- et
    # utelatt felt skal IKKE nullstilles (ekte PATCH-semantikk, ikke PUT).
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    # Trygt mot injeksjon: nøklene kommer KUN fra TournamentUpdate sine egne
    # feltnavn (en fast, kjent hviteliste), aldri fra en rå streng brukeren
    # sender inn.
    # `status` er -- ulikt de andre feltene her -- en EKTE Postgres ENUM-type
    # (tournament_status, migrasjon 001), ikke text+CHECK. asyncpg sender
    # parameteren som ukjent/text; et eksplisitt cast trengs for at
    # tildelingen skal løses riktig.
    set_clauses = [
        f"{key} = ${i}::tournament_status" if key == "status" else f"{key} = ${i}"
        for i, key in enumerate(updates, start=1)
    ]
    values = list(updates.values())
    values.append(tournament_id)

    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            f"""
            UPDATE tournament SET {', '.join(set_clauses)}
            WHERE id = ${len(values)}
            RETURNING {_TOURNAMENT_COLUMNS}
            """,
            *values,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
    return _tournament_from_row(row)


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
    response_model=Tournament,
)
async def upload_hero_image(
    tournament_id: str,
    file: UploadFile,
    organization_id: str = Depends(get_authorized_org),
) -> Tournament:
    """Ekte multipart-opplasting (IKKE en presignert nettleser-URL): filen
    går gjennom API-et, som konverterer den til AVIF før den lagres i MinIO
    -- se app/storage.py sin moduldocstring for hvorfor."""
    if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
        raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")

    raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
    if len(raw) > storage.MAX_UPLOAD_BYTES:
        raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")

    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")

        try:
            key = await storage.upload_image("tournaments", tournament_id, raw)
        except storage.InvalidImageError:
            raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")

        row = await conn.fetchrow(
            f"UPDATE tournament SET hero_image_key = $1 WHERE id = $2 RETURNING {_TOURNAMENT_COLUMNS}",
            key,
            tournament_id,
        )
    return _tournament_from_row(row)


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
    response_model=Tournament,
)
async def delete_hero_image(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> Tournament:
    """Samme mønster som DELETE /auth/profile/avatar -- nullstiller kun
    referansen, sletter ikke selve MinIO-objektet (samme aksepterte
    forenkling som avatar-fjerning)."""
    async with org_connection(organization_id) as conn:
        row = await conn.fetchrow(
            f"UPDATE tournament SET hero_image_key = NULL WHERE id = $1 RETURNING {_TOURNAMENT_COLUMNS}",
            tournament_id,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
    return _tournament_from_row(row)


# ---------------------------------------------------------------------------
# Lag (ADR-011: maks to lag per turnering, håndhevet her — ikke i skjemaet —
# slik at match-modellen kan holdes generell for fremtidige turneringstyper)
# ---------------------------------------------------------------------------

class TeamCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    color: str | None = None


class Team(BaseModel):
    id: str
    name: str
    color: str | None


@router.get("/orgs/{organization_id}/tournaments/{tournament_id}/teams", response_model=list[Team])
async def list_teams(
    tournament_id: str,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> list[Team]:
    """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
    denne turneringen, samme mønster som scoring.py sin get_scorecard --
    laste team-navn/-farge er en forutsetning for at scorekort-siden i
    det hele tatt kan rendres for en deltaker uten org-medlemskap."""
    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Turneringen 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.")
        rows = await conn.fetch(
            "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
            tournament_id,
        )
    return [Team(**dict(r)) for r in rows]


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/teams",
    response_model=Team,
    status_code=201,
)
async def create_team(
    tournament_id: str,
    body: TeamCreate,
    organization_id: str = Depends(get_authorized_org),
) -> Team:
    async with org_connection(organization_id) as conn, translate_db_errors():
        # Lås turneringsraden slik at to samtidige forespørsler ikke begge kan
        # lese "1 lag" og begge sette inn et andre/tredje lag (TOCTOU).
        locked = await conn.fetchrow(
            "SELECT id FROM tournament WHERE id = $1 AND organization_id = $2 FOR UPDATE",
            tournament_id,
            organization_id,
        )
        if locked is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")

        existing = await conn.fetchval(
            "SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id
        )
        if existing >= 2:
            raise app_error(
                409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)."
            )

        row = await conn.fetchrow(
            """
            INSERT INTO team (organization_id, tournament_id, name, color)
            VALUES ($1, $2, $3, $4)
            RETURNING id::text, name, color
            """,
            organization_id,
            tournament_id,
            body.name,
            body.color,
        )
    return Team(**dict(row))


# ---------------------------------------------------------------------------
# Turnering-nivå walkover/konsesjon (ADR-024)
#
# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander
# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen"
# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon,
# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match.
# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir
# seg selv, eller org-eier/admin.
# ---------------------------------------------------------------------------


class ConcedeTournamentRequest(BaseModel):
    conceding_team_id: str


class ConcedeTournamentOut(BaseModel):
    matches_conceded: int


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/concede",
    response_model=ConcedeTournamentOut,
    status_code=201,
)
async def concede_tournament(
    tournament_id: str,
    body: ConcedeTournamentRequest,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> ConcedeTournamentOut:
    async with org_connection(organization_id) as conn, translate_db_errors():
        team = await conn.fetchrow(
            "SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1",
            body.conceding_team_id,
        )
        if team is None or team["tournament_id"] != tournament_id:
            raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.")

        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 å gi opp turneringen.",
            )

        # FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme
        # race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state.
        matches = await conn.fetch(
            """
            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.points_per_match::float AS points_per_match
            FROM match m
            JOIN session s ON s.id = m.session_id
            WHERE s.tournament_id = $1
              AND m.points_side_a IS NULL
              AND (m.team_a_id = $2 OR m.team_b_id = $2)
            FOR UPDATE OF m
            """,
            tournament_id,
            body.conceding_team_id,
        )
        for m in matches:
            side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
            await apply_concession(conn, m["id"], side, m["points_per_match"], tournament_id)

    return ConcedeTournamentOut(matches_conceded=len(matches))


# ---------------------------------------------------------------------------
# Roster (spiller-pool per lag, ADR-007)
# ---------------------------------------------------------------------------

class RosterEntryCreate(BaseModel):
    player_id: str
    is_captain: bool = False
    # None => fryses fra player.handicap_index ved innsetting (ADR-007).
    handicap_index_snapshot: float | None = None
    # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
    class_id: str | None = None


class RosterEntry(BaseModel):
    id: str
    player_id: str
    display_name: str
    handicap_index_snapshot: float | None
    is_captain: bool
    class_id: str | None
    class_name: str | None


_ROSTER_COLUMNS = """
    tr.id::text, tr.player_id::text,
    tr.handicap_index_snapshot::float AS handicap_index_snapshot,
    tr.is_captain, p.display_name,
    tr.class_id::text AS class_id, tc.name AS class_name
"""


@router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry])
async def list_roster(
    team_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[RosterEntry]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            f"""
            SELECT {_ROSTER_COLUMNS}
            FROM team_roster tr
            JOIN player p ON p.id = tr.player_id
            LEFT JOIN tournament_class tc ON tc.id = tr.class_id
            WHERE tr.team_id = $1
            ORDER BY p.display_name
            """,
            team_id,
        )
    return [RosterEntry(**dict(r)) for r in rows]


@router.post(
    "/orgs/{organization_id}/teams/{team_id}/roster",
    response_model=RosterEntry,
    status_code=201,
)
async def add_roster_entry(
    team_id: str,
    body: RosterEntryCreate,
    organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
    async with org_connection(organization_id) as conn, translate_db_errors():
        team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id)
        if team is None:
            raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")

        # Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)).
        # Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme
        # turnering, og own_team_ids() (blind draw-filteret i matches.py) ville
        # da regnet hen som "eget lag" på begge sider — hen ville sett
        # motstanderens skjulte oppstilling før lås (ADR-013).
        already_elsewhere = await conn.fetchval(
            """
            SELECT EXISTS (
                SELECT 1 FROM team_roster tr
                JOIN team t ON t.id = tr.team_id
                WHERE t.tournament_id = $1 AND tr.player_id = $2 AND tr.team_id <> $3
            )
            """,
            team["tournament_id"],
            body.player_id,
            team_id,
        )
        if already_elsewhere:
            raise app_error(
                409,
                "DUPLICATE",
                "Spilleren er allerede rostret på det andre laget i denne turneringen.",
            )

        snapshot = body.handicap_index_snapshot
        if snapshot is None:
            snapshot = await conn.fetchval(
                "SELECT handicap_index FROM player WHERE id = $1", body.player_id
            )

        # Samme "kun én kaptein per lag"-invariant som update_roster_entry
        # (ADR-023) -- håndhevet her også, selv om dagens frontend aldri
        # sender is_captain=true ved opprettelse, for at API-et er korrekt
        # uavhengig av klient.
        if body.is_captain:
            await conn.execute(
                "UPDATE team_roster SET is_captain = false WHERE team_id = $1", team_id
            )

        row = await conn.fetchrow(
            """
            WITH inserted AS (
                INSERT INTO team_roster
                    (organization_id, team_id, player_id, handicap_index_snapshot, is_captain, class_id)
                VALUES ($1, $2, $3, $4, $5, $6)
                RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
            )
            SELECT inserted.id::text, inserted.player_id::text,
                   inserted.handicap_index_snapshot::float AS handicap_index_snapshot,
                   inserted.is_captain, p.display_name,
                   inserted.class_id::text AS class_id, tc.name AS class_name
            FROM inserted
            JOIN player p ON p.id = inserted.player_id
            LEFT JOIN tournament_class tc ON tc.id = inserted.class_id
            """,
            organization_id,
            team_id,
            body.player_id,
            snapshot,
            body.is_captain,
            body.class_id,
        )
    return RosterEntry(**dict(row))


class RosterEntryUpdate(BaseModel):
    """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
    TournamentUpdate/SponsorUpdate. `class_id` lagt til 2026-08-03 (bredere
    enn tidligere, da feltet KUN var is_captain: bool påkrevd) -- utvidet
    slik at klasse kan settes/endres uten å tvinge et samtidig
    kaptein-valg."""

    is_captain: bool | None = None
    class_id: str | None = None


@router.patch(
    "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
    response_model=RosterEntry,
)
async def update_roster_entry(
    team_id: str,
    roster_id: str,
    body: RosterEntryUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    # 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
    # autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
    # "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY
    # kaptein fjerner automatisk merket fra en ev. tidligere kaptein på
    # SAMME lag, i samme transaksjon (org_connection åpner allerede én).
    # Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader.
    async with org_connection(organization_id) as conn:
        if updates.get("is_captain"):
            await conn.execute(
                "UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2",
                team_id,
                roster_id,
            )

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

        row = await conn.fetchrow(
            f"""
            WITH updated AS (
                UPDATE team_roster SET {', '.join(set_clauses)}
                WHERE id = ${len(values) - 1} AND team_id = ${len(values)}
                RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
            )
            SELECT updated.id::text, updated.player_id::text,
                   updated.handicap_index_snapshot::float AS handicap_index_snapshot,
                   updated.is_captain, p.display_name,
                   updated.class_id::text AS class_id, tc.name AS class_name
            FROM updated
            JOIN player p ON p.id = updated.player_id
            LEFT JOIN tournament_class tc ON tc.id = updated.class_id
            """,
            *values,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
    return RosterEntry(**dict(row))


class RosterMove(BaseModel):
    target_team_id: str


@router.post(
    "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move",
    response_model=RosterEntry,
)
async def move_roster_entry(
    team_id: str,
    roster_id: str,
    body: RosterMove,
    organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
    """FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom
    lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for
    to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren
    midlertidig ikke er rostret noe sted)."""
    async with org_connection(organization_id) as conn, translate_db_errors():
        current = await conn.fetchrow(
            "SELECT tr.id, t.tournament_id::text AS tournament_id "
            "FROM team_roster tr JOIN team t ON t.id = tr.team_id "
            "WHERE tr.id = $1 AND tr.team_id = $2",
            roster_id,
            team_id,
        )
        if current is None:
            raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
        if body.target_team_id == team_id:
            raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.")
        target_team = await conn.fetchrow(
            "SELECT id FROM team WHERE id = $1 AND tournament_id = $2",
            body.target_team_id,
            current["tournament_id"],
        )
        if target_team is None:
            raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.")

        # Roster-raden er referert av match_participant.team_roster_id (ON
        # DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én
        # match kan ikke flyttes trygt (matchens team_side ville da ikke
        # lenger stemme med spillerens faktiske lag). Krev at spilleren
        # fjernes fra matchen(e) FØRST, i stedet for å tillate en
        # inkonsistent mellomtilstand.
        already_in_match = await conn.fetchval(
            "SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)",
            roster_id,
        )
        if already_in_match:
            raise app_error(
                409,
                "ALREADY_IN_MATCH",
                "Spilleren er allerede lagt til i en match og kan ikke flyttes -- "
                "fjern spilleren fra matchen(e) først.",
            )

        # Kaptein-merket følger IKKE med til det nye laget -- "kun én
        # kaptein per lag" (ADR-023) håndheves allerede ELLERS via
        # update_roster_entry, men en flyttet spiller skal aldri bli
        # kaptein på et nytt lag uten et eksplisitt valg der.
        row = await conn.fetchrow(
            """
            WITH moved AS (
                UPDATE team_roster
                SET team_id = $1, is_captain = false
                WHERE id = $2
                RETURNING id, player_id, handicap_index_snapshot, is_captain
            )
            SELECT moved.id::text, moved.player_id::text,
                   moved.handicap_index_snapshot::float AS handicap_index_snapshot,
                   moved.is_captain, p.display_name
            FROM moved JOIN player p ON p.id = moved.player_id
            """,
            body.target_team_id,
            roster_id,
        )
    return RosterEntry(**dict(row))


@router.delete(
    "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
    status_code=204,
)
async def delete_roster_entry(
    team_id: str,
    roster_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        deleted = await conn.fetchval(
            "DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id",
            roster_id,
            team_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")


# ---------------------------------------------------------------------------
# Økter (ADR-007: turnering = ordnet sekvens av økter)
# ---------------------------------------------------------------------------

class SessionCreate(BaseModel):
    sequence: int
    name: str | None = None
    format: str
    hole_config: str = "full_18"
    course_id: str
    points_per_match: float = 1.0
    allowance_override: dict | None = None
    # ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result'
    # (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'.
    scoring_mode: str = "stroke"
    # Klokkeslett-modell (se plan): én starttid + ett intervall per økt.
    # match.tee_time utledes av disse i matches.py, IKKE lagret per match.
    scheduled_at: datetime | None = None
    tee_interval_minutes: int | None = None
    start_hole: int = 1
    # KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
    # sidens individuelle resultater som teller per hull ("beste N av M").
    shamble_best_n: int | None = Field(default=None, ge=1)


class SessionOut(BaseModel):
    id: str
    sequence: int
    name: str | None
    format: str
    hole_config: str
    course_id: str
    points_per_match: float
    allowance_override: dict | None
    scoring_mode: str
    scheduled_at: datetime | None
    tee_interval_minutes: int | None
    start_hole: int
    shamble_best_n: int | None
    locked_team_ids: list[str]
    revealed: bool


def _session_out(row, locked: set[str]) -> SessionOut:
    return SessionOut(
        id=row["id"],
        sequence=row["sequence"],
        name=row["name"],
        format=row["format"],
        hole_config=row["hole_config"],
        course_id=row["course_id"],
        points_per_match=row["points_per_match"],
        allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None,
        scoring_mode=row["scoring_mode"],
        scheduled_at=row["scheduled_at"],
        tee_interval_minutes=row["tee_interval_minutes"],
        start_hole=row["start_hole"],
        shamble_best_n=row["shamble_best_n"],
        locked_team_ids=sorted(locked),
        revealed=len(locked) >= 2,
    )


async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
    """Delt mellom den innloggede org-ruten under og det offentlige
    landingsside-endepunktet (app/routers/registration.py, ADR-018) -- samme
    spørring, samme blind draw-låsing, ikke duplisert to steder."""
    rows = await conn.fetch(
        """
        SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
               course_id::text, points_per_match::float AS points_per_match,
               allowance_override::text AS allowance_override, scoring_mode,
               scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
        FROM session
        WHERE tournament_id = $1
        ORDER BY sequence
        """,
        tournament_id,
    )
    result = []
    for r in rows:
        locked = await locked_team_ids(conn, r["id"])
        result.append(_session_out(r, locked))
    return result


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
    response_model=list[SessionOut],
)
async def list_sessions(
    tournament_id: str,
    organization_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> list[SessionOut]:
    """2026-07-21 (deltaker-tilgang-runden): org-medlem ELLER deltaker i
    denne turneringen, samme mønster som list_teams over -- innholdet er
    for øvrig identisk med det offentlige `/public/.../sessions`-
    endepunktet (ADR-018) for enhver som allerede kan se turneringen, så
    dette utvider ikke hva som er synlig, kun HVEM som kan nå det via
    denne org-scopede stien."""
    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Turneringen 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_sessions(conn, tournament_id)


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sessions",
    response_model=SessionOut,
    status_code=201,
)
async def create_session(
    tournament_id: str,
    body: SessionCreate,
    organization_id: str = Depends(get_authorized_org),
) -> SessionOut:
    allowance_json = json.dumps(body.allowance_override) if body.allowance_override is not None else None
    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            """
            INSERT INTO session
                (organization_id, tournament_id, sequence, name, format,
                 hole_config, course_id, points_per_match, allowance_override, scoring_mode,
                 scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
            RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
                      course_id::text, points_per_match::float AS points_per_match,
                      allowance_override::text AS allowance_override, scoring_mode,
                      scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
            """,
            organization_id,
            tournament_id,
            body.sequence,
            body.name,
            body.format,
            body.hole_config,
            body.course_id,
            body.points_per_match,
            allowance_json,
            body.scoring_mode,
            body.scheduled_at,
            body.tee_interval_minutes,
            body.start_hole,
            body.shamble_best_n,
        )
    return _session_out(row, set())


class SessionUpdate(BaseModel):
    """Bevisst IKKE format/scoring_mode/hole_config ennå -- disse har dypere
    strukturelle konsekvenser for allerede registrerte scores (bytter man
    scoring_mode havner gamle oppføringer i feil tabell; bytter man format
    endres reglene for match_participant) som fortjener egen, senere design.
    course_id er den ENESTE feltet som krever spesialhåndtering her (se
    _remap_course) -- resten er en vanlig exclude_unset-oppdatering."""

    name: str | None = None
    scheduled_at: datetime | None = None
    tee_interval_minutes: int | None = None
    start_hole: int | None = Field(default=None, ge=1, le=18)
    points_per_match: float | None = None
    allowance_override: dict | None = None
    course_id: str | None = None


async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
    """Reist av brukeren (2026-07-18): en organisator oppdager MIDT I RUNDEN
    at feil bane var satt opp -- de faktisk registrerte slagene er ekte og
    skal IKKE røres, men `match_participant.tee_id` peker til en tee som
    hører til DEN GAMLE banen, og må flyttes til en tilsvarende tee på den
    nye for at handicap-beregningen skal bli riktig videre.

    Matcher på tee-navn (case-insensitive) -- kjønn hører til RATINGEN, ikke
    selve utslaget (ADR-029), så remap-en sjekker i tillegg at den nye
    tee-en faktisk har en full_18-rating for hver berørte spillers kjønn.
    Finnes ingen tilsvarende, gyldig tee på den nye banen for én eller
    flere spillere, avvises HELE bane-byttet tydelig FØR noe skrives --
    heller stoppe enn å gjette feil (samme "fail loudly" prinsipp som
    ADR-019 sin importvalidering).
    """
    course_exists = await conn.fetchval("SELECT id FROM course WHERE id = $1", new_course_id)
    if course_exists is None:
        raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")

    participants = await conn.fetch(
        """
        SELECT mp.id::text AS id, t.name AS tee_name, p.gender AS player_gender
        FROM match_participant mp
        JOIN match m ON m.id = mp.match_id
        JOIN tee t ON t.id = mp.tee_id
        JOIN team_roster tr ON tr.id = mp.team_roster_id
        JOIN player p ON p.id = tr.player_id
        WHERE m.session_id = $1
        """,
        session_id,
    )

    remap: dict[str, str] = {}
    missing: set[str] = set()
    for p in participants:
        new_tee_id = await conn.fetchval(
            """
            SELECT t.id::text FROM tee t
            JOIN tee_rating tr ON tr.tee_id = t.id AND tr.scope = 'full_18'
                               AND tr.gender IS NOT DISTINCT FROM $3
            WHERE t.course_id = $1 AND lower(t.name) = lower($2)
            """,
            new_course_id,
            p["tee_name"],
            p["player_gender"],
        )
        if new_tee_id is None:
            missing.add(p["tee_name"])
        else:
            remap[p["id"]] = new_tee_id

    if missing:
        raise app_error(
            400,
            "VALIDATION_FAILED",
            "Den nye banen mangler en tee som matcher: "
            + ", ".join(sorted(missing))
            + ". Legg til tilsvarende tee på den nye banen før du bytter.",
        )

    for participant_id, new_tee_id in remap.items():
        await conn.execute("UPDATE match_participant SET tee_id = $1 WHERE id = $2", new_tee_id, participant_id)

    await conn.execute("UPDATE session SET course_id = $1 WHERE id = $2", new_course_id, session_id)


async def _recompute_session_matches(
    conn, session_id: str, format_: str, allowance_override: dict | None
) -> None:
    """Regner om handicap + matchstatus for HVER match i økten -- brukt etter
    et bane- eller allowance-bytte. Bevisst UAVHENGIG av om en match allerede
    er avgjort (brukeren bekreftet eksplisitt: en bane-korrigering skal kunne
    endre et allerede cachet resultat, ikke bare blokkeres av den vanlige
    ALREADY_DECIDED-sperren som gjelder NYE slag-innsendinger)."""
    config = parse_allowance_config(format_, allowance_override)
    matches = await conn.fetch("SELECT id::text AS id FROM match WHERE session_id = $1", session_id)
    for m in matches:
        for side in ("a", "b"):
            await compute_and_store_side_handicaps(conn, m["id"], side, format_, config)
        await recompute_and_cache_match_state(conn, m["id"])


@router.patch("/orgs/{organization_id}/sessions/{session_id}", response_model=SessionOut)
async def update_session(
    session_id: str,
    body: SessionUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> SessionOut:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    async with org_connection(organization_id) as conn, translate_db_errors():
        session = await conn.fetchrow(
            "SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
            session_id,
        )
        if session is None:
            raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")

        new_course_id = updates.pop("course_id", None)
        allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates

        if updates:
            set_clauses = []
            values: list = []
            for i, (key, value) in enumerate(updates.items(), start=1):
                if key == "allowance_override":
                    set_clauses.append(f"allowance_override = ${i}::jsonb")
                    values.append(json.dumps(value) if value is not None else None)
                else:
                    set_clauses.append(f"{key} = ${i}")
                    values.append(value)
            values.append(session_id)
            await conn.execute(
                f"UPDATE session SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
                *values,
            )

        if new_course_id is not None and new_course_id != session["course_id"]:
            await _remap_course(conn, session_id, new_course_id)
            allowance_or_points_changed = True  # banebytte krever alltid omregning

        if allowance_or_points_changed:
            current = await conn.fetchrow(
                "SELECT allowance_override::text AS allowance_override FROM session WHERE id = $1",
                session_id,
            )
            current_override = json.loads(current["allowance_override"]) if current["allowance_override"] else None
            await _recompute_session_matches(
                conn, session_id, session["format"], current_override
            )

        row = await conn.fetchrow(
            """
            SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
                   course_id::text, points_per_match::float AS points_per_match,
                   allowance_override::text AS allowance_override, scoring_mode,
                   scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
            FROM session WHERE id = $1
            """,
            session_id,
        )
        locked = await locked_team_ids(conn, session_id)

    return _session_out(row, locked)


@router.delete("/orgs/{organization_id}/sessions/{session_id}", status_code=204)
async def delete_session(
    session_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    """Kun tomme økter kan slettes (ingen matcher opprettet ennå) -- dekker
    "jeg satte opp feil økt, fjern den" FØR den er tatt i bruk. En økt med
    matcher (og dermed potensielt registrerte scores/poeng) må rettes med
    PATCH (bane-/felt-korrigering) i stedet for å kunne slettes -- hindrer
    at noen ved et uhell sletter ekte kamper/resultater."""
    async with org_connection(organization_id) as conn:
        session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id)
        if session is None:
            raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")

        match_count = await conn.fetchval("SELECT count(*) FROM match WHERE session_id = $1", session_id)
        if match_count > 0:
            raise app_error(
                409,
                "VALIDATION_FAILED",
                "Økten har allerede matcher og kan ikke slettes -- bruk PATCH for å korrigere den i stedet.",
            )

        await conn.execute("DELETE FROM session WHERE id = $1", session_id)


# ---------------------------------------------------------------------------
# Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon,
# økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034).
#
# Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se
# begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå
# (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per
# (økt, deltaker) via match_participant.invitation_sent_at. Locale er
# bevisst alltid 'nb' -- en spiller uten konto har ingen lagret
# språkpreferanse å lese.
# ---------------------------------------------------------------------------

class SendInvitationsOut(BaseModel):
    sent: int
    skipped_has_account: int
    skipped_no_email: int
    skipped_already_sent: int


@router.post(
    "/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations",
    response_model=SendInvitationsOut,
)
async def send_scorecard_invitations(
    session_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> SendInvitationsOut:
    async with org_connection(organization_id) as conn:
        session = await conn.fetchrow(
            "SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1",
            session_id,
        )
        if session is None:
            raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
        tournament_name = await conn.fetchval(
            "SELECT name FROM tournament WHERE id = $1", session["tournament_id"]
        )
        session_label = (session["name"] or "").strip() or "en økt"

        rows = await conn.fetch(
            """
            SELECT mp.id::text AS participant_id, mp.invitation_sent_at,
                   p.display_name, p.email, p.user_id::text AS user_id,
                   m.status_text
            FROM match_participant mp
            JOIN match m ON m.id = mp.match_id
            JOIN team_roster tr ON tr.id = mp.team_roster_id
            JOIN player p ON p.id = tr.player_id
            WHERE m.session_id = $1
            """,
            session_id,
        )

        sent = 0
        skipped_has_account = 0
        skipped_no_email = 0
        skipped_already_sent = 0

        for r in rows:
            if r["user_id"] is not None:
                skipped_has_account += 1
                continue
            if not r["email"]:
                skipped_no_email += 1
                continue
            if r["invitation_sent_at"] is not None:
                skipped_already_sent += 1
                continue

            # Kun meningsfull for individuell-ball-formater (singles/
            # fourball) -- for delt-ball-formater er match_participant_id
            # ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig
            # NULL der uten noen egen format-sjekk her.
            gross = await conn.fetchval(
                "SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1",
                r["participant_id"],
            )
            if r["status_text"]:
                summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}."
            else:
                summary = f"Du var satt opp for {session_label} i {tournament_name}."
            if gross is not None:
                summary += f" Du hadde {gross} slag totalt."

            email = r["email"].strip().lower()
            now = datetime.now(timezone.utc)
            raw_token = secrets.token_urlsafe(32)
            expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
            # Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
            # request_magic_link (routers/auth.py) -- begrenser vinduet en
            # eldre, lekket lenke kan misbrukes i.
            await conn.execute(
                "UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
                email,
            )
            await conn.execute(
                "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
                email,
                _hash_secret(raw_token),
                expires_at,
            )
            if settings.SMTP_CONFIGURED:
                try:
                    await send_session_result_email(
                        email, r["display_name"], tournament_name, summary, raw_token, "nb"
                    )
                except Exception:
                    # Se app/email.py sitt mønster -- driftsfeil i
                    # utsendingen skal aldri hindre resten av utsendingen
                    # (token-raden består uansett, lenken virker fortsatt
                    # hvis spilleren ber om en ny normalt).
                    traceback.print_exc()
            elif settings.DEV_LOG_MAGIC_LINKS:
                # Samme "[DEV] Magic link for ..."-linjeformat som
                # request_magic_link (routers/auth.py) FØRST -- slik at
                # eksisterende dev-/test-verktøy som harvester token derfra
                # fungerer uendret her også -- pluss en egen, lesbar
                # oppsummeringslinje.
                print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
                print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True)

            await conn.execute(
                "UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1",
                r["participant_id"],
            )
            sent += 1

        return SendInvitationsOut(
            sent=sent,
            skipped_has_account=skipped_has_account,
            skipped_no_email=skipped_no_email,
            skipped_already_sent=skipped_already_sent,
        )


# ---------------------------------------------------------------------------
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
# ---------------------------------------------------------------------------

class SponsorCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    url: str | None = None


_SPONSOR_COLUMNS = "id::text, name, url, logo_key"


class Sponsor(BaseModel):
    id: str
    name: str
    url: str | None
    logo_key: str | None
    logo_url: str | None


def _sponsor_from_row(row) -> Sponsor:
    data = dict(row)
    key = data["logo_key"]
    return Sponsor(**data, logo_url=storage.public_url(key) if key else None)


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
    response_model=list[Sponsor],
)
async def list_sponsors(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[Sponsor]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            f"SELECT {_SPONSOR_COLUMNS} FROM tournament_sponsor WHERE tournament_id = $1 ORDER BY created_at",
            tournament_id,
        )
    return [_sponsor_from_row(r) for r in rows]


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors",
    response_model=Sponsor,
    status_code=201,
)
async def add_sponsor(
    tournament_id: str,
    body: SponsorCreate,
    organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
    async with org_connection(organization_id) as conn, translate_db_errors():
        tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if tournament is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        row = await conn.fetchrow(
            f"""
            INSERT INTO tournament_sponsor (organization_id, tournament_id, name, url)
            VALUES ($1, $2, $3, $4)
            RETURNING {_SPONSOR_COLUMNS}
            """,
            organization_id,
            tournament_id,
            body.name,
            body.url,
        )
    return _sponsor_from_row(row)


class SponsorUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1, max_length=200)
    url: str | None = None
    # logo_key er BEVISST IKKE med -- settes kun via upload_sponsor_logo
    # under, samme begrunnelse som tournament.hero_image_key over.


@router.patch(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
    response_model=Sponsor,
)
async def update_sponsor(
    tournament_id: str,
    sponsor_id: str,
    body: SponsorUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
    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(sponsor_id)
    values.append(tournament_id)

    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            f"""
            UPDATE tournament_sponsor SET {', '.join(set_clauses)}
            WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
            RETURNING {_SPONSOR_COLUMNS}
            """,
            *values,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")
    return _sponsor_from_row(row)


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}/logo",
    response_model=Sponsor,
)
async def upload_sponsor_logo(
    tournament_id: str,
    sponsor_id: str,
    file: UploadFile,
    organization_id: str = Depends(get_authorized_org),
) -> Sponsor:
    if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
        raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")

    raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
    if len(raw) > storage.MAX_UPLOAD_BYTES:
        raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")

    async with org_connection(organization_id) as conn:
        exists = await conn.fetchval(
            "SELECT id FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2",
            sponsor_id,
            tournament_id,
        )
        if exists is None:
            raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")

        try:
            key = await storage.upload_image("sponsors", sponsor_id, raw)
        except storage.InvalidImageError:
            raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")

        row = await conn.fetchrow(
            f"UPDATE tournament_sponsor SET logo_key = $1 WHERE id = $2 RETURNING {_SPONSOR_COLUMNS}",
            key,
            sponsor_id,
        )
    return _sponsor_from_row(row)


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/sponsors/{sponsor_id}",
    status_code=204,
)
async def delete_sponsor(
    tournament_id: str,
    sponsor_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        deleted = await conn.fetchval(
            "DELETE FROM tournament_sponsor WHERE id = $1 AND tournament_id = $2 RETURNING id",
            sponsor_id,
            tournament_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.")


# ---------------------------------------------------------------------------
# Konkurranseklasser (2026-08-03) -- fritt navngitte, valgfritt standard-
# utslag. Delt mellom lag- og individuelle turneringer (samme `tournament`-
# tabell). Brukes til å forhåndsutfylle riktig utslag når en deltaker legges
# til en match (her)/runde (individual_tournaments.py), og -- KUN for
# individuelle turneringer -- til å dele opp leaderboardet. Se
# 053_tournament_classes.sql for full begrunnelse.
# ---------------------------------------------------------------------------

class TournamentClassCreate(BaseModel):
    name: str = Field(min_length=1, max_length=200)
    default_tee_id: str | None = None


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

    name: str | None = Field(default=None, min_length=1, max_length=200)
    default_tee_id: str | None = None


_CLASS_COLUMNS = """
    tc.id::text, tc.name, tc.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
"""


class TournamentClass(BaseModel):
    id: str
    name: str
    default_tee_id: str | None
    default_tee_name: str | None


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/classes",
    response_model=list[TournamentClass],
)
async def list_tournament_classes(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> list[TournamentClass]:
    async with org_connection(organization_id) as conn:
        rows = await conn.fetch(
            f"""
            SELECT {_CLASS_COLUMNS}
            FROM tournament_class tc
            LEFT JOIN tee ON tee.id = tc.default_tee_id
            WHERE tc.tournament_id = $1
            ORDER BY tc.created_at
            """,
            tournament_id,
        )
    return [TournamentClass(**dict(r)) for r in rows]


@router.post(
    "/orgs/{organization_id}/tournaments/{tournament_id}/classes",
    response_model=TournamentClass,
    status_code=201,
)
async def create_tournament_class(
    tournament_id: str,
    body: TournamentClassCreate,
    organization_id: str = Depends(get_authorized_org),
) -> TournamentClass:
    async with org_connection(organization_id) as conn, translate_db_errors():
        tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id)
        if tournament is None:
            raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
        row = await conn.fetchrow(
            f"""
            WITH inserted AS (
                INSERT INTO tournament_class (organization_id, tournament_id, name, default_tee_id)
                VALUES ($1, $2, $3, $4)
                RETURNING id, name, default_tee_id
            )
            SELECT inserted.id::text, inserted.name,
                   inserted.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
            FROM inserted LEFT JOIN tee ON tee.id = inserted.default_tee_id
            """,
            organization_id,
            tournament_id,
            body.name,
            body.default_tee_id,
        )
    return TournamentClass(**dict(row))


@router.patch(
    "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}",
    response_model=TournamentClass,
)
async def update_tournament_class(
    tournament_id: str,
    class_id: str,
    body: TournamentClassUpdate,
    organization_id: str = Depends(get_authorized_org),
) -> TournamentClass:
    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(class_id)
    values.append(tournament_id)

    async with org_connection(organization_id) as conn, translate_db_errors():
        row = await conn.fetchrow(
            f"""
            WITH updated AS (
                UPDATE tournament_class SET {', '.join(set_clauses)}
                WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
                RETURNING id, name, default_tee_id
            )
            SELECT updated.id::text, updated.name,
                   updated.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name
            FROM updated LEFT JOIN tee ON tee.id = updated.default_tee_id
            """,
            *values,
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.")
    return TournamentClass(**dict(row))


@router.delete(
    "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}",
    status_code=204,
)
async def delete_tournament_class(
    tournament_id: str,
    class_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> None:
    async with org_connection(organization_id) as conn:
        deleted = await conn.fetchval(
            "DELETE FROM tournament_class WHERE id = $1 AND tournament_id = $2 RETURNING id",
            class_id,
            tournament_id,
        )
        if deleted is None:
            raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.")


# ---------------------------------------------------------------------------
# Leaderboard (turnering-total, summert på tvers av alle økter/matcher)
#
# `match.team_a_id`/`team_b_id` settes PER MATCH ved opprettelse (matches.py),
# ikke garantert konsistent på tvers av matcher -- en organisator kunne i
# prinsippet opprettet match 1 med team_a=Rød og match 3 med team_a=Blå. Derfor
# summeres ALT (både total og per-økt) på det EKTE lag-id-et, aldri på
# "a"/"b"-labelen -- se points_by_team under.
# ---------------------------------------------------------------------------

class TeamStanding(BaseModel):
    team_id: str
    team_name: str
    color: str | None
    points: float
    # ADR-020 Beslutning B: hvis pågående matcher holder seg som de står nå --
    # ikke-avgjorte matcher bidrar med full poengsum til leading_side (delt
    # likt ved "AS"/ikke startet). Avgjorte matcher bidrar likt til begge.
    projected_points: float


class SessionStanding(BaseModel):
    session_id: str
    sequence: int
    name: str | None
    points_by_team: dict[str, float]
    projected_points_by_team: dict[str, float]
    matches_total: int
    matches_decided: int


class Leaderboard(BaseModel):
    teams: list[TeamStanding]
    matches_total: int
    matches_decided: int
    sessions: list[SessionStanding]


async def fetch_leaderboard(conn, tournament_id: str) -> Leaderboard:
    """Delt mellom den innloggede (get_leaderboard under) og den offentlige
    /public/tournaments/{id}/leaderboard (registration.py, tilskuer-rolle) --
    ingen spiller-nivå-data i responsmodellene (kun lag-id/-navn/-farge/
    poeng), så ingen ekstra synlighetsfiltrering trengs utover den vanlige
    tournament.visibility-sjekken den offentlige ruten selv gjør FØR denne
    kalles."""
    teams = await conn.fetch(
        "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at",
        tournament_id,
    )
    if len(teams) != 2:
        raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.")

    rows = await conn.fetch(
        """
        SELECT s.id::text AS session_id, s.sequence, s.name,
               s.points_per_match::float AS points_per_match,
               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, m.points_side_b::float AS points_side_b,
               m.leading_side
        FROM match m
        JOIN session s ON s.id = m.session_id
        WHERE s.tournament_id = $1
        ORDER BY s.sequence, m.sequence
        """,
        tournament_id,
    )

    team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams}
    team_projected: dict[str, float] = {t["id"]: 0.0 for t in teams}
    session_order: list[str] = []
    sessions: dict[str, dict] = {}
    matches_total = 0
    matches_decided = 0

    for r in rows:
        matches_total += 1
        sid = r["session_id"]
        if sid not in sessions:
            session_order.append(sid)
            sessions[sid] = {
                "session_id": sid,
                "sequence": r["sequence"],
                "name": r["name"],
                "points_by_team": {t["id"]: 0.0 for t in teams},
                "projected_points_by_team": {t["id"]: 0.0 for t in teams},
                "matches_total": 0,
                "matches_decided": 0,
            }
        sessions[sid]["matches_total"] += 1

        # Projisert bidrag: avgjorte matcher gir samme tall som faktisk
        # (de ER allerede det de blir). Ikke-avgjorte matcher gir hele
        # øktens poengsum til den som leder NÅ, delt 0,5/0,5 ved "AS"
        # eller en match som ennå ikke har noen registrerte hull.
        if r["points_side_a"] is not None:
            proj_a, proj_b = r["points_side_a"], r["points_side_b"]
        else:
            ppm = r["points_per_match"]
            if r["leading_side"] == "a":
                proj_a, proj_b = ppm, 0.0
            elif r["leading_side"] == "b":
                proj_a, proj_b = 0.0, ppm
            else:
                proj_a = proj_b = ppm / 2
        team_projected[r["team_a_id"]] = team_projected.get(r["team_a_id"], 0.0) + proj_a
        team_projected[r["team_b_id"]] = team_projected.get(r["team_b_id"], 0.0) + proj_b
        sessions[sid]["projected_points_by_team"][r["team_a_id"]] = (
            sessions[sid]["projected_points_by_team"].get(r["team_a_id"], 0.0) + proj_a
        )
        sessions[sid]["projected_points_by_team"][r["team_b_id"]] = (
            sessions[sid]["projected_points_by_team"].get(r["team_b_id"], 0.0) + proj_b
        )

        if r["points_side_a"] is None:
            continue
        matches_decided += 1
        sessions[sid]["matches_decided"] += 1
        team_totals[r["team_a_id"]] = team_totals.get(r["team_a_id"], 0.0) + r["points_side_a"]
        team_totals[r["team_b_id"]] = team_totals.get(r["team_b_id"], 0.0) + r["points_side_b"]
        sessions[sid]["points_by_team"][r["team_a_id"]] = (
            sessions[sid]["points_by_team"].get(r["team_a_id"], 0.0) + r["points_side_a"]
        )
        sessions[sid]["points_by_team"][r["team_b_id"]] = (
            sessions[sid]["points_by_team"].get(r["team_b_id"], 0.0) + r["points_side_b"]
        )

    team_standings = [
        TeamStanding(
            team_id=t["id"],
            team_name=t["name"],
            color=t["color"],
            points=team_totals[t["id"]],
            projected_points=team_projected[t["id"]],
        )
        for t in teams
    ]

    return Leaderboard(
        teams=team_standings,
        matches_total=matches_total,
        matches_decided=matches_decided,
        sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])],
    )


@router.get(
    "/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard",
    response_model=Leaderboard,
)
async def get_leaderboard(
    tournament_id: str,
    organization_id: str = Depends(get_authorized_org),
) -> Leaderboard:
    async with org_connection(organization_id) as conn:
        return await fetch_leaderboard(conn, tournament_id)


# ---------------------------------------------------------------------------
# Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder
# og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet
# for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke
# summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN
# meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format
# (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball-
# formater og hole_result-modus ikke har noen individuell brutto-score.
# ---------------------------------------------------------------------------

class IndividualLeaderboardEntry(BaseModel):
    participant_id: str
    display_name: str
    team_id: str
    team_name: str
    team_color: str | None
    thru: int
    gross_score: int | None
    gross_to_par: int | None
    net_score: int | None
    net_to_par: int | None
    total_points: int | None


class IndividualLeaderboard(BaseModel):
    session_id: str
    format: str
    entries: list[IndividualLeaderboardEntry]


async def fetch_individual_leaderboard(
    conn, organization_id: str, session_id: str, viewer_user_id: str | None
) -> IndividualLeaderboard:
    session = await conn.fetchrow(
        """
        SELECT tournament_id::text AS tournament_id, format, scoring_mode,
               hole_config::text AS hole_config, course_id::text AS course_id
        FROM session WHERE id = $1
        """,
        session_id,
    )
    if session is None:
        raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
    if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"):
        raise app_error(
            400,
            "VALIDATION_FAILED",
            "Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.",
        )

    hole_rows = await conn.fetch(
        "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
        session["course_id"],
    )
    if len(hole_rows) != 18:
        raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
    par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
    all_18_si = [r["stroke_index"] for r in hole_rows]
    played = played_hole_numbers(session["hole_config"])

    # Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026):
    # egen side FØR reveal, hele feltet ETTER.
    locked = await locked_team_ids(conn, session_id)
    revealed = len(locked) >= 2
    own = (
        set()
        if revealed
        else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id)
    )

    participant_rows = await conn.fetch(
        """
        SELECT mp.id::text AS participant_id, mp.playing_handicap,
               tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color,
               p.display_name
        FROM match_participant mp
        JOIN match m ON m.id = mp.match_id
        JOIN team_roster tr ON tr.id = mp.team_roster_id
        JOIN team t ON t.id = tr.team_id
        JOIN player p ON p.id = tr.player_id
        WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[]))
        ORDER BY p.display_name
        """,
        session_id,
        revealed,
        list(own),
    )

    entries: list[IndividualLeaderboardEntry] = []
    for r in participant_rows:
        score_rows = await conn.fetch(
            "SELECT hole_number, gross_strokes FROM hole_score "
            "WHERE match_participant_id = $1 ORDER BY hole_number",
            r["participant_id"],
        )
        gross_score = gross_to_par = net_score = net_to_par = total_points = None
        if score_rows:
            gross_score = sum(s["gross_strokes"] for s in score_rows)
            par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows)
            gross_to_par = gross_score - par_total

            if r["playing_handicap"] is not None:
                strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
                strokes_by_hole = dict(zip(played, strokes_list))
                strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
                net_score = gross_score - strokes_received_total
                net_to_par = gross_to_par - strokes_received_total
                # Samme stableford-formel som round-leaderboardets
                # total_points (rounds.py) -- 2 poeng for netto par, +/-1
                # per slag, gulvet på 0.
                total_points = sum(
                    max(
                        0,
                        par_by_hole[s["hole_number"]]
                        - (s["gross_strokes"] - strokes_by_hole[s["hole_number"]])
                        + 2,
                    )
                    for s in score_rows
                )

        entries.append(
            IndividualLeaderboardEntry(
                participant_id=r["participant_id"],
                display_name=r["display_name"],
                team_id=r["team_id"],
                team_name=r["team_name"],
                team_color=r["team_color"],
                thru=len(score_rows),
                gross_score=gross_score,
                gross_to_par=gross_to_par,
                net_score=net_score,
                net_to_par=net_to_par,
                total_points=total_points,
            )
        )

    return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)


@router.get(
    "/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard",
    response_model=IndividualLeaderboard,
)
async def get_individual_leaderboard(
    session_id: str,
    organization_id: str = Depends(get_authorized_org),
    user: CurrentUser = Depends(get_current_user),
) -> IndividualLeaderboard:
    async with org_connection(organization_id) as conn:
        return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)
