"""
Autentisering: magic-link/passord-forespørsel/-verifisering, 2FA, utlogging,
"hvem er jeg" (ADR-009, ADR-021).

Sikkerhetsmønster (se ADR-009/ADR-021):
  - request-link svarer ALLTID identisk, uansett om e-posten finnes eller
    nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke
    hvilke e-poster som har konto (enumerering). login-password svarer
    identisk (401 INVALID_CREDENTIALS) uansett om e-posten finnes, mangler
    passord, eller passordet er feil -- samme prinsipp.
  - Kun SHA-256-hashen av magic-link-token/2FA-kode lagres, aldri klartekst.
    Passord hashes med Argon2id (app/auth.py sin hash_password), ikke
    SHA-256 -- se ADR-021 Beslutning B.
  - app_user opprettes FØRST når en gyldig, uforbrukt magic-link-token løses
    inn (IKKE når lenken bare forespørres) -- hindrer massopprettelse av
    kontoer for e-poster man ikke eier. Passord kan kun SETTES av en
    allerede innlogget bruker (set-password), aldri ved kontoopprettelse.
  - Token-/kode-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke
    les-sjekk-skriv) for å hindre at to samtidige forsøk begge lykkes.
  - Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED,
    ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri på
    som standard). En driftsfeil i selve utsendingen logges server-side og
    lekker ALDRI til klienten som en annen respons.

To-stegs innlogging (ADR-021 Beslutning D/E): primær-autentisering
(verify-link ELLER login-password) utsteder ALDRI direkte en full sesjon --
den går alltid via `_issue_login_result()`, som avgjør om 2FA kreves
(brukeren har selv slått det på) eller må settes opp FØRST (org-eier/admin
uten 2FA konfigurert), og utsteder riktig sesjons-STADIE (se app/auth.py).

i18n (se ADR-015): `locale` sendes av klienten ved forespørsel, lagres på
token-raden, og brukes BÅDE til å velge språk på selve e-posten OG til å
sette en HELT NY brukers `preferred_locale`. En EKSISTERENDE bruker som ber
om en ny lenke får IKKE sin lagrede preferanse overskrevet.
"""

import base64
import hashlib
import secrets
import traceback
from datetime import date, datetime, timedelta, timezone
from io import BytesIO
from typing import Literal

import qrcode
from fastapi import APIRouter, Depends, Request, Response, UploadFile
from pydantic import BaseModel, EmailStr, Field

from .. import storage
from ..auth import (
    CurrentUser,
    PendingUser,
    SESSION_COOKIE_NAME,
    create_session_token,
    generate_totp_secret,
    get_current_or_enrolling_user,
    get_current_user,
    get_pending_user,
    hash_password,
    resolve_user_id_by_email,
    should_use_secure_cookies,
    totp_provisioning_uri,
    user_requires_2fa_enrollment,
    verify_password,
    verify_totp_code,
)
from ..config import settings
from ..db import org_connection, plain_connection
from ..email import (
    send_email_change_confirmation,
    send_magic_link_email,
    send_new_account_alert_email,
    send_secondary_email_verification,
    send_two_factor_code_email,
)
from ..errors import app_error, translate_db_errors
from ..rate_limit import RateLimiter

router = APIRouter(prefix="/auth", tags=["auth"])

_GENERIC_RESPONSE = {
    "status": "ok",
    "detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
}

# Rate limiting (sikkerhetsgjennomgang 2026-08-09, se app/rate_limit.py for
# begrunnelse/forbehold). IP-baserte grenser brukes der ressursen som
# beskyttes ikke er entydig knyttet til én konto (request-link/2fa-email-
# request kan gjelde en HVILKEN SOM HELST e-post, login-password likeledes).
# 2fa/verify og 2fa/setup/confirm brute-forcer derimot EN BESTEMT konto sin
# kode -- der er nøkkelen den faktiske (pending-/innloggede) bruker-ID-en i
# stedet for IP, se .check(...)-kallene i selve endepunktene, siden en
# angriper med en gyldig pending-sesjon ellers kunne omgått IP-basert
# begrensning ved å bytte IP mellom forsøk.
_login_password_limiter = RateLimiter(max_requests=10, window_seconds=600)
_request_link_limiter = RateLimiter(max_requests=5, window_seconds=600)
_two_factor_email_request_limiter = RateLimiter(max_requests=5, window_seconds=600)
_two_factor_verify_limiter = RateLimiter(
    max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
)
_two_factor_setup_confirm_limiter = RateLimiter(
    max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
)


def _hash_secret(raw: str) -> str:
    """Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
    2FA-engangskoder (begge er kortlevde, engangs hemmeligheter av samme
    trusselklasse). Passord bruker Argon2id i stedet (app/auth.py)."""
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def _set_session_cookie(response: Response, request: Request, token: str, stage: str) -> None:
    max_age = settings.SESSION_MAX_AGE_SECONDS if stage == "full" else 5 * 60
    response.set_cookie(
        SESSION_COOKIE_NAME,
        token,
        max_age=max_age,
        httponly=True,
        samesite="lax",
        secure=should_use_secure_cookies(request),
        path="/",
    )


def _build_qr_data_uri(data: str) -> str:
    img = qrcode.make(data)
    buf = BytesIO()
    img.save(buf, format="PNG")
    b64 = base64.b64encode(buf.getvalue()).decode("ascii")
    return f"data:image/png;base64,{b64}"


