"""
Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- to separate
`app_user`-kontoer eid av samme reelle person slås sammen til én.

Selvbetjent (bruker bekreftet eksplisitt): `keeper_id` (initiativtakeren,
den aktive økten) beholdes, `loser_id` (kontoen bekreftet via en lenke sendt
til DENS e-post, se app/routers/account_merge.py) slås INN i keeper og
slettes til slutt.

RLS-grensen avgjør strukturen (verifisert direkte mot skjemaet, ikke
antatt): `organization_membership`/`app_user`/`friendship`/`notification`/
`round`/`round_participant`/`round_message*`/`handicap_history`/
`push_subscription`/`user_secondary_email` m.fl. har INGEN RLS (håndteres
av app-laget, "auth-laget", se 001_initial_schema.sql linje 364-366) --
disse slås sammen i ÉN global `plain_connection()`-transaksjon.
`player`/`team_roster`/`tournament_registration`/`tournament_participant`/
`order_of_merit_team_member`/`message`/`message_reaction`/`message_comment`/
`tournament_class`/`tournament`/`lineup_lock`/`organization_invitation`/
`tournament_round_participant_flag_plant` ER RLS-beskyttet
(`org_isolation`-policy, se ENABLE ROW LEVEL SECURITY-arrayene i 001/003/
012/013/040/055/059/070) -- disse MÅ behandles i en EGEN
`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
atomisk vei på tvers (samme N+1-per-org-mønster som allerede er etablert
praksis i `/auth/me`/"Mine runder", se kommentar i app/routers/auth.py sin
`me()`-funksjon). **Denne grensen ble faktisk feil-klassifisert første
gang** (seks tabeller endte opp i _merge_global sin plain_connection()-
løkke) -- symptomet var IKKE en stille RLS-nullstilling som forventet, men
et forsinket "invalid uuid ''"-krasj på en GJENBRUKT tilkobling (Postgres
sin current_setting('app.current_org', true) kan returnere tom streng,
ikke NULL, på en tilkobling der GUC-en tidligere har vært satt i samme
fysiske forbindelse -- se ADR-080). Rettet ved faktisk å verifisere HVER
tabell mot ENABLE ROW LEVEL SECURITY-arrayene i stedet for å anta ut fra
tabellnavn/-mønster.

Ingen sesjons-tilbakekallingsmekanisme finnes andre steder i appen --
sletting av `app_user`-raden for taperen er derfor det ENESTE som kan tvinge
ut en gjenlevende øktinformasjonskapsel, og skjer derfor alltid SIST, kun
hvis alt foregående lyktes.
"""

from __future__ import annotations

from pydantic import BaseModel

from .db import org_connection, plain_connection

_ROLE_RANK = {"owner": 3, "admin": 2, "member": 1}


# ---------------------------------------------------------------------------
# Forhåndsvisning (les-only) -- gjenbrukt BÅDE ved rå forespørsel (før
# e-post sendes) og på nytt når bekreftelseslenken åpnes (fersk, i tilfelle
# noe endret seg i mellomtiden). Pydantic (ikke dataclass) siden disse
# returneres direkte som response_model, samme mønster som
# app/hole_history.py sin HoleHistoryOut.
# ---------------------------------------------------------------------------


class OrgMembershipPreview(BaseModel):
    organization_id: str
    organization_name: str
    keeper_role: str | None
    loser_role: str | None
    resulting_role: str


class OrgPlayerPreview(BaseModel):
    organization_id: str
    organization_name: str
    keeper_has_player: bool
    loser_has_player: bool
    # Antall team_roster/tournament_registration/tournament_participant/
    # order_of_merit_team_member-rader som ville kollidert (begge kontoer
    # allerede i samme lag/turnering/OOM-lag) -- kun >0 når BEGGE har en
    # player-rad i denne org-en.
    colliding_registrations: int


class MergePreview(BaseModel):
    keeper_id: str
    keeper_display_name: str
    keeper_email: str | None
    loser_id: str
    loser_display_name: str
    loser_email: str | None
    organization_memberships: list[OrgMembershipPreview] = []
    player_links: list[OrgPlayerPreview] = []
    friend_count: int = 0
    shared_friend_count: int = 0
    were_friends_with_each_other: bool = False
    round_count: int = 0
    secondary_email_count: int = 0