async def _link_round_participants_by_email(conn, user_id: str, email: str) -> None:
    """Kobler frittstående-runde-gjester (round_participant.guest_email) til
    denne nå-innloggede kontoen (2026-08-03) -- samme "kjør trygt på HVER
    innlogging"-idempotens som link_player_by_email (WHERE user_id IS NULL),
    men INGEN SECURITY DEFINER-bro trengs: round/round_participant har ingen
    RLS (ADR-033 Beslutning A, plain_connection()), så en rett UPDATE er nok.

    Bevisst IKKE en blind kobling -- se ADR-038-tillitsresonnementet: en
    FREMMED kan i prinsippet ha registrert deg som gjest med feil/dårlig
    score. `exclude_from_handicap = true` settes derfor alltid på nykoblede
    rader, uansett hva raden opprinnelig hadde -- personen må selv slå den
    PÅ (update_participant) for at runden skal telle mot faktisk HCP.
    `NOT EXISTS`-vaktet mot en allerede eksisterende ekte deltaker-rad for
    (round_id, user_id) -- ville ellers brutt UNIQUE-indeksen fra migrasjon
    027 hvis personen tilfeldigvis også er lagt til som ekte spiller på
    SAMME runde. guest_name/guest_first_name/guest_last_name nullstilles
    ved kobling (samme "nøyaktig ett av user_id/guest_first_name"-invariant
    som CHECK-constrainten på round_participant håndhever ved INSERT) --
    `gender` derimot IKKE: den er NOT NULL og brukt til tee-rating-oppslag
    for ALLE deltakere, ikke bare gjester (reelt funn under scratch-
    testing -- en første versjon nullet den også, brøt NOT NULL-
    constrainten). Beholder gjestens opprinnelig snapshottede kjønn
    uendret; update_participant sin egen guest_only_fields-sjekk låser
    uansett feltet mot videre endring så snart user_id er satt."""
    await conn.execute(
        """
        UPDATE round_participant AS rp
        SET user_id = $1,
            exclude_from_handicap = true,
            guest_name = NULL, guest_first_name = NULL, guest_last_name = NULL
        WHERE rp.user_id IS NULL
          AND lower(rp.guest_email) = lower($2)
          AND NOT EXISTS (
              SELECT 1 FROM round_participant rp2
              WHERE rp2.round_id = rp.round_id AND rp2.user_id = $1
          )
        """,
        user_id,
        email,
    )


class MagicLinkRequest(BaseModel):
    email: EmailStr
    locale: Literal["nb", "en"] = "nb"


@router.post("/request-link")
async def request_magic_link(body: MagicLinkRequest, _rl: None = Depends(_request_link_limiter)) -> dict:
    email = body.email.lower()
    now = datetime.now(timezone.utc)
    cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)

    async with plain_connection() as conn:
        recent = await conn.fetchval(
            """
            SELECT EXISTS (
                SELECT 1 FROM magic_link_token
                WHERE email = $1 AND consumed_at IS NULL AND created_at > $2
            )
            """,
            email,
            cooldown_cutoff,
        )
        if not recent:
            # Ugyldiggjør tidligere uforbrukte lenker for denne e-posten --
            # 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,
            )
            raw_token = secrets.token_urlsafe(32)
            expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
            await conn.execute(
                "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)",
                email,
                _hash_secret(raw_token),
                expires_at,
                body.locale,
            )
            if settings.DEV_LOG_MAGIC_LINKS:
                print(f"[DEV] Magic link for {email} ({body.locale}): {raw_token}", flush=True)
            elif settings.SMTP_CONFIGURED:
                try:
                    await send_magic_link_email(email, raw_token, body.locale)
                except Exception:
                    # Se modul-docstring: driftsfeil skal ALDRI endre klientens
                    # respons. Token-raden består og utløper naturlig hvis den
                    # aldri når frem.
                    traceback.print_exc()
            else:
                print(
                    f"[ADVARSEL] Verken DEV_LOG_MAGIC_LINKS eller SMTP er konfigurert "
                    f"-- ingen lenke ble sendt for {email}.",
                    flush=True,
                )

    return _GENERIC_RESPONSE


class MagicLinkVerify(BaseModel):
    token: str
    display_name: str | None = None


class SessionUser(BaseModel):
    id: str
    email: str
    display_name: str
    preferred_locale: str


class LoginResult(BaseModel):
    """Felles svarform for verify-link, login-password OG 2fa/verify (ADR-021
    Beslutning E) -- akkurat ÉN av de tre utfallene:
      - "success": full sesjon utstedt, `user` er fylt ut.
      - "2fa_required": brukeren har selv slått på 2FA -- `two_factor_method`
        forteller klienten hvilken kode-innsending som trengs.
      - "2fa_setup_required": org-eier/admin uten 2FA -- klienten må vise
        oppsett-flyten FØR noe annet blir tilgjengelig."""

    status: Literal["success", "2fa_required", "2fa_setup_required"]
    user: SessionUser | None = None
    two_factor_method: Literal["totp", "email"] | None = None


async def _issue_login_result(response: Response, request: Request, user_id: str) -> LoginResult:
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            """
            SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
                   two_factor_method
            FROM app_user WHERE id = $1
            """,
            user_id,
        )
        needs_enrollment = await user_requires_2fa_enrollment(conn, user_id)

    two_factor_method = user_row["two_factor_method"]

    if two_factor_method:
        token = create_session_token(user_id, stage="pending_2fa")
        _set_session_cookie(response, request, token, "pending_2fa")
        return LoginResult(status="2fa_required", two_factor_method=two_factor_method)

    if needs_enrollment:
        token = create_session_token(user_id, stage="must_enroll_2fa")
        _set_session_cookie(response, request, token, "must_enroll_2fa")
        return LoginResult(status="2fa_setup_required")

    token = create_session_token(user_id, stage="full")
    _set_session_cookie(response, request, token, "full")
    return LoginResult(
        status="success",
        user=SessionUser(
            id=user_row["id"],
            email=user_row["email"],
            display_name=user_row["display_name"],
            preferred_locale=user_row["preferred_locale"],
        ),
    )


@router.post("/verify-link", response_model=LoginResult)
async def verify_magic_link(body: MagicLinkVerify, response: Response, request: Request) -> LoginResult:
    token_hash = _hash_secret(body.token)
    async with plain_connection() as conn:
        # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
        # samtidige forsøk med samme lenke kan da ikke begge lykkes.
        token_row = await conn.fetchrow(
            """
            UPDATE magic_link_token
            SET consumed_at = now()
            WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
            RETURNING email, locale
            """,
            token_hash,
        )
        if token_row is None:
            raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
        email = token_row["email"]

        # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
        # e-postadresser") løses til EIERENS eksisterende konto -- må
        # sjekkes FØR en eventuell ny konto opprettes under, ellers ville
        # en innlogging på en sekundær adresse (som med vilje ALDRI står i
        # app_user.email) stille opprettet en helt ny, separat, feilaktig
        # konto.
        resolved_user_id = await resolve_user_id_by_email(conn, email)
        # Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
        # kontoens navn trengs ETTER at connection-blokken er lukket
        # (e-postutsending skal aldri skje mens en transaksjon/tilkobling
        # holdes åpen) -- fanget her, brukt lenger ned.
        is_new_account = False
        new_account_display_name = None

        if resolved_user_id is not None:
            user_id = resolved_user_id
        else:
            placeholder_name = email.split("@")[0]
            new_account_display_name = body.display_name or placeholder_name
            user_id = await conn.fetchval(
                """
                INSERT INTO app_user (email, display_name, preferred_locale)
                VALUES ($1, $2, $3)
                ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
                RETURNING id
                """,
                email,
                new_account_display_name,
                token_row["locale"],
            )
            if user_id is None:
                # En annen samtidig verifisering for samme e-post vant
                # innsettingen (eller brukeren fantes allerede) --
                # preferred_locale røres IKKE her, kun ved førstegangs-
                # opprettelse (se modul-docstring). Ikke en ny konto --
                # ingen driftsvarsel.
                user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
            else:
                is_new_account = True

        # Koble enhver player-rad (ADR-017) OG enhver ventende
        # organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
        # nå-innloggede kontoen. Trygt å kjøre på HVER innlogging -- begge
        # er idempotente, berører kun rader som ennå ikke er koblet/forbrukt.
        await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
        await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
        await _link_round_participants_by_email(conn, user_id, email)

    # Driftsvarsel til appens eier ved en HELT NY konto (2026-07-30) --
    # BEVISST etter at connection-blokken er lukket, samme "e-post skal
    # aldri sendes mens en tilkobling/transaksjon holdes åpen"-prinsipp som
    # resten av appen. En feil her skal ALDRI endre selve innloggings-
    # responsen -- fanget og logget, ikke kastet videre.
    if is_new_account:
        if settings.DEV_LOG_MAGIC_LINKS:
            print(f"[DEV] Ny konto opprettet: {email} ({new_account_display_name})", flush=True)
        elif settings.SMTP_CONFIGURED:
            try:
                await send_new_account_alert_email(
                    settings.NEW_ACCOUNT_ALERT_EMAIL, email, new_account_display_name
                )
            except Exception:
                traceback.print_exc()

    # user_id er en asyncpg UUID her (ingen ::text-cast i spørringene over) --
    # create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på
    # en rå UUID. Fant under scratch-testing: str() FØR den sendes videre.
    return await _issue_login_result(response, request, str(user_id))


class PasswordLoginRequest(BaseModel):
    email: EmailStr
    password: str


@router.post("/login-password", response_model=LoginResult)
async def login_with_password(
    body: PasswordLoginRequest,
    response: Response,
    request: Request,
    _rl: None = Depends(_login_password_limiter),
) -> LoginResult:
    email = body.email.lower()
    async with plain_connection() as conn:
        # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
        # e-postadresser") -- passordet ligger uansett på selve kontoen,
        # ikke adressen, så slå opp eieren og fortsett normalt.
        resolved_user_id = await resolve_user_id_by_email(conn, email)
        user_row = (
            await conn.fetchrow(
                "SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
            )
            if resolved_user_id is not None
            else None
        )
        # Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
        # feil passord) -- unngår enumerering, samme prinsipp som magic-link.
        if (
            user_row is None
            or user_row["password_hash"] is None
            or not verify_password(body.password, user_row["password_hash"])
        ):
            raise app_error(401, "INVALID_CREDENTIALS", "E-post eller passord er feil.")

        await conn.execute("SELECT link_player_by_email($1, $2)", user_row["id"], email)
        await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_row["id"], email)
        await _link_round_participants_by_email(conn, user_row["id"], email)

    return await _issue_login_result(response, request, user_row["id"])


class SetPasswordRequest(BaseModel):
    # Bevisst INGEN restriksjon på tegnsett -- spesialtegn/mellomrom skal
    # fungere korrekt (Argon2id, se app/auth.py). max_length er kun en
    # fornuftig DoS-grense mot ekstremt lange innsendinger, ikke en reell
    # begrensning på passfraser.
    password: str = Field(min_length=8, max_length=200)


@router.post("/set-password")
async def set_password(body: SetPasswordRequest, user: CurrentUser = Depends(get_current_user)) -> dict:
    hashed = hash_password(body.password)
    async with plain_connection() as conn:
        await conn.execute("UPDATE app_user SET password_hash = $1 WHERE id = $2", hashed, user.user_id)
    return {"status": "ok"}


@router.post("/remove-password")
async def remove_password(user: CurrentUser = Depends(get_current_user)) -> dict:
    async with plain_connection() as conn:
        await conn.execute("UPDATE app_user SET password_hash = NULL WHERE id = $1", user.user_id)
    return {"status": "ok"}


# ---------------------------------------------------------------------------
# 2FA: verifisering av en PÅGÅENDE innlogging (ADR-021 Beslutning C/E)
# ---------------------------------------------------------------------------


async def _send_two_factor_email_code(conn, user_id: str, email: str, locale: str) -> None:
    # Ugyldiggjør tidligere uforbrukte koder for denne brukeren -- samme
    # "begrens vinduet en gammel kode kan misbrukes i"-mønster som magic-link.
    await conn.execute(
        "UPDATE two_factor_code SET consumed_at = now() WHERE user_id = $1 AND consumed_at IS NULL",
        user_id,
    )
    code = f"{secrets.randbelow(1_000_000):06d}"
    await conn.execute(
        "INSERT INTO two_factor_code (user_id, code_hash, expires_at) VALUES ($1, $2, $3)",
        user_id,
        _hash_secret(code),
        datetime.now(timezone.utc) + timedelta(minutes=5),
    )
    if settings.DEV_LOG_MAGIC_LINKS:
        print(f"[DEV] 2FA-kode for {email}: {code}", flush=True)
    elif settings.SMTP_CONFIGURED:
        try:
            await send_two_factor_code_email(email, code, locale)
        except Exception:
            traceback.print_exc()