async def _affected_org_ids(keeper_id: str, loser_id: str) -> list[str]:
    """Union av org-er der EN AV kontoene har medlemskap ELLER en
    spiller-kobling (en bruker kan være spiller i en org uavhengig av
    medlemskap, se ADR-031). `player_organizations_for_user` er en
    SECURITY DEFINER-bro (migrasjon 015) -- fungerer korrekt selv uten
    app.current_org satt, samme som i /auth/me."""
    org_ids: set[str] = set()
    async with plain_connection() as conn:
        rows = await conn.fetch(
            "SELECT organization_id::text AS organization_id FROM organization_membership WHERE user_id = ANY($1::uuid[])",
            [keeper_id, loser_id],
        )
        org_ids.update(r["organization_id"] for r in rows)
        for uid in (keeper_id, loser_id):
            rows = await conn.fetch(
                "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", uid
            )
            org_ids.update(r["organization_id"] for r in rows)
    return sorted(org_ids)


async def compute_merge_preview(keeper_id: str, loser_id: str) -> MergePreview:
    async with plain_connection() as conn:
        keeper_row = await conn.fetchrow(
            "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", keeper_id
        )
        loser_row = await conn.fetchrow(
            "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", loser_id
        )
        preview = MergePreview(
            keeper_id=keeper_id,
            keeper_display_name=keeper_row["display_name"],
            keeper_email=keeper_row["email"],
            loser_id=loser_id,
            loser_display_name=loser_row["display_name"],
            loser_email=loser_row["email"],
        )

        membership_rows = await conn.fetch(
            "SELECT organization_id::text AS organization_id, user_id::text AS user_id, role "
            "FROM organization_membership WHERE user_id = ANY($1::uuid[])",
            [keeper_id, loser_id],
        )
        friend_count = await conn.fetchval(
            "SELECT count(*) FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)",
            loser_id,
        )
        preview.friend_count = friend_count

        keeper_friend_ids = {
            r["other"]
            for r in await conn.fetch(
                """
                SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
                FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
                """,
                keeper_id,
            )
        }
        loser_friend_ids = {
            r["other"]
            for r in await conn.fetch(
                """
                SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
                FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
                """,
                loser_id,
            )
        }
        preview.shared_friend_count = len(keeper_friend_ids & loser_friend_ids)
        preview.were_friends_with_each_other = loser_id in keeper_friend_ids

        preview.round_count = await conn.fetchval(
            "SELECT count(*) FROM round_participant WHERE user_id = $1", loser_id
        )
        preview.secondary_email_count = await conn.fetchval(
            "SELECT count(*) FROM user_secondary_email WHERE user_id = $1", loser_id
        )

    membership_by_org: dict[str, dict[str, str]] = {}
    for row in membership_rows:
        membership_by_org.setdefault(row["organization_id"], {})[row["user_id"]] = row["role"]

    org_ids = await _affected_org_ids(keeper_id, loser_id)
    for org_id in org_ids:
        async with org_connection(org_id) as conn:
            org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)

            roles = membership_by_org.get(org_id, {})
            keeper_role = roles.get(keeper_id)
            loser_role = roles.get(loser_id)
            if keeper_role or loser_role:
                resulting = keeper_role or loser_role
                if keeper_role and loser_role:
                    resulting = keeper_role if _ROLE_RANK[keeper_role] >= _ROLE_RANK[loser_role] else loser_role
                preview.organization_memberships.append(
                    OrgMembershipPreview(
                        organization_id=org_id,
                        organization_name=org_name,
                        keeper_role=keeper_role,
                        loser_role=loser_role,
                        resulting_role=resulting,
                    )
                )

            keeper_player_id = await conn.fetchval(
                "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
            )
            loser_player_id = await conn.fetchval(
                "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
            )
            if keeper_player_id or loser_player_id:
                colliding = 0
                if keeper_player_id and loser_player_id:
                    colliding += await conn.fetchval(
                        """
                        SELECT count(*) FROM team_roster a
                        WHERE a.player_id = $1 AND EXISTS (
                            SELECT 1 FROM team_roster b WHERE b.team_id = a.team_id AND b.player_id = $2
                        )
                        """,
                        loser_player_id,
                        keeper_player_id,
                    )
                    colliding += await conn.fetchval(
                        """
                        SELECT count(*) FROM tournament_registration a
                        WHERE a.player_id = $1 AND EXISTS (
                            SELECT 1 FROM tournament_registration b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
                        )
                        """,
                        loser_player_id,
                        keeper_player_id,
                    )
                    colliding += await conn.fetchval(
                        """
                        SELECT count(*) FROM tournament_participant a
                        WHERE a.player_id = $1 AND EXISTS (
                            SELECT 1 FROM tournament_participant b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
                        )
                        """,
                        loser_player_id,
                        keeper_player_id,
                    )
                    colliding += await conn.fetchval(
                        """
                        SELECT count(*) FROM order_of_merit_team_member a
                        WHERE a.player_id = $1 AND EXISTS (
                            SELECT 1 FROM order_of_merit_team_member b
                            WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2
                        )
                        """,
                        loser_player_id,
                        keeper_player_id,
                    )
                preview.player_links.append(
                    OrgPlayerPreview(
                        organization_id=org_id,
                        organization_name=org_name,
                        keeper_has_player=keeper_player_id is not None,
                        loser_has_player=loser_player_id is not None,
                        colliding_registrations=colliding,
                    )
                )

    return preview