@router.post("/2fa/email/request")
async def request_two_factor_email_code(
    pending: PendingUser = Depends(get_pending_user),
    _rl: None = Depends(_two_factor_email_request_limiter),
) -> dict:
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            "SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
            pending.user_id,
        )
        if user_row["two_factor_method"] != "email":
            raise app_error(400, "VALIDATION_FAILED", "Denne kontoen bruker ikke e-post som 2FA-metode.")
        await _send_two_factor_email_code(conn, pending.user_id, user_row["email"], user_row["preferred_locale"])
    return {"status": "sent"}


class TwoFactorVerify(BaseModel):
    code: str


@router.post("/2fa/verify", response_model=LoginResult)
async def verify_two_factor(
    body: TwoFactorVerify,
    response: Response,
    request: Request,
    pending: PendingUser = Depends(get_pending_user),
) -> LoginResult:
    # Nøkkel på pending.user_id, ikke IP -- se begrunnelse ved limiter-
    # definisjonene lenger opp i filen.
    _two_factor_verify_limiter.check(pending.user_id)

    async with plain_connection() as conn:
        method = await conn.fetchval(
            "SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
        )
        valid = False
        if method == "totp":
            secret = await conn.fetchval(
                "SELECT totp_secret FROM app_user WHERE id = $1", pending.user_id
            )
            valid = secret is not None and verify_totp_code(secret, body.code.strip())
        elif method == "email":
            row = await conn.fetchrow(
                """
                UPDATE two_factor_code SET consumed_at = now()
                WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
                RETURNING id
                """,
                pending.user_id,
                _hash_secret(body.code.strip()),
            )
            valid = row is not None

    if not valid:
        raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")

    # Utsted en FULL sesjon direkte -- IKKE via _issue_login_result(), som
    # ville sett two_factor_method fortsatt satt og krevd EN NY runde med
    # 2FA for akkurat denne innloggingen (uendelig løkke). Samme fiks som
    # 2fa/setup/confirm, se kommentaren der -- fant begge under
    # scratch-testing.
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            """
            SELECT id::text AS id, email::text AS email, display_name, preferred_locale
            FROM app_user WHERE id = $1
            """,
            pending.user_id,
        )
    token = create_session_token(pending.user_id, stage="full")
    _set_session_cookie(response, request, token, "full")
    return LoginResult(
        status="success",
        user=SessionUser(
            id=user_row["id"],
            email=user_row["email"],
            display_name=user_row["display_name"],
            preferred_locale=user_row["preferred_locale"],
        ),
    )


# ---------------------------------------------------------------------------
# 2FA: OPPSETT (frivillig fra kontoinnstillinger, ELLER tvunget for
# org-eier/admin -- begge veier deler samme logikk, se
# get_current_or_enrolling_user i app/auth.py)
# ---------------------------------------------------------------------------


class TwoFactorSetupStart(BaseModel):
    method: Literal["totp", "email"]


class TwoFactorSetupStartResult(BaseModel):
    status: Literal["totp_ready", "email_sent"]
    secret: str | None = None
    otpauth_uri: str | None = None
    qr_code_data_uri: str | None = None


@router.post("/2fa/setup/start", response_model=TwoFactorSetupStartResult)
async def start_two_factor_setup(
    body: TwoFactorSetupStart, user: CurrentUser = Depends(get_current_or_enrolling_user)
) -> TwoFactorSetupStartResult:
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            "SELECT email::text AS email, preferred_locale FROM app_user WHERE id = $1", user.user_id
        )

        if body.method == "totp":
            # Ingenting lagres ennå -- secreten rundtures til klienten og
            # ekkoes tilbake i /2fa/setup/confirm, som er FØRSTE gang noe
            # persisteres. Unngår en halvferdig 2FA-tilstand i databasen
            # hvis brukeren forlater oppsettet uten å fullføre.
            secret = generate_totp_secret()
            uri = totp_provisioning_uri(secret, user_row["email"] or user.user_id)
            return TwoFactorSetupStartResult(
                status="totp_ready",
                secret=secret,
                otpauth_uri=uri,
                qr_code_data_uri=_build_qr_data_uri(uri),
            )

        await _send_two_factor_email_code(conn, user.user_id, user_row["email"], user_row["preferred_locale"])
        return TwoFactorSetupStartResult(status="email_sent")


class TwoFactorSetupConfirm(BaseModel):
    method: Literal["totp", "email"]
    code: str
    # Kun for totp -- klienten ekkoer secreten fra /2fa/setup/start tilbake,
    # se kommentaren der.
    secret: str | None = None


@router.post("/2fa/setup/confirm", response_model=SessionUser)
async def confirm_two_factor_setup(
    body: TwoFactorSetupConfirm,
    response: Response,
    request: Request,
    user: CurrentUser = Depends(get_current_or_enrolling_user),
) -> SessionUser:
    # Nøkkel på user.user_id, ikke IP -- se begrunnelse ved limiter-
    # definisjonene lenger opp i filen.
    _two_factor_setup_confirm_limiter.check(user.user_id)

    if body.method == "totp":
        if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
            raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
        async with plain_connection() as conn:
            await conn.execute(
                "UPDATE app_user SET two_factor_method = 'totp', totp_secret = $1 WHERE id = $2",
                body.secret,
                user.user_id,
            )
    else:
        async with plain_connection() as conn:
            row = await conn.fetchrow(
                """
                UPDATE two_factor_code SET consumed_at = now()
                WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
                RETURNING id
                """,
                user.user_id,
                _hash_secret(body.code.strip()),
            )
            if row is None:
                raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")
            await conn.execute(
                "UPDATE app_user SET two_factor_method = 'email' WHERE id = $1", user.user_id
            )

    # Oppsettet er fullført -- utsted en FULL sesjon direkte, IKKE via
    # _issue_login_result() sin vanlige gjenbrukte 2FA-sjekk: den ville nå
    # (feilaktig) sett two_factor_method nettopp satt og krevd EN NY runde
    # med 2FA-verifisering for akkurat denne innloggingen, selv om brukeren
    # bokstavelig talt akkurat beviste koden i confirm-kallet over. Fant
    # under scratch-testing (AssertionError -- resultatet ble "2fa_required"
    # i stedet for "success").
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            """
            SELECT id::text AS id, email::text AS email, display_name, preferred_locale
            FROM app_user WHERE id = $1
            """,
            user.user_id,
        )
    token = create_session_token(user.user_id, stage="full")
    _set_session_cookie(response, request, token, "full")
    return SessionUser(**dict(user_row))


@router.post("/2fa/disable")
async def disable_two_factor(user: CurrentUser = Depends(get_current_user)) -> dict:
    async with plain_connection() as conn:
        await conn.execute(
            "UPDATE app_user SET two_factor_method = NULL, totp_secret = NULL WHERE id = $1",
            user.user_id,
        )
    return {"status": "ok"}


@router.post("/logout")
async def logout(response: Response) -> dict:
    response.delete_cookie(SESSION_COOKIE_NAME, path="/")
    return {"status": "ok"}


class MyOrg(BaseModel):
    organization_id: str
    name: str
    role: str


class MyTournament(BaseModel):
    """Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder').
    Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT
    (`tournament_registration`) men ikke ennå rostret på et lag.

    `my_session_id`/`my_match_id` (2026-07-21, deltaker-tilgang-runden):
    én av brukerens egne matcher (foretrekker en ikke-avgjort fremfor en
    avgjort, ellers vilkårlig) -- lar frontend lenke direkte til lagets
    chat/scorekort uten at brukeren selv må navigere seg dit via
    program-/blind draw-skjermene (som fortsatt krever org-medlemskap for
    andre formål enn nettopp dette). `None` hvis brukeren ikke er lagt til
    i noen match ennå."""

    organization_id: str
    organization_name: str
    tournament_id: str
    tournament_name: str
    status: str
    team_id: str
    team_name: str
    team_color: str | None
    next_session_at: datetime | None
    my_session_id: str | None
    my_match_id: str | None


class SecondaryEmailOut(BaseModel):
    id: str
    email: str


# Kølle-bag (2026-07-24, ADR-031-mønster): fast, ikke-utvidbar liste --
# samme liste brukes i migrasjon 022 sin CHECK-constraint (bakstopper) og
# frontend sin avkrysningsliste. Maks 14 (den ekte golfregelen), håndhevet
# under i ProfileUpdate.
BAG_CLUBS: list[str] = [
    "Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood",
    "7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid",
    "1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron",
    "8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge",
    "Lob wedge", "Ultra-lob wedge", "Chipper", "Putter",
]
BagClub = Literal[tuple(BAG_CLUBS)]  # type: ignore[valid-type]


class Me(BaseModel):
    id: str
    email: str
    display_name: str
    preferred_locale: str
    has_password: bool
    two_factor_method: Literal["totp", "email"] | None
    organizations: list[MyOrg]
    # Personlig profil (ADR-031) -- ETT sett per konto, atskilt fra de
    # org-scopede `player`-radene (se ADR-031 for hvorfor de ikke slås
    # sammen til én ting).
    first_name: str | None
    last_name: str | None
    birth_date: date | None
    gender: str | None
    handicap_index: float | None
    # Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet
    # automatisk fra beste 8 av de ≤20 nyeste tellende Score
    # Differential-ene fra frittstående runder. Aldri direkte redigerbar
    # -- kun `handicap_index` over er det (manuelt satt, brukt til
    # beregning i runder som i dag). `None` betyr færre enn 3 tellende
    # runder ennå, ikke en feil.
    computed_handicap_index: float | None
    computed_handicap_index_updated_at: str | None
    home_club: str | None
    country: str | None
    bio: str | None
    mobile_country_code: str | None
    mobile_number: str | None
    bag_clubs: list[str]
    avatar_url: str | None
    # Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
    # profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/
    # hjemmeklubb/land) -- beskrivelse og bilde er bevisst IKKE med, de er
    # valgfrie. Frontend bruker denne til å tvinge en ny/ufullstendig
    # bruker gjennom profil-fullføring FØR resten av appen vises.
    profile_complete: bool
    my_tournaments: list[MyTournament]
    secondary_emails: list[SecondaryEmailOut]