# ---------------------------------------------------------------------------
# Utførelse -- se modul-docstring for transaksjons-strukturen.
# ---------------------------------------------------------------------------


class MergeResult(BaseModel):
    keeper_id: str
    merged_organization_count: int
    merged_player_count: int


async def _merge_org_scoped(conn, org_id: str, keeper_id: str, loser_id: str) -> None:
    """Kjøres inni ÉN org_connection(org_id)-transaksjon -- player-
    sammenslåing + dedup av de fire tabellene som har egne UNIQUE(x,
    player_id)-constraints. Idempotent: sjekker at taperens player-rad
    faktisk fortsatt finnes før den gjør noe."""
    keeper_player_id = await conn.fetchval(
        "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
    )
    loser_player_id = await conn.fetchval(
        "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
    )
    if loser_player_id is None:
        return

    if keeper_player_id is None:
        # Ingen kollisjon mulig -- enkel re-peking.
        await conn.execute("UPDATE player SET user_id = $1 WHERE id = $2", keeper_id, loser_player_id)
        return

    # Begge har en player-rad i denne org-en: re-pek taperens avhengigheter
    # til keeperens player-rad, men KUN der det ikke ville brutt en
    # UNIQUE(x, player_id) -- i så fall er taperens rad en ren duplikat og
    # slettes i stedet (personen var allerede med via keeperens rad).
    for table, group_col in (
        ("team_roster", "team_id"),
        ("tournament_registration", "tournament_id"),
        ("tournament_participant", "tournament_id"),
    ):
        await conn.execute(
            f"DELETE FROM {table} a WHERE a.player_id = $1 AND EXISTS "
            f"(SELECT 1 FROM {table} b WHERE b.{group_col} = a.{group_col} AND b.player_id = $2)",
            loser_player_id,
            keeper_player_id,
        )
        await conn.execute(f"UPDATE {table} SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id)

    await conn.execute(
        "DELETE FROM order_of_merit_team_member a WHERE a.player_id = $1 AND EXISTS "
        "(SELECT 1 FROM order_of_merit_team_member b WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2)",
        loser_player_id,
        keeper_player_id,
    )
    await conn.execute(
        "UPDATE order_of_merit_team_member SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id
    )

    # Taperens nå-tomme player-rad slettes til slutt (ingenting refererer
    # den lenger).
    await conn.execute("DELETE FROM player WHERE id = $1", loser_player_id)


async def _repoint_rls_scoped_safe_tables(conn, keeper_id: str, loser_id: str) -> None:
    """Kjøres inni SAMME org_connection(org_id)-transaksjon som
    _merge_org_scoped, for den ene org-en av gangen. Disse henger av
    app_user DIREKTE (ikke via player), men ER likevel RLS-beskyttet
    (org_isolation, se 001/003/012/013-migrasjonenes ENABLE ROW LEVEL
    SECURITY-arrayer) -- MÅ derfor IKKE stå i _merge_global sin
    plain_connection()-løkke (en tidligere feil gjorde nettopp det, se
    ADR-080). RLS filtrerer automatisk til akkurat DENNE org-en, så ingen
    egen WHERE organization_id-betingelse trengs her."""
    for table, column in (
        ("tournament", "created_by"),
        ("lineup_lock", "locked_by"),
        ("organization_invitation", "invited_by"),
        ("message", "author_user_id"),
        ("message_comment", "author_user_id"),
        ("tournament_round_participant_flag_plant", "planted_by_user_id"),
    ):
        await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)

    # message_reaction: egen UNIQUE(message_id, user_id) -- samme
    # behold-keeperens-ellers-overfør-taperens-regel som round_message_
    # reaction (som IKKE er RLS-beskyttet og derfor håndteres i _merge_global).
    await conn.execute(
        "DELETE FROM message_reaction a WHERE a.user_id = $1 AND EXISTS "
        "(SELECT 1 FROM message_reaction b WHERE b.message_id = a.message_id AND b.user_id = $2)",
        loser_id,
        keeper_id,
    )
    await conn.execute("UPDATE message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)


async def _merge_global(conn, keeper_id: str, loser_id: str) -> None:
    """Kjøres inni ÉN plain_connection()-transaksjon -- alt som IKKE er
    RLS-beskyttet. Idempotent: sjekker taperens app_user-rad fortsatt
    finnes før den gjør noe (i praksis alltid sann her, ekstra forsvar mot
    dobbel-kjøring)."""
    still_exists = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id)
    if not still_exists:
        return

    # organization_membership: behold høyest rolle, aldri nedgrader keeper.
    loser_memberships = await conn.fetch(
        "SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
        loser_id,
    )
    for row in loser_memberships:
        keeper_role = await conn.fetchval(
            "SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
            row["organization_id"],
            keeper_id,
        )
        if keeper_role is None:
            await conn.execute(
                "UPDATE organization_membership SET user_id = $1 WHERE organization_id = $2 AND user_id = $3",
                keeper_id,
                row["organization_id"],
                loser_id,
            )
        elif _ROLE_RANK[row["role"]] > _ROLE_RANK[keeper_role]:
            await conn.execute(
                "UPDATE organization_membership SET role = $1 WHERE organization_id = $2 AND user_id = $3",
                row["role"],
                row["organization_id"],
                keeper_id,
            )
            await conn.execute(
                "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
                row["organization_id"],
                loser_id,
            )
        else:
            await conn.execute(
                "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
                row["organization_id"],
                loser_id,
            )

    # friendship: selv-lenke (var venner med hverandre) slettes -- ville
    # brutt CHECK(requester <> addressee) hvis re-pekt. Delt tredjepart:
    # behold 'accepted' fremfor 'pending', dropp taperens duplikat.
    await conn.execute(
        "DELETE FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) "
        "OR (requester_user_id = $2 AND addressee_user_id = $1)",
        keeper_id,
        loser_id,
    )
    loser_friendships = await conn.fetch(
        "SELECT id::text AS id, requester_user_id::text AS requester_user_id, "
        "addressee_user_id::text AS addressee_user_id, status "
        "FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1",
        loser_id,
    )
    for row in loser_friendships:
        other_id = row["addressee_user_id"] if row["requester_user_id"] == loser_id else row["requester_user_id"]
        existing = await conn.fetchrow(
            "SELECT id::text AS id, status FROM friendship WHERE "
            "(requester_user_id = $1 AND addressee_user_id = $2) OR (requester_user_id = $2 AND addressee_user_id = $1)",
            keeper_id,
            other_id,
        )
        if existing is None:
            new_requester = keeper_id if row["requester_user_id"] == loser_id else other_id
            new_addressee = other_id if row["requester_user_id"] == loser_id else keeper_id
            await conn.execute(
                "UPDATE friendship SET requester_user_id = $1, addressee_user_id = $2 WHERE id = $3",
                new_requester,
                new_addressee,
                row["id"],
            )
        elif existing["status"] != "accepted" and row["status"] == "accepted":
            await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", existing["id"])
            await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])
        else:
            await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])

    # friend_categorization: samme selvlenke-fjerning, deretter slå sammen
    # kategori-sett for delte venner (union, ikke overskriving).
    await conn.execute(
        "DELETE FROM friend_categorization WHERE (owner_user_id = $1 AND friend_user_id = $2) "
        "OR (owner_user_id = $2 AND friend_user_id = $1)",
        keeper_id,
        loser_id,
    )
    await conn.execute(
        """
        INSERT INTO friend_categorization (owner_user_id, friend_user_id, category)
        SELECT $1, friend_user_id, category FROM friend_categorization WHERE owner_user_id = $2
        ON CONFLICT DO NOTHING
        """,
        keeper_id,
        loser_id,
    )
    await conn.execute("DELETE FROM friend_categorization WHERE owner_user_id = $1", loser_id)
    await conn.execute(
        """
        UPDATE friend_categorization SET friend_user_id = $1
        WHERE friend_user_id = $2 AND NOT EXISTS (
            SELECT 1 FROM friend_categorization x
            WHERE x.owner_user_id = friend_categorization.owner_user_id AND x.friend_user_id = $1 AND x.category = friend_categorization.category
        )
        """,
        keeper_id,
        loser_id,
    )
    await conn.execute("DELETE FROM friend_categorization WHERE friend_user_id = $1", loser_id)

    # user_notification_email_pref: behold keeperens, dropp taperens duplikat.
    await conn.execute(
        """
        INSERT INTO user_notification_email_pref (user_id, type)
        SELECT $1, type FROM user_notification_email_pref WHERE user_id = $2
        ON CONFLICT DO NOTHING
        """,
        keeper_id,
        loser_id,
    )

    # round_message_reaction: behold keeperens reaksjon om den finnes,
    # ellers overfør taperens. (message_reaction, org-lagchat-varianten, ER
    # RLS-beskyttet -- håndteres i _repoint_rls_scoped_safe_tables i stedet.)
    await conn.execute(
        "DELETE FROM round_message_reaction a WHERE a.user_id = $1 AND EXISTS "
        "(SELECT 1 FROM round_message_reaction b WHERE b.round_message_id = a.round_message_id AND b.user_id = $2)",
        loser_id,
        keeper_id,
    )
    await conn.execute("UPDATE round_message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)

    # user_secondary_email: taperens PRIMÆRE e-post blir en ny sekundær-
    # e-post på keeper (global unikhet garanterer ingen kollisjon mulig --
    # den kan uansett bare tilhøre nøyaktig én av de to kontoene fra før).
    loser_email = await conn.fetchval("SELECT email::text AS email FROM app_user WHERE id = $1", loser_id)
    if loser_email is not None:
        await conn.execute(
            "INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING",
            keeper_id,
            loser_email,
        )
    await conn.execute("UPDATE user_secondary_email SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)

    # Alt annet med ingen konkurrerende unikhet -- trygt å re-peke direkte.
    # KUN tabeller UTEN RLS her -- se _RLS_SCOPED_SAFE_TABLES for de som ser
    # trygge ut på samme vis men faktisk ER org_isolation-beskyttet (player/
    # course/tournament/message m.fl., se 001/003/012/013-arrayene) og derfor
    # MÅ behandles i _merge_org_scoped (org_connection per org) i stedet --
    # en tidligere feil her (alle seks re-pekt via plain_connection) ga et
    # snodig, forsinket symptom (tom streng vs. NULL på en gjenbrukt
    # tilkoblings app.current_org etter en avsluttet org_connection()-
    # transaksjon) i stedet for en ren RLS-stillhet, se ADR-080 for
    # detaljene -- IKKE anta at en tabell er RLS-fri uten å sjekke arrayene.
    for table, column in (
        ("handicap_history", "user_id"),
        ("notification", "user_id"),
        ("push_subscription", "user_id"),
        ("personal_course", "created_by_user_id"),
        ("round", "owner_user_id"),
        ("round_participant", "user_id"),
        ("round_message", "author_user_id"),
        ("round_message_comment", "author_user_id"),
        ("round_shot", "recorded_by_user_id"),
        ("round_message_tag", "tagged_user_id"),
        ("round_message_comment_tag", "tagged_user_id"),
        ("round_participant_flag_plant", "planted_by_user_id"),
    ):
        await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)

    # Ephemere/tokens knyttet til taperen -- ingen verdi å overføre,
    # forsvinner uansett via CASCADE ved sletting under, men ryddes
    # eksplisitt her for et rent svar fra denne funksjonen alene.
    await conn.execute("DELETE FROM two_factor_code WHERE user_id = $1", loser_id)
    await conn.execute("DELETE FROM email_change_token WHERE user_id = $1", loser_id)
    await conn.execute("DELETE FROM secondary_email_token WHERE user_id = $1", loser_id)


async def execute_merge(keeper_id: str, loser_id: str) -> MergeResult:
    org_ids = await _affected_org_ids(keeper_id, loser_id)

    async with plain_connection() as conn:
        async with conn.transaction():
            await _merge_global(conn, keeper_id, loser_id)

    merged_player_count = 0
    for org_id in org_ids:
        async with org_connection(org_id) as conn:
            had_loser_player = await conn.fetchval(
                "SELECT EXISTS(SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)", org_id, loser_id
            )
            await _merge_org_scoped(conn, org_id, keeper_id, loser_id)
            await _repoint_rls_scoped_safe_tables(conn, keeper_id, loser_id)
            if had_loser_player:
                merged_player_count += 1

    # Siste, irreversible steg -- kun etter at alt over har lyktes.
    # CASCADE dekker det gjenværende (account_merge_token selv, samt
    # ethvert FK utenfor listen over som ikke er berørt her).
    async with plain_connection() as conn:
        await conn.execute("DELETE FROM app_user WHERE id = $1", loser_id)

    return MergeResult(
        keeper_id=keeper_id,
        merged_organization_count=len(org_ids),
        merged_player_count=merged_player_count,
    )