@router.get("/me", response_model=Me)
async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
    # MERK: `organization` (org_self-policyen) kan IKKE joines direkte her via
    # plain_connection() -- migrasjon 005 fikset kun tomstreng-krasjen, den
    # endret ikke at org_self krever en MATCHENDE app.current_org for å vise
    # en rad i det hele tatt (riktig RLS-oppførsel, ikke en bug). En bruker
    # kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN
    # kontekst å sette for en tverr-org-spørring som denne. Løsningen er
    # derfor å slå opp hvert org-navn ETT OM GANGEN gjennom org_connection()
    # (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
    # men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
    # er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
    #
    # ADR-031: samme N+1-mønster brukt for "Mine runder", nå over org-er
    # funnet via player_organizations_for_user() (migrasjon 015) i stedet
    # for organization_membership -- en bruker kan være SPILLER i en org
    # helt uavhengig av om de er MEDLEM der.
    async with plain_connection() as conn:
        user_row = await conn.fetchrow(
            """
            SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
                   (password_hash IS NOT NULL) AS has_password, two_factor_method,
                   first_name, last_name, birth_date, gender,
                   handicap_index::float AS handicap_index,
                   computed_handicap_index::float AS computed_handicap_index,
                   computed_handicap_index_updated_at,
                   home_club, country, bio,
                   mobile_country_code, mobile_number, bag_clubs, avatar_key
            FROM app_user WHERE id = $1
            """,
            user.user_id,
        )
        membership_rows = await conn.fetch(
            "SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
            user.user_id,
        )
        player_org_rows = await conn.fetch(
            "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
            user.user_id,
        )
        secondary_email_rows = await conn.fetch(
            "SELECT id::text AS id, email::text AS email FROM user_secondary_email WHERE user_id = $1 ORDER BY created_at",
            user.user_id,
        )

    organizations = []
    for m in membership_rows:
        async with org_connection(m["organization_id"]) as org_conn:
            name = await org_conn.fetchval(
                "SELECT name FROM organization WHERE id = $1", m["organization_id"]
            )
        organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))

    my_tournaments: list[MyTournament] = []
    for p in player_org_rows:
        org_id = p["organization_id"]
        async with org_connection(org_id) as org_conn:
            org_name = await org_conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
            rows = await org_conn.fetch(
                """
                SELECT t.id::text AS tournament_id, t.name AS tournament_name,
                       t.status::text AS status,
                       tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color,
                       (SELECT min(s.scheduled_at) FROM session s
                        WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at,
                       mymatch.session_id AS my_session_id, mymatch.match_id AS my_match_id
                FROM player pl
                JOIN team_roster tr ON tr.player_id = pl.id
                JOIN team tm ON tm.id = tr.team_id
                JOIN tournament t ON t.id = tm.tournament_id
                LEFT JOIN LATERAL (
                    SELECT m.id::text AS match_id, m.session_id::text AS session_id
                    FROM match_participant mp
                    JOIN match m ON m.id = mp.match_id
                    WHERE mp.team_roster_id = tr.id
                    ORDER BY (m.points_side_a IS NULL) DESC, m.created_at DESC
                    LIMIT 1
                ) mymatch ON true
                WHERE pl.user_id = $1
                ORDER BY t.created_at DESC
                """,
                user.user_id,
            )
        for r in rows:
            my_tournaments.append(
                MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
            )

    profile_complete = all(
        user_row[field] is not None
        for field in ("first_name", "last_name", "birth_date", "gender", "handicap_index", "home_club", "country")
    )

    return Me(
        id=user_row["id"],
        email=user_row["email"],
        display_name=user_row["display_name"],
        preferred_locale=user_row["preferred_locale"],
        has_password=user_row["has_password"],
        two_factor_method=user_row["two_factor_method"],
        organizations=organizations,
        first_name=user_row["first_name"],
        last_name=user_row["last_name"],
        birth_date=user_row["birth_date"],
        gender=user_row["gender"],
        handicap_index=user_row["handicap_index"],
        computed_handicap_index=user_row["computed_handicap_index"],
        computed_handicap_index_updated_at=(
            user_row["computed_handicap_index_updated_at"].isoformat()
            if user_row["computed_handicap_index_updated_at"] else None
        ),
        home_club=user_row["home_club"],
        country=user_row["country"],
        bio=user_row["bio"],
        mobile_country_code=user_row["mobile_country_code"],
        mobile_number=user_row["mobile_number"],
        bag_clubs=list(user_row["bag_clubs"]) if user_row["bag_clubs"] else [],
        avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
        profile_complete=profile_complete,
        my_tournaments=my_tournaments,
        secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
    )


class ProfileUpdate(BaseModel):
    """Personlig profil (ADR-031) -- PATCH-semantikk via exclude_unset, som
    resten av appen. Et felt sendt eksplisitt som `null` NULLES (f.eks.
    fjern fødselsdato), et UTELATT felt endres ikke -- vanlig
    "sletting" av et enkeltfelt trenger derfor ingen egen DELETE-vei her,
    kun avatar (binært innhold) har sin egen under."""

    first_name: str | None = Field(default=None, max_length=100)
    last_name: str | None = Field(default=None, max_length=100)
    birth_date: date | None = None
    gender: str | None = Field(default=None, pattern="^[mfx]$")
    # WHS-maksimum er 54.0 -- en fersk spiller uten offisiell HCP ennå skal
    # bruke nettopp dette som utgangspunkt (frontend forhåndsutfyller
    # skjemaet med 54), ikke la feltet stå tomt. Nedre grense er romslig
    # nok til å dekke reelle plusshandicap.
    handicap_index: float | None = Field(default=None, ge=-10, le=54)
    home_club: str | None = Field(default=None, max_length=200)
    country: str | None = Field(default=None, max_length=100)
    bio: str | None = Field(default=None, max_length=500)
    # Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve
    # nummeret -- lar frontend tilby en egen landsnummer-velger uten å
    # måtte parse/splitte en fritekststreng i etterkant.
    mobile_country_code: str | None = Field(default=None, max_length=5)
    mobile_number: str | None = Field(default=None, max_length=30)
    # Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
    # en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
    bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)


@router.patch("/profile", response_model=Me)
async def update_profile(
    body: ProfileUpdate,
    user: CurrentUser = Depends(get_current_user),
) -> Me:
    updates = body.model_dump(exclude_unset=True)
    if not updates:
        raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")

    # Aldersgrense (2026-08-10, eksplisitt brukerkrav etter juridisk/
    # personvern-vurdering, se ARCHITECTURE_DECISIONS.md): TeeCup skal
    # ALDRI la noen under 13 år registrere seg (GDPR/personopplysningsloven
    # -- under 13 krever verifiserbart foreldresamtykke, som appen ikke har
    # støtte for). Håndhevet HER, server-side, ikke bare i skjemaet --
    # klientside-validering alene er ingen reell grense.
    if body.birth_date is not None:
        today = date.today()
        age = today.year - body.birth_date.year - (
            (today.month, today.day) < (body.birth_date.month, body.birth_date.day)
        )
        if age < 13:
            raise app_error(
                400, "UNDER_MINIMUM_AGE", "Du må være minst 13 år for å bruke TeeCup."
            )

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

    async with plain_connection() as conn:
        # HCP-historikk (FEATURE_BACKLOG.md, ADR-031 sitt "naturlig neste
        # steg"-punkt): les gjeldende verdi FØR den overskrives, slik at vi
        # kan avgjøre om dette faktisk er en ENDRING (ikke bare et PATCH-
        # kall som gjentar samme verdi) FØR vi logger en ny historikk-rad.
        old_hcp = None
        if "handicap_index" in updates:
            old_hcp = await conn.fetchval("SELECT handicap_index FROM app_user WHERE id = $1", user.user_id)

        await conn.execute(
            f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
            *values,
        )

        # `display_name` er brukt BREDT (org-medlemslister, invitasjons-
        # e-post, dashbord-hilsen) og settes i dag KUN fra e-postens
        # lokaldel ved kontoopprettelse (verify_magic_link) -- ADR-031s
        # profil-fullføring la til atskilte first_name/last_name-felt, men
        # rørte aldri display_name. Konsekvens (rapportert av bruker
        # 2026-07-25, egen konto): en bruker med fullstendig utfylt profil
        # viste fortsatt e-post-avledet plassholdernavn overalt. Fikset her
        # ved å synkronisere display_name automatisk hver gang for-/
        # etternavn endres -- KUN når begge faktisk er satt etterpå (unngår
        # et halvferdig "Fornavn None"-navn ved delvis utfylling).
        if "first_name" in updates or "last_name" in updates:
            name_row = await conn.fetchrow(
                "SELECT first_name, last_name FROM app_user WHERE id = $1", user.user_id
            )
            if name_row and name_row["first_name"] and name_row["last_name"]:
                full_name = f"{name_row['first_name']} {name_row['last_name']}".strip()
                await conn.execute("UPDATE app_user SET display_name = $1 WHERE id = $2", full_name, user.user_id)

        if "handicap_index" in updates:
            new_hcp = updates["handicap_index"]
            # Kun faktiske tallverdier logges (ikke nullstilling) -- en
            # "HCP fjernet"-hendelse gir ingen mening i en verdi-over-tid-
            # historikk.
            changed = new_hcp is not None and (old_hcp is None or float(old_hcp) != float(new_hcp))
            if changed:
                await conn.execute(
                    "INSERT INTO handicap_history (user_id, handicap_index) VALUES ($1, $2)",
                    user.user_id,
                    new_hcp,
                )
    return await me(user)


@router.get("/profile/handicap-history")
async def get_handicap_history(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
    async with plain_connection() as conn:
        rows = await conn.fetch(
            """
            SELECT handicap_index::float AS handicap_index, recorded_at, source
            FROM handicap_history WHERE user_id = $1
            ORDER BY recorded_at
            """,
            user.user_id,
        )
    return [
        {"handicap_index": r["handicap_index"], "recorded_at": r["recorded_at"].isoformat(), "source": r["source"]}
        for r in rows
    ]


@router.post("/profile/handicap/apply-computed", response_model=Me)
async def apply_computed_handicap(user: CurrentUser = Depends(get_current_user)) -> Me:
    """ADR-038 Beslutning B -- eksplisitt "Bruk som mitt HCP"-handling.
    Kopierer gjeldende computed_handicap_index inn i det manuelt satte
    handicap_index. INGEN automatisk synkronisering noen vei ellers --
    de to tallene lever uavhengig helt til brukeren selv trykker denne."""
    async with plain_connection() as conn:
        computed = await conn.fetchval(
            "SELECT computed_handicap_index::float FROM app_user WHERE id = $1", user.user_id
        )
        if computed is None:
            raise app_error(400, "VALIDATION_FAILED", "Ingen faktisk HCP er beregnet ennå.")
        await conn.execute("UPDATE app_user SET handicap_index = $2 WHERE id = $1", user.user_id, computed)
        await conn.execute(
            "INSERT INTO handicap_history (user_id, handicap_index, source) VALUES ($1, $2, 'manual')",
            user.user_id, computed,
        )
    return await me(user)


@router.post("/profile/avatar", response_model=Me)
async def upload_avatar(
    file: UploadFile,
    user: CurrentUser = Depends(get_current_user),
) -> Me:
    """Samme ekte multipart->AVIF-mønster som tournament.hero_image_key
    (ADR-018 MinIO-runden) -- se app/storage.py."""
    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).")

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

    async with plain_connection() as conn:
        await conn.execute("UPDATE app_user SET avatar_key = $1 WHERE id = $2", key, user.user_id)
    return await me(user)


@router.delete("/profile/avatar", response_model=Me)
async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me:
    async with plain_connection() as conn:
        await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id)
    return await me(user)


# --- Verifisert e-postbytte -------------------------------------------------
# E-post er innloggings-identifikatoren (magic-link-mål) -- kan IKKE endres
# med en enkel PATCH slik de andre profilfeltene kan, uten å risikere at en
# skrivefeil eller en kapret sesjon stjeler kontoen for godt. To steg, samme
# token-hash-og-utløp-mønster som magic_link_token (004): (1) bevis at DU
# eier kontoen i dag (krever en gyldig sesjon), (2) bevis at du eier den NYE
# adressen (klikk lenken sendt DIT) -- ingen av de to alene er nok.


class EmailChangeRequest(BaseModel):
    new_email: EmailStr


@router.post("/profile/email")
async def request_email_change(
    body: EmailChangeRequest,
    user: CurrentUser = Depends(get_current_user),
) -> dict:
    new_email = body.new_email.lower()
    async with plain_connection() as conn:
        taken = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", new_email)
        if taken is not None:
            raise app_error(409, "DUPLICATE", "Denne e-postadressen er allerede i bruk av en annen konto.")

        raw_token = secrets.token_urlsafe(32)
        expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
        await conn.execute(
            "INSERT INTO email_change_token (user_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
            user.user_id,
            new_email,
            _hash_secret(raw_token),
            expires_at,
        )

    if settings.DEV_LOG_MAGIC_LINKS:
        print(f"[DEV] E-postbytte-lenke for {new_email}: {raw_token}", flush=True)
    elif settings.SMTP_CONFIGURED:
        try:
            await send_email_change_confirmation(new_email, raw_token)
        except Exception:
            traceback.print_exc()
    return {"status": "ok"}


class EmailChangeConfirm(BaseModel):
    token: str


@router.post("/profile/email/confirm", response_model=Me)
async def confirm_email_change(body: EmailChangeConfirm) -> Me:
    token_hash = _hash_secret(body.token)
    async with plain_connection() as conn:
        # Atomisk forbruk, samme mønster som verify_magic_link.
        token_row = await conn.fetchrow(
            """
            UPDATE email_change_token
            SET consumed_at = now()
            WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
            RETURNING user_id::text AS user_id, new_email::text AS new_email
            """,
            token_hash,
        )
        if token_row is None:
            raise app_error(401, "INVALID_EMAIL_CHANGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")

        # Kan ha blitt tatt av en annen konto i tiden MELLOM forespørsel og
        # bekreftelse -- sjekket på nytt her, ikke bare ved forespørselen.
        taken = await conn.fetchval(
            "SELECT id FROM app_user WHERE email = $1 AND id <> $2",
            token_row["new_email"],
            token_row["user_id"],
        )
        if taken is not None:
            raise app_error(
                409, "DUPLICATE", "Denne e-postadressen ble tatt i bruk av en annen konto i mellomtiden."
            )

        async with translate_db_errors():
            await conn.execute(
                "UPDATE app_user SET email = $1 WHERE id = $2", token_row["new_email"], token_row["user_id"]
            )
    return await me(CurrentUser(user_id=token_row["user_id"]))


# --- Sekundær e-postadresse (kun det enkle tilfellet -- se FEATURE_BACKLOG.md
# "Én person, flere e-postadresser" for hvorfor ekte konto-sammenslåing er
# BEVISST utenfor omfang her) -------------------------------------------------
# Samme to-stegs bevis-eierskap-mønster som e-postbytte over, men ADDITIVT
# (legger til en ny rad i user_secondary_email) i stedet for å erstatte
# app_user.email. En verifisert sekundær e-post kan deretter brukes til
# innlogging (magic-link OG passord, se verify_magic_link/login_with_password)
# -- den løses til DENNE kontoen, aldri til en ny, separat konto.


class SecondaryEmailRequest(BaseModel):
    email: EmailStr


@router.post("/secondary-email")
async def request_secondary_email(
    body: SecondaryEmailRequest,
    user: CurrentUser = Depends(get_current_user),
) -> dict:
    email = body.email.lower()
    async with plain_connection() as conn:
        primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
        if primary is not None:
            raise app_error(
                409,
                "DUPLICATE",
                "Denne adressen er allerede en konto sin hovedadresse. Bruk "
                "\"Slå sammen med en annen konto\" lenger ned for å slå de to "
                "kontoene sammen i stedet.",
            )
        existing_secondary = await conn.fetchval(
            "SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
        )
        if existing_secondary is not None:
            code = "DUPLICATE" if existing_secondary != user.user_id else "ALREADY_ADDED"
            raise app_error(
                409,
                code,
                "Denne adressen er allerede lagt til"
                + ("på kontoen din." if existing_secondary == user.user_id else " av en annen konto."),
            )

        raw_token = secrets.token_urlsafe(32)
        expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
        await conn.execute(
            "INSERT INTO secondary_email_token (user_id, email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
            user.user_id,
            email,
            _hash_secret(raw_token),
            expires_at,
        )

    if settings.DEV_LOG_MAGIC_LINKS:
        print(f"[DEV] Sekundær e-post-lenke for {email}: {raw_token}", flush=True)
    elif settings.SMTP_CONFIGURED:
        try:
            await send_secondary_email_verification(email, raw_token)
        except Exception:
            traceback.print_exc()
    return {"status": "ok"}


class SecondaryEmailConfirm(BaseModel):
    token: str


@router.post("/secondary-email/confirm", response_model=Me)
async def confirm_secondary_email(body: SecondaryEmailConfirm) -> Me:
    token_hash = _hash_secret(body.token)
    async with plain_connection() as conn:
        token_row = await conn.fetchrow(
            """
            UPDATE secondary_email_token
            SET consumed_at = now()
            WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
            RETURNING user_id::text AS user_id, email::text AS email
            """,
            token_hash,
        )
        if token_row is None:
            raise app_error(401, "INVALID_SECONDARY_EMAIL_LINK", "Lenken er ugyldig, brukt eller utløpt.")

        # Re-sjekk begge unikhetsbetingelsene -- adressen kan ha blitt tatt
        # (som hovedadresse ELLER som en annen kontos sekundæradresse) i
        # tiden MELLOM forespørsel og bekreftelse.
        taken_primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", token_row["email"])
        if taken_primary is not None:
            raise app_error(409, "DUPLICATE", "Denne adressen ble tatt i bruk av en konto i mellomtiden.")
        taken_secondary = await conn.fetchval(
            "SELECT user_id FROM user_secondary_email WHERE email = $1", token_row["email"]
        )
        if taken_secondary is not None:
            raise app_error(409, "DUPLICATE", "Denne adressen ble lagt til av en annen konto i mellomtiden.")

        async with translate_db_errors():
            await conn.execute(
                "INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)",
                token_row["user_id"],
                token_row["email"],
            )
    return await me(CurrentUser(user_id=token_row["user_id"]))


@router.delete("/secondary-email/{secondary_email_id}", status_code=204)
async def delete_secondary_email(
    secondary_email_id: str,
    user: CurrentUser = Depends(get_current_user),
) -> None:
    async with plain_connection() as conn:
        row = await conn.fetchrow(
            "SELECT user_id::text AS user_id FROM user_secondary_email WHERE id = $1", secondary_email_id
        )
        if row is None:
            raise app_error(404, "NOT_FOUND", "Adressen finnes ikke.")
        if row["user_id"] != user.user_id:
            raise app_error(403, "NOT_OWNER", "Denne adressen tilhører ikke deg.")
        await conn.execute("DELETE FROM user_secondary_email WHERE id = $1", secondary_email_id)
