import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { constants, publicEncrypt } from 'node:crypto';

import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
import { InMemoryOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
import type { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
import { createGatewayApiContext } from '../src/context.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
import type { GatewayPrismaClient } from '@sammo-ts/infra';
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import type { GatewayProfileRepository } from '../src/orchestrator/profileRepository.js';

const buildCaller = (
    options: {
        userIconDir?: string;
        localAccountGraceDays?: number;
        flushError?: Error;
        profileListError?: Error;
        kakaoId?: string;
        kakaoEmail?: string;
        kakaoSignupAlreadyRegistered?: boolean;
        allowKakaoRefresh?: boolean;
    } = {}
) => {
    const users = createInMemoryUserRepository();
    const sessions = new InMemoryGatewaySessionService({
        sessionTtlSeconds: 3600,
        gameSessionTtlSeconds: 600,
    });
    const flushPublisher = {
        publishUserFlush: vi.fn(async () => {
            if (options.flushError) {
                throw options.flushError;
            }
        }),
    };
    const oauthSessions = new InMemoryOAuthSessionStore();
    const kakaoProfile = {
        id: options.kakaoId ?? '1',
        email: options.kakaoEmail ?? 'tester@example.com',
    };
    const sentTalkMessages: string[] = [];
    const refreshTokenCalls: string[] = [];
    const kakaoClient = {
        restKey: '',
        redirectUri: '',
        oauthHost: '',
        apiHost: '',
        buildAuthUrl: (state: string, scopes: string[]) =>
            `https://kauth.example.test/authorize?state=${state}&scope=${scopes.join(',')}`,
        exchangeCode: async () => ({
            accessToken: 'access-token',
            accessTokenExpiresIn: 3600,
            refreshToken: 'refresh-token',
            refreshTokenExpiresIn: 86400,
        }),
        refreshToken: async (refreshToken: string) => {
            refreshTokenCalls.push(refreshToken);
            if (!options.allowKakaoRefresh) {
                throw new Error('not used');
            }
            return {
                accessToken: 'refreshed-access-token',
                accessTokenExpiresIn: 3600,
            };
        },
        signup: async () =>
            options.kakaoSignupAlreadyRegistered
                ? { alreadyRegistered: true }
                : { id: kakaoProfile.id, alreadyRegistered: false },
        getMe: async () => ({
            id: kakaoProfile.id,
            kakaoAccount: {
                hasEmail: true,
                email: kakaoProfile.email,
                isEmailValid: true,
                isEmailVerified: true,
            },
        }),
        sendTalkMessage: async (_accessToken: string, message: string) => {
            sentTalkMessages.push(message);
        },
    };
    const profileRows = [
        {
            profileName: 'che:default',
            profile: 'che',
            instanceKey: 'default',
            currentScenario: null,
            scenario: 'default',
            apiPort: 15003,
            status: 'RUNNING' as const,
            buildStatus: 'SUCCEEDED' as const,
            buildCommitSha: 'HEAD',
            meta: {},
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
        },
        {
            profileName: 'hwe:default',
            profile: 'hwe',
            instanceKey: 'default',
            currentScenario: null,
            scenario: 'default',
            apiPort: 15015,
            status: 'RUNNING' as const,
            buildStatus: 'SUCCEEDED' as const,
            buildCommitSha: 'HEAD',
            meta: {},
            createdAt: new Date().toISOString(),
            updatedAt: new Date().toISOString(),
        },
    ];
    const profiles: GatewayProfileRepository = {
        listProfiles: async () => profileRows,
        getProfile: async (profileName: string) =>
            profileRows.find((profile) => profile.profileName === profileName) ?? null,
        upsertProfile: async () => {
            throw new Error('not used');
        },
        updateCurrentScenario: async () => null,
        updateStatus: async () => null,
        updateBuildStatus: async () => null,
        updateMeta: async () => null,
        listReservedToStart: async () => [],
        findQueuedBuild: async () => null,
        updateLastError: async () => {},
        updateWorkspaceUsage: async () => {},
        clearWorkspaceUsage: async () => {},
        listOperations: async () => [],
        getOperation: async () => null,
        listOperationLogs: async () => [],
        appendOperationLog: async (operationId, input) => ({
            cursor: '1',
            operationId,
            createdAt: '2026-08-11T00:00:00.000Z',
            ...input,
        }),
        createOperation: async () => {
            throw new Error('not implemented');
        },
        claimNextOperation: async () => null,
        completeOperation: async () => {
            throw new Error('not implemented');
        },
        requeueOperation: async () => {
            throw new Error('not implemented');
        },
        cancelOperation: async () => false,
        retryOperation: async () => null,
    };
    const orchestrator = {
        start: () => {},
        stop: async () => {},
        reconcileNow: async () => {},
        runScheduleNow: async () => {},
        runBuildQueueNow: async () => {},
        runOperationsNow: async () => {},
        cleanupStaleWorkspaces: async () => ({
            removed: [],
            skipped: [],
        }),
        listRuntimeStates: async () => [],
    };
    const profileStatus = new InMemoryProfileStatusService(
        profileRows.map((profile) => ({
            profileName: profile.profileName,
            profile: profile.profile,
            instanceKey: profile.instanceKey,
            currentScenario: profile.currentScenario,
            scenario: profile.scenario,
            status: profile.status,
            lifecycle: {
                runtimeExpected: true,
                userAccessible: true,
                turnsRunning: true,
                operatorResumable: false,
                dataInitialized: profile.currentScenario !== null,
            },
            apiPort: profile.apiPort,
            runtime: {
                apiRunning: true,
                daemonRunning: true,
                auctionRunning: false,
                battleSimRunning: false,
                tournamentRunning: false,
            },
            korName: profile.profile,
            color: '#fff',
        }))
    );
    if (options.profileListError) {
        profileStatus.listLobbyProfiles = async () => {
            throw options.profileListError;
        };
    }
    const passwordEnvelope = createPasswordEnvelopeService();
    const requestHeaders: Record<string, string> = {};
    const userIconUpload = {
        upload: vi.fn(async ({ filename }: { filename: string }) => ({
            picture: `users/core2026/${filename}`,
            publicUrl: `https://sam-image.hided.net/icons/users/core2026/${filename}`,
        })),
    };
    const sealPassword = (password: string) => {
        const key = passwordEnvelope.getPublicKey();
        return {
            keyId: key.keyId,
            ciphertext: publicEncrypt(
                {
                    key: key.publicKeyPem,
                    padding: constants.RSA_PKCS1_OAEP_PADDING,
                    oaepHash: 'sha256',
                },
                Buffer.from(password, 'utf8')
            ).toString('base64'),
        };
    };
    const caller = appRouter.createCaller(
        createGatewayApiContext({
            users,
            sessions,
            flushPublisher,
            gameTokenSecret: 'test-secret',
            gameSessionTtlSeconds: 600,
            kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
            oauthSessions,
            publicBaseUrl: 'http://localhost',
            userIconDir: options.userIconDir,
            userIconPublicUrl: 'http://localhost/user-icons',
            sharedIconPublicUrl: 'https://sam-image.hided.net/icons',
            userIconUpload,
            adminLocalAccountEnabled: false,
            localRegistrationEnabled: true,
            localAccountGraceDays: options.localAccountGraceDays ?? 7,
            passwordEnvelope,
            profiles,
            orchestrator,
            profileStatus,
            requestHeaders,
            prisma: {
                appUser: {
                    findFirst: async () => null,
                },
            } as unknown as GatewayPrismaClient,
        })
    );
    return {
        caller,
        oauthSessions,
        users,
        sessions,
        flushPublisher,
        userIconUpload,
        kakaoProfile,
        sentTalkMessages,
        refreshTokenCalls,
        sealPassword,
        setSessionHeader: (sessionToken: string) => {
            requestHeaders['x-session-token'] = sessionToken;
        },
    };
};

describe('gateway auth flow', () => {
    it('allows a signed-in regular user to read only the active profile scenario catalog', async () => {
        const { caller, sealPassword, setSessionHeader } = buildCaller();

        await expect(caller.lobby.scenarios({ profileName: 'che:default' })).rejects.toMatchObject({
            code: 'UNAUTHORIZED',
        });

        const register = await caller.auth.registerLocal({
            username: 'scenario-reader',
            credential: sealPassword('scenario-reader-password'),
            displayName: '시나리오조회자',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        setSessionHeader(register.sessionToken);

        const scenarios = await caller.lobby.scenarios({ profileName: 'che:default' });
        expect(scenarios.length).toBeGreaterThan(0);
        expect(scenarios[0]).toMatchObject({
            id: expect.any(Number),
            title: expect.any(String),
            defaultStatTotal: expect.any(Number),
        });
        await expect(caller.lobby.scenarios({ profileName: 'hidden:default' })).rejects.toMatchObject({
            code: 'NOT_FOUND',
        });
    });

    it('registers a local account first and accepts an encrypted password login', async () => {
        const { caller, users, sealPassword } = buildCaller();
        const register = await caller.auth.registerLocal({
            username: 'LOCAL-User',
            credential: sealPassword('비밀번호-password'),
            displayName: '로컬유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });

        expect(register.user).toMatchObject({
            username: 'local-user',
            displayName: '로컬유저',
            kakaoVerified: false,
        });
        const stored = await users.findByUsername('local-user');
        expect(stored?.passwordHash.startsWith('$argon2id$')).toBe(true);
        expect(stored?.thirdPartyUse).toBe(false);
        expect(stored?.termsAcceptedAt).toBeTruthy();
        expect(stored?.privacyAcceptedAt).toBeTruthy();

        const login = await caller.auth.login({
            username: 'LOCAL-USER',
            credential: sealPassword('비밀번호-password'),
        });
        expect(login.status).toBe('login');
        if (login.status !== 'login') {
            throw new Error('Expected completed login.');
        }
        expect(login.user.username).toBe('local-user');
    });

    it('blocks password login while a ban is active and allows it after expiry', async () => {
        const { caller, users, sealPassword } = buildCaller();
        await caller.auth.registerLocal({
            username: 'banned-user',
            credential: sealPassword('banned-password'),
            displayName: '차단유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('banned-user');
        expect(user).not.toBeNull();
        await users.updateSanctions(user!.id, {
            bannedUntil: '2099-01-01T00:00:00.000Z',
        });

        await expect(
            caller.auth.login({
                username: 'banned-user',
                credential: sealPassword('banned-password'),
            })
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });

        await users.updateSanctions(user!.id, {
            bannedUntil: '2000-01-01T00:00:00.000Z',
        });
        await expect(
            caller.auth.login({
                username: 'banned-user',
                credential: sealPassword('banned-password'),
            })
        ).resolves.toMatchObject({ user: { username: 'banned-user' } });
    });

    const gameSessionRestrictionCases: Array<{ label: string; sanctions: UserSanctions }> = [
        {
            label: 'global suspension',
            sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
        },
        {
            label: 'profile login restriction',
            sanctions: {
                serverRestrictions: {
                    'che:default': {
                        blockedFeatures: ['login'],
                    },
                },
            },
        },
        {
            label: 'base profile gameplay restriction',
            sanctions: {
                serverRestrictions: {
                    che: {
                        blockedFeatures: ['gameplay'],
                        until: '2099-01-01T00:00:00.000Z',
                    },
                },
            },
        },
    ];

    it.each(gameSessionRestrictionCases)('blocks game-session issuance for $label', async ({ sanctions }) => {
        const { caller, users, sealPassword } = buildCaller();
        const register = await caller.auth.registerLocal({
            username: `restricted-${Object.keys(sanctions)[0]}`,
            credential: sealPassword('restricted-password'),
            displayName: '제한유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername(register.user.username);
        expect(user).not.toBeNull();
        await users.updateSanctions(user!.id, sanctions);

        await expect(
            caller.auth.issueGameSession({
                sessionToken: register.sessionToken,
                profile: 'che:default',
            })
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });

    it('blocks pre-verification general creation on che but grants the hwe grace period', async () => {
        const { caller, sealPassword, setSessionHeader } = buildCaller();
        const register = await caller.auth.registerLocal({
            username: 'policy-user',
            credential: sealPassword('policy-password'),
            displayName: '정책유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });

        const che = await caller.auth.issueGameSession({
            sessionToken: register.sessionToken,
            profile: 'che:default',
        });
        const hwe = await caller.auth.issueGameSession({
            sessionToken: register.sessionToken,
            profile: 'hwe:default',
        });
        const chePayload = decryptGameSessionToken(che.gameToken, 'test-secret');
        const hwePayload = decryptGameSessionToken(hwe.gameToken, 'test-secret');

        expect(chePayload?.identity).toMatchObject({
            kakaoVerified: false,
            canCreateGeneral: false,
            requiresKakaoVerification: true,
        });
        expect(hwePayload?.identity).toMatchObject({
            kakaoVerified: false,
            canCreateGeneral: true,
            requiresKakaoVerification: true,
        });

        setSessionHeader(register.sessionToken);
        const profileList = await caller.lobby.profiles();
        expect(profileList.find((profile) => profile.profile === 'che')?.localAccountPolicy?.canCreateGeneral).toBe(
            false
        );
        expect(profileList.find((profile) => profile.profile === 'hwe')?.localAccountPolicy?.canCreateGeneral).toBe(
            true
        );
    });

    it('rejects continued game access after the local account grace period', async () => {
        const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 7 });
        const register = await caller.auth.registerLocal({
            username: 'expired-user',
            credential: sealPassword('expired-password'),
            displayName: '만료유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('expired-user');
        expect(user).not.toBeNull();
        if (user) {
            user.kakaoGraceStartedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
        }

        await expect(
            caller.auth.issueGameSession({
                sessionToken: register.sessionToken,
                profile: 'hwe:default',
            })
        ).rejects.toMatchObject({
            code: 'FORBIDDEN',
            message: expect.stringContaining('유예기간'),
        });
    });

    it('issues a CHE game token to an expired tester with an active special access grant', async () => {
        const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
        const register = await caller.auth.registerLocal({
            username: 'special-tester',
            credential: sealPassword('tester-password'),
            displayName: '특수테스터',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('special-tester');
        expect(user).not.toBeNull();
        if (!user) throw new Error('Expected local tester.');
        await users.createSpecialAccessGrant(user.id, {
            kind: 'TESTER',
            profiles: ['che'],
            allowsGeneralCreation: true,
            expiresAt: null,
            reason: 'CHE 회귀 검증',
            grantedByUserId: 'admin-id',
        });

        const issued = await caller.auth.issueGameSession({
            sessionToken: register.sessionToken,
            profile: 'che:default',
        });
        const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');

        expect(payload?.identity).toMatchObject({
            kakaoVerified: false,
            canCreateGeneral: true,
            requiresKakaoVerification: false,
            specialAccess: { kind: 'TESTER', expiresAt: null },
        });
    });

    it('lets a Kakao-linked recovery account log in with its password while the grant is active', async () => {
        const { caller, users, sealPassword } = buildCaller();
        await caller.auth.registerLocal({
            username: 'lost-phone-user',
            credential: sealPassword('recovery-password'),
            displayName: '분실복구유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('lost-phone-user');
        expect(user).not.toBeNull();
        if (!user) throw new Error('Expected recovery user.');
        user.oauthType = 'KAKAO';
        user.oauthId = 'lost-phone-kakao-id';
        user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
        await users.createSpecialAccessGrant(user.id, {
            kind: 'RECOVERY',
            profiles: ['che'],
            allowsGeneralCreation: true,
            expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
            reason: '휴대폰 분실 본인 확인 완료',
            grantedByUserId: 'admin-id',
        });

        await expect(
            caller.auth.login({
                username: 'lost-phone-user',
                credential: sealPassword('recovery-password'),
            })
        ).resolves.toMatchObject({ status: 'login', user: { username: 'lost-phone-user' } });
    });

    it('lets a Kakao-linked operator log in with its password without a grant', async () => {
        const { caller, users, sealPassword } = buildCaller();
        await caller.auth.registerLocal({
            username: 'oauth-free-operator',
            credential: sealPassword('operator-password'),
            displayName: '복구운영자',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('oauth-free-operator');
        expect(user).not.toBeNull();
        if (!user) throw new Error('Expected operator user.');
        user.oauthType = 'KAKAO';
        user.oauthId = 'operator-kakao-id';
        user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
        await users.updateRoles(user.id, ['user', 'admin.users.manage']);

        await expect(
            caller.auth.login({
                username: 'oauth-free-operator',
                credential: sealPassword('operator-password'),
            })
        ).resolves.toMatchObject({ status: 'login', user: { username: 'oauth-free-operator' } });
    });

    it('keeps an active server sanction authoritative over special access', async () => {
        const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
        const register = await caller.auth.registerLocal({
            username: 'sanctioned-special-tester',
            credential: sealPassword('tester-password'),
            displayName: '제재특수테스터',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        const user = await users.findByUsername('sanctioned-special-tester');
        expect(user).not.toBeNull();
        if (!user) throw new Error('Expected sanctioned local tester.');
        await users.createSpecialAccessGrant(user.id, {
            kind: 'TESTER',
            profiles: ['che'],
            allowsGeneralCreation: true,
            expiresAt: null,
            reason: 'CHE 회귀 검증',
            grantedByUserId: 'admin-id',
        });
        await users.updateSanctions(user.id, {
            serverRestrictions: {
                che: {
                    blockedFeatures: ['login'],
                    until: '2099-01-01T00:00:00.000Z',
                },
            },
        });

        await expect(
            caller.auth.issueGameSession({
                sessionToken: register.sessionToken,
                profile: 'che:default',
            })
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });
    });

    it('links Kakao to the logged-in local account instead of creating a second user', async () => {
        const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
        const register = await caller.auth.registerLocal({
            username: 'verify-user',
            credential: sealPassword('verify-password'),
            displayName: '인증유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        setSessionHeader(register.sessionToken);
        const start = await caller.auth.kakaoStart({ mode: 'verify' });
        const verified = await caller.auth.kakaoExchange({
            code: 'oauth-code',
            state: start.state,
        });

        expect(verified.status).toBe('otp');
        if (verified.status !== 'otp') {
            throw new Error('Expected Kakao OTP challenge.');
        }
        const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
        expect(code).toBeTruthy();
        const completed = await caller.auth.kakaoOtp({ challengeId: verified.challengeId, code: code! });
        expect(completed.user.kakaoVerified).toBe(true);
        const stored = await users.findByUsername('verify-user');
        expect(stored).toMatchObject({
            oauthType: 'KAKAO',
            oauthId: '1',
            email: 'tester@example.com',
        });
        expect(stored?.kakaoVerifiedAt).toBeTruthy();
    });

    it('always requests both email and KakaoTalk message consent', async () => {
        const { caller } = buildCaller();

        const start = await caller.auth.kakaoStart({ mode: 'login', scopes: [] });

        expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
    });

    it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
        const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
        const emailOwner = await users.createUser({
            username: 'email-owner',
            password: 'owner-password',
            oauth: {
                type: 'KAKAO',
                id: 'original-kakao-id',
                email: 'tester@example.com',
                info: {},
            },
        });
        emailOwner.passwordResetRequired = true;
        await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
        await users.setKakaoReplacementApproval(emailOwner.id, {
            until: new Date(Date.now() + 60_000),
            approvedByUserId: 'admin-user-id',
            reason: '이메일 보존 계정의 교체 승인',
        });
        kakaoProfile.id = 'different-kakao-id';

        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });

        expect(recovery).toMatchObject({
            status: 'account_recovery',
            action: 'link_existing',
            email: 'tester@example.com',
        });
        if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');

        const linked = await caller.auth.kakaoResolveAccount({
            oauthSessionId: recovery.oauthSessionId,
            action: 'link_existing',
        });
        expect(linked.status).toBe('password_setup');
        if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
        expect(sentTalkMessages).toHaveLength(0);
        const passwordSet = await caller.auth.kakaoSetPassword({
            oauthSessionId: linked.oauthSessionId,
            credential: sealPassword('replacement-password'),
        });
        expect(passwordSet.status).toBe('otp');
        expect(sentTalkMessages).toHaveLength(1);
        expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
        expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true);
        expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
            id: emailOwner.id,
            username: 'email-owner',
            email: 'tester@example.com',
        });
        expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced');
        expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
    });

    it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
        const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
            kakaoId: 'imported-kakao-id',
            kakaoEmail: 'imported@example.com',
        });
        const user = await users.createUser({
            username: 'imported-kakao-user',
            password: 'legacy-password',
            oauth: {
                type: 'KAKAO',
                id: kakaoProfile.id,
                email: kakaoProfile.email,
                info: {},
            },
        });
        user.passwordResetRequired = true;
        const createSession = vi.spyOn(sessions, 'createSession');

        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        expect(login).toMatchObject({
            status: 'password_setup',
            email: 'imported@example.com',
            successStatus: 'login',
        });
        if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
        expect(login).not.toHaveProperty('sessionToken');
        expect(createSession).not.toHaveBeenCalled();
        expect(sentTalkMessages).toHaveLength(0);

        const setup = await caller.auth.kakaoSetPassword({
            oauthSessionId: login.oauthSessionId,
            credential: sealPassword('new-imported-password'),
        });
        expect(setup.status).toBe('otp');
        expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
        expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
        await expect(
            caller.auth.kakaoSetPassword({
                oauthSessionId: login.oauthSessionId,
                credential: sealPassword('another-password'),
            })
        ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
    });

    it('rechecks sanctions before consuming a migrated password setup', async () => {
        const { caller, users, kakaoProfile, sealPassword } = buildCaller({
            kakaoId: 'sanctioned-setup-id',
            kakaoEmail: 'sanctioned-setup@example.com',
        });
        const user = await users.createUser({
            username: 'sanctioned-setup-user',
            password: 'legacy-password',
            oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
        });
        user.passwordResetRequired = true;
        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
        await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });

        await expect(
            caller.auth.kakaoSetPassword({
                oauthSessionId: login.oauthSessionId,
                credential: sealPassword('blocked-password'),
            })
        ).rejects.toMatchObject({ code: 'FORBIDDEN' });
        expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
    });

    it('consumes password setup when the provider identity changes before submission', async () => {
        const { caller, users, kakaoProfile, sealPassword } = buildCaller({
            kakaoId: 'setup-target-id',
            kakaoEmail: 'setup-target@example.com',
        });
        const user = await users.createUser({
            username: 'setup-target-user',
            password: 'legacy-password',
            oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
        });
        user.passwordResetRequired = true;
        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
        kakaoProfile.id = 'changed-provider-id';

        await expect(
            caller.auth.kakaoSetPassword({
                oauthSessionId: login.oauthSessionId,
                credential: sealPassword('new-target-password'),
            })
        ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
        expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
        await expect(
            caller.auth.kakaoSetPassword({
                oauthSessionId: login.oauthSessionId,
                credential: sealPassword('new-target-password'),
            })
        ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
    });

    it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
        const { caller, users, sealPassword } = buildCaller({ kakaoSignupAlreadyRegistered: true });

        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        expect(recovery).toMatchObject({
            status: 'account_recovery',
            action: 'rejoin',
            email: 'tester@example.com',
        });
        if (recovery.status !== 'account_recovery') throw new Error('Expected rejoin choice.');

        const confirmed = await caller.auth.kakaoResolveAccount({
            oauthSessionId: recovery.oauthSessionId,
            action: 'rejoin',
        });
        expect(confirmed).toMatchObject({ status: 'join', email: 'tester@example.com' });
        if (confirmed.status !== 'join') throw new Error('Expected registration session.');

        const registered = await caller.auth.register({
            oauthSessionId: confirmed.oauthSessionId,
            username: 'rejoined-user',
            credential: sealPassword('rejoined-password'),
            displayName: '재가입사용자',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        expect(registered.status).toBe('otp');
        expect(await users.findByUsername('rejoined-user')).toMatchObject({
            oauthType: 'KAKAO',
            oauthId: '1',
            email: 'tester@example.com',
        });
    });

    it('does not let the registration mutation bypass the recovery confirmation', async () => {
        const { caller, users, sealPassword } = buildCaller();
        await users.createUser({
            username: 'retained-owner',
            password: 'owner-password',
            oauth: {
                type: 'KAKAO',
                id: 'former-kakao-id',
                email: 'tester@example.com',
                info: {},
            },
        });

        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');

        await expect(
            caller.auth.register({
                oauthSessionId: recovery.oauthSessionId,
                username: 'bypass-user',
                credential: sealPassword('bypass-password'),
                displayName: '우회사용자',
                termsAgreed: true,
                privacyAgreed: true,
                thirdPartyUse: false,
            })
        ).rejects.toMatchObject({
            code: 'PRECONDITION_FAILED',
            message: expect.stringContaining('복구 여부를 먼저 선택'),
        });
    });

    it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => {
        const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({
            kakaoId: 'new-kakao-id',
            kakaoEmail: 'new-kakao@example.com',
        });
        const user = await users.createUser({
            username: 'approved-replacement-user',
            password: 'replacement-password',
            oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} },
        });
        const oldSession = await sessions.createSession(user);
        setSessionHeader(oldSession.sessionToken);
        await users.setKakaoReplacementApproval(user.id, {
            until: new Date(Date.now() + 60_000),
            approvedByUserId: 'admin-user-id',
            reason: '사용자 본인 확인 완료',
        });

        const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken });
        const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
        expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' });
        expect(await users.findById(user.id)).toMatchObject({
            oauthId: 'new-kakao-id',
            email: 'new-kakao@example.com',
            kakaoReplacementApprovedUntil: undefined,
            authRevision: 1,
        });
        await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true);
        await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull();

        kakaoProfile.id = 'old-kakao-id';
        kakaoProfile.email = 'old-kakao@example.com';
        const retiredStart = await caller.auth.kakaoStart({ mode: 'login' });
        await expect(
            caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state })
        ).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') });
    });

    it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
        const { caller, users, kakaoProfile } = buildCaller({
            kakaoId: 'stable-kakao-id',
            kakaoEmail: 'changed@example.com',
        });
        const user = await users.createUser({
            username: 'kakao-email-change',
            password: 'email-change-password',
            oauth: {
                type: 'KAKAO',
                id: 'stable-kakao-id',
                email: 'before@example.com',
                info: {},
            },
        });
        await users.markKakaoTalkVerified(user.id, new Date(Date.now() + 60_000));

        const start = await caller.auth.kakaoStart({ mode: 'login' });
        const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });

        expect(login.status).toBe('login');
        expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
        expect(await users.findByEmail('before@example.com')).toBeNull();
    });

    it('checks Kakao identity and changed email on password login, then verifies the talk OTP', async () => {
        const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
            kakaoId: 'password-login-kakao-id',
            kakaoEmail: 'after-password-login@example.com',
        });
        const user = await users.createUser({
            username: 'kakao-password-login',
            password: 'kakao-password',
            oauth: {
                type: 'KAKAO',
                id: kakaoProfile.id,
                email: 'before-password-login@example.com',
                info: {
                    accessToken: 'stored-access-token',
                    refreshToken: 'stored-refresh-token',
                    accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
                    refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
                },
            },
        });

        const login = await caller.auth.login({
            username: user.username,
            credential: sealPassword('kakao-password'),
        });
        expect(login.status).toBe('otp');
        if (login.status !== 'otp') {
            throw new Error('Expected Kakao OTP challenge.');
        }
        expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
        expect(sentTalkMessages).toHaveLength(1);

        await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
            code: 'UNAUTHORIZED',
            message: expect.stringContaining('2회 더 시도'),
        });
        const code = sentTalkMessages[0]?.match(/인증 코드는 (\d{4})/)?.[1];
        expect(code).toBeTruthy();
        const completed = await caller.auth.kakaoOtp({ challengeId: login.challengeId, code: code! });
        expect(completed.validUntil).toBeTruthy();
        expect((await users.findById(user.id))?.kakaoTalkVerifiedUntil).toBe(completed.validUntil);

        const nextLogin = await caller.auth.login({
            username: user.username,
            credential: sealPassword('kakao-password'),
        });
        expect(nextLogin.status).toBe('login');
        expect(sentTalkMessages).toHaveLength(1);
    });

    it('refreshes an expired access token before the password-login identity check', async () => {
        const { caller, users, sealPassword, kakaoProfile, refreshTokenCalls } = buildCaller({
            kakaoId: 'refresh-kakao-id',
            kakaoEmail: 'refreshed-email@example.com',
            allowKakaoRefresh: true,
        });
        const user = await users.createUser({
            username: 'refresh-kakao-user',
            password: 'refresh-kakao-password',
            oauth: {
                type: 'KAKAO',
                id: kakaoProfile.id,
                email: 'old-refresh-email@example.com',
                info: {
                    accessToken: 'expired-access-token',
                    refreshToken: 'usable-refresh-token',
                    accessTokenValidUntil: new Date(Date.now() - 60_000).toISOString(),
                    refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
                },
            },
        });

        const login = await caller.auth.login({
            username: user.username,
            credential: sealPassword('refresh-kakao-password'),
        });

        expect(login.status).toBe('otp');
        expect(refreshTokenCalls).toEqual(['usable-refresh-token']);
        expect(await users.findById(user.id)).toMatchObject({
            email: 'refreshed-email@example.com',
            oauthInfo: {
                accessToken: 'refreshed-access-token',
                refreshToken: 'usable-refresh-token',
            },
        });
    });

    it('rejects password-login email synchronization when the changed email belongs to another user', async () => {
        const { caller, users, sealPassword, kakaoProfile } = buildCaller({
            kakaoId: 'conflicting-email-kakao-id',
            kakaoEmail: 'occupied@example.com',
        });
        const user = await users.createUser({
            username: 'conflicting-email-user',
            password: 'conflicting-email-password',
            oauth: {
                type: 'KAKAO',
                id: kakaoProfile.id,
                email: 'previous@example.com',
                info: {
                    accessToken: 'stored-access-token',
                    accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
                },
            },
        });
        await users.createUser({
            username: 'occupied-email-owner',
            password: 'occupied-email-password',
            oauth: {
                type: 'KAKAO',
                id: 'other-kakao-id',
                email: kakaoProfile.email,
                info: {},
            },
        });

        await expect(
            caller.auth.login({
                username: user.username,
                credential: sealPassword('conflicting-email-password'),
            })
        ).rejects.toMatchObject({
            code: 'CONFLICT',
            message: expect.stringContaining('이미 다른 계정에서 사용 중'),
        });
        expect((await users.findById(user.id))?.email).toBe('previous@example.com');
    });

    it('reuses the active challenge and blocks retries after three wrong OTP values', async () => {
        const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
            kakaoId: 'attempt-limit-kakao-id',
        });
        const user = await users.createUser({
            username: 'attempt-limit-user',
            password: 'attempt-limit-password',
            oauth: {
                type: 'KAKAO',
                id: kakaoProfile.id,
                email: kakaoProfile.email,
                info: {
                    accessToken: 'stored-access-token',
                    accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
                },
            },
        });
        const login = await caller.auth.login({
            username: user.username,
            credential: sealPassword('attempt-limit-password'),
        });
        expect(login.status).toBe('otp');
        if (login.status !== 'otp') {
            throw new Error('Expected Kakao OTP challenge.');
        }

        for (const remaining of [2, 1, 0]) {
            await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
                code: 'UNAUTHORIZED',
                message:
                    remaining > 0 ? expect.stringContaining(`${remaining}회 더 시도`) : expect.stringContaining('초과'),
            });
        }
        const retried = await caller.auth.login({
            username: user.username,
            credential: sealPassword('attempt-limit-password'),
        });
        expect(retried).toMatchObject({ status: 'otp', challengeId: login.challengeId, attemptsRemaining: 0 });
        expect(sentTalkMessages).toHaveLength(1);
    });

    it('blocks Kakao login while a ban is active', async () => {
        const { caller, users, sealPassword, setSessionHeader } = buildCaller();
        const register = await caller.auth.registerLocal({
            username: 'kakao-banned-user',
            credential: sealPassword('kakao-banned-password'),
            displayName: '카카오제재유저',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });
        setSessionHeader(register.sessionToken);
        const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' });
        await caller.auth.kakaoExchange({
            code: 'oauth-code',
            state: verifyStart.state,
        });

        const stored = await users.findByUsername('kakao-banned-user');
        expect(stored).not.toBeNull();
        if (stored) {
            await users.updateSanctions(stored.id, {
                bannedUntil: new Date(Date.now() + 60_000).toISOString(),
            });
        }

        const loginStart = await caller.auth.kakaoStart({ mode: 'login' });
        await expect(
            caller.auth.kakaoExchange({
                code: 'oauth-code',
                state: loginStart.state,
            })
        ).rejects.toMatchObject({
            code: 'FORBIDDEN',
            message: 'Account login is blocked.',
        });
    });

    it('carries the bootstrap superuser role into game sessions', async () => {
        const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
        process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
        try {
            const { caller } = buildCaller();
            const bootstrap = await caller.auth.bootstrapLocal({
                token: 'bootstrap-test-token',
                username: 'admin',
                password: 'secretpass',
                displayName: 'Admin',
            });

            expect(bootstrap.user.roles).toEqual(['superuser']);

            const issued = await caller.auth.issueGameSession({
                sessionToken: bootstrap.sessionToken,
                profile: 'che:default',
            });
            const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');

            expect(payload?.user.roles).toEqual(['superuser']);
        } finally {
            if (previousToken === undefined) {
                delete process.env.GATEWAY_BOOTSTRAP_TOKEN;
            } else {
                process.env.GATEWAY_BOOTSTRAP_TOKEN = previousToken;
            }
        }
    });

    it('registers and issues a game session', async () => {
        const { caller, oauthSessions, sealPassword, sentTalkMessages } = buildCaller();
        const oauthSession = await oauthSessions.createSession({
            mode: 'login',
            kakaoId: '1',
            email: 'tester@example.com',
            accessToken: 'token',
            refreshToken: 'refresh',
            accessTokenValidUntil: new Date().toISOString(),
            refreshTokenValidUntil: new Date().toISOString(),
            createdAt: new Date().toISOString(),
        });
        const register = await caller.auth.register({
            oauthSessionId: oauthSession.id,
            username: 'tester',
            credential: sealPassword('secretpass'),
            displayName: 'Tester',
            termsAgreed: true,
            privacyAgreed: true,
            thirdPartyUse: false,
        });

        expect(register.status).toBe('otp');
        if (register.status !== 'otp') {
            throw new Error('Expected Kakao OTP challenge.');
        }
        const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
        expect(code).toBeTruthy();
        const completed = await caller.auth.kakaoOtp({ challengeId: register.challengeId, code: code! });
        expect(completed.user.username).toBe('tester');
        expect(completed.sessionToken).toBeTruthy();

        const issued = await caller.auth.issueGameSession({
            sessionToken: completed.sessionToken,
            profile: 'che:default',
        });

        expect(issued.profile).toBe('che:default');
        expect(issued.gameToken).toBeTruthy();

        const validated = await caller.auth.validateGameSession({
            profile: 'che:default',
            gameToken: issued.gameToken,
        });

        expect(validated?.user.username).toBe('tester');
    });

    it('keeps the migrated member number inside the encrypted game identity', async () => {
        const { caller, users, sessions } = buildCaller();
        const user = await users.createUser({
            username: 'legacy-seed-user',
            password: 'secretpass',
        });
        user.legacyMemberNo = 42;
        const session = await sessions.createSession(user);

        const issued = await caller.auth.issueGameSession({
            sessionToken: session.sessionToken,
            profile: 'che:default',
        });
        const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');

        expect(payload?.user.legacyMemberNo).toBe(42);
        const validated = await caller.auth.validateGameSession({
            profile: 'che:default',
            gameToken: issued.gameToken,
        });
        expect(validated).toMatchObject({
            user: {
                id: user.id,
            },
        });
        expect(validated?.user).not.toHaveProperty('legacyMemberNo');
    });

    it('issues each game token from the latest user roles, sanctions, and icon', async () => {
        const { caller, users, sessions } = buildCaller();
        const user = await users.createUser({
            username: 'fresh-game-identity',
            password: 'secretpass',
        });
        user.legacyGrade = 0;
        const session = await sessions.createSession(user);

        await users.updateRoles(user.id, ['user', 'latest-role']);
        await users.updateSanctions(user.id, {
            warningCount: 2,
            legacyPenalty: {
                any: {
                    chat: { expire: 4_102_444_800, value: 1 },
                },
            },
        });
        await users.updateIcon(user.id, 'latest-owner.webp', 3, new Date('2026-07-30T12:00:00.000Z'));

        const issued = await caller.auth.issueGameSession({
            sessionToken: session.sessionToken,
            profile: 'che:default',
        });
        const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');

        expect(payload?.user).toMatchObject({
            id: user.id,
            roles: ['user', 'latest-role'],
            picture: 'latest-owner.webp',
            imageServer: 3,
            iconUpdatedAt: '2026-07-30T12:00:00.000Z',
            canUseGeneralPicture: false,
        });
        expect(payload?.sanctions).toMatchObject({
            warningCount: 2,
            legacyPenalty: {
                any: {
                    chat: { expire: 4_102_444_800, value: 1 },
                },
            },
        });
    });

    it('revokes the gateway session and every linked game session on logout', async () => {
        const { caller, users, sessions } = buildCaller();
        const user = await users.createUser({
            username: 'logout-user',
            password: 'secretpass',
        });
        const session = await sessions.createSession(user);
        const gameSession = await sessions.createGameSession(session.sessionToken, 'che:default');
        expect(gameSession).not.toBeNull();

        await caller.auth.logout({ sessionToken: session.sessionToken });

        expect(await sessions.getSession(session.sessionToken)).toBeNull();
        expect(
            gameSession ? await sessions.getGameSession(gameSession.profile, gameSession.gameToken) : undefined
        ).toBeNull();
    });
});

describe('account self service', () => {
    it('changes only the authenticated user password after verifying the current password', async () => {
        const { caller, users, sessions, sealPassword } = buildCaller();
        const user = await users.createUser({
            username: 'self-service',
            password: 'current-password',
        });
        user.passwordResetRequired = true;
        const session = await sessions.createSession(user);

        await expect(
            caller.account.changePassword({
                sessionToken: session.sessionToken,
                currentCredential: sealPassword('wrong-password'),
                newCredential: sealPassword('next-password'),
            })
        ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });

        await caller.account.changePassword({
            sessionToken: session.sessionToken,
            currentCredential: sealPassword('current-password'),
            newCredential: sealPassword('next-password'),
        });

        const refreshed = await users.findById(user.id);
        expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
        expect(refreshed?.passwordResetRequired).toBe(false);
    });

    it('revokes the session and schedules deletion after 30 days', async () => {
        const { caller, users, sessions, sealPassword } = buildCaller();
        const user = await users.createUser({
            username: 'delete-self',
            password: 'current-password',
        });
        const session = await sessions.createSession(user);

        const result = await caller.account.scheduleDeletion({
            sessionToken: session.sessionToken,
            currentCredential: sealPassword('current-password'),
        });

        expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
        expect((await users.findById(user.id))?.deleteAfter).toBe(result.deleteAfter);
        expect(await sessions.getSession(session.sessionToken)).toBeNull();
    });

    it('revokes third-party use consent without allowing it to be re-enabled', async () => {
        const { caller, users, sessions } = buildCaller();
        const user = await users.createUser({
            username: 'privacy-self',
            password: 'current-password',
        });
        const session = await sessions.createSession(user);

        await caller.account.disallowThirdPartyUse({ sessionToken: session.sessionToken });

        expect((await users.findById(user.id))?.thirdPartyUse).toBe(false);
    });

    it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
        try {
            const { caller, users, sessions, flushPublisher, userIconUpload } = buildCaller({
                userIconDir: iconDir,
            });
            const user = await users.createUser({
                username: 'icon-self',
                password: 'current-password',
            });
            const session = await sessions.createSession(user);
            const png = await sharp({
                create: {
                    width: 64,
                    height: 64,
                    channels: 4,
                    background: '#334455',
                },
            })
                .png()
                .toBuffer();

            const result = await caller.account.changeIcon({
                sessionToken: session.sessionToken,
                imageData: `data:image/png;base64,${png.toString('base64')}`,
            });
            const updated = await users.findById(user.id);
            const account = await caller.account.get({ sessionToken: session.sessionToken });

            expect(result.iconUrl).toMatch(
                /^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/
            );
            expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
            expect(account?.icons[0]?.url).toMatch(
                /^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.png$/
            );
            expect(updated?.imageServer).toBe(0);
            expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
            expect(userIconUpload.upload).toHaveBeenCalledWith(
                expect.objectContaining({ contentType: 'image/png', body: png })
            );
            await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
                code: 'TOO_MANY_REQUESTS',
            });
        } finally {
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });

    it('stores AVIF account icons with the public AVIF extension and media type', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-avif-'));
        try {
            const { caller, users, sessions, userIconUpload } = buildCaller({ userIconDir: iconDir });
            const user = await users.createUser({
                username: 'icon-avif',
                password: 'current-password',
            });
            const session = await sessions.createSession(user);
            const avif = await sharp({
                create: { width: 64, height: 64, channels: 4, background: '#334455' },
            })
                .avif()
                .toBuffer();

            const result = await caller.account.changeIcon({
                sessionToken: session.sessionToken,
                imageData: `data:image/avif;base64,${avif.toString('base64')}`,
            });

            expect(result.iconUrl).toMatch(
                /^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{32}\.avif$/
            );
            expect(userIconUpload.upload).toHaveBeenCalledWith(
                expect.objectContaining({ contentType: 'image/avif', body: avif })
            );
        } finally {
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });

    it('atomically allows only one icon change per KST day and removes the losing file', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
        try {
            const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
            const user = await users.createUser({
                username: 'icon-race',
                password: 'current-password',
            });
            const session = await sessions.createSession(user);
            const png = await sharp({
                create: {
                    width: 64,
                    height: 64,
                    channels: 4,
                    background: '#556677',
                },
            })
                .png()
                .toBuffer();
            const attempts = await Promise.allSettled(
                [1, 2].map(() =>
                    caller.account.changeIcon({
                        sessionToken: session.sessionToken,
                        imageData: `data:image/png;base64,${png.toString('base64')}`,
                    })
                )
            );

            expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
            expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
            expect(await users.listIcons(user.id)).toHaveLength(1);
        } finally {
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });

    it('flushes an account icon deletion with selectable running profiles', async () => {
        const { caller, users, sessions, flushPublisher } = buildCaller();
        const user = await users.createUser({
            username: 'icon-delete',
            password: 'current-password',
        });
        await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-30T12:00:00.000Z'));
        const session = await sessions.createSession(user);

        const result = await caller.account.deleteIcon({ sessionToken: session.sessionToken });
        const updated = await users.findById(user.id);

        expect(result).toMatchObject({
            ok: true,
            iconUrl: null,
            profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
        });
        expect(updated).toMatchObject({ picture: 'default.jpg', imageServer: 0 });
        expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-deleted');
    });

    it('uses a rolling 24-hour upload window and preserves delete-to-upload behavior', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-kst-'));
        const png = await sharp({
            create: {
                width: 64,
                height: 64,
                channels: 4,
                background: '#667788',
            },
        })
            .png()
            .toBuffer();
        try {
            vi.useFakeTimers();
            vi.setSystemTime(new Date('2026-07-31T14:59:59.000Z'));
            const { caller, users, sessions } = buildCaller({ userIconDir: iconDir });
            const user = await users.createUser({
                username: 'icon-kst',
                password: 'current-password',
            });
            await users.updateIcon(user.id, 'old.png', 1, new Date('2026-07-31T00:00:00.000Z'));
            const session = await sessions.createSession(user);

            await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
                code: 'TOO_MANY_REQUESTS',
            });

            vi.setSystemTime(new Date('2026-07-31T15:00:00.000Z'));
            await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
                code: 'TOO_MANY_REQUESTS',
            });

            vi.setSystemTime(new Date('2026-08-01T00:00:00.000Z'));
            const nextSession = await sessions.createSession(user);
            const deleted = await caller.account.deleteIcon({ sessionToken: nextSession.sessionToken });
            expect(deleted.revision).toBe('2026-08-01T00:00:00.000Z');

            const changed = await caller.account.changeIcon({
                sessionToken: nextSession.sessionToken,
                imageData: `data:image/png;base64,${png.toString('base64')}`,
            });
            expect(new Date(changed.revision).getTime()).toBeGreaterThan(new Date(deleted.revision).getTime());
            expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');
        } finally {
            vi.useRealTimers();
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });

    it('does not commit an icon when profile discovery fails before mutation', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-profile-failure-'));
        try {
            const { caller, users, sessions } = buildCaller({
                userIconDir: iconDir,
                profileListError: new Error('profile unavailable'),
            });
            const user = await users.createUser({
                username: 'icon-profile-failure',
                password: 'current-password',
            });
            const session = await sessions.createSession(user);
            const png = await sharp({
                create: { width: 64, height: 64, channels: 4, background: '#778899' },
            })
                .png()
                .toBuffer();

            await expect(
                caller.account.changeIcon({
                    sessionToken: session.sessionToken,
                    imageData: `data:image/png;base64,${png.toString('base64')}`,
                })
            ).rejects.toThrow('profile unavailable');
            expect(await fs.readdir(iconDir)).toEqual([]);
            expect(await users.findById(user.id)).toMatchObject({
                picture: 'default.jpg',
                imageServer: 0,
            });
        } finally {
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });

    it('returns a recoverable success when flush publication fails after commit', async () => {
        const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-flush-failure-'));
        try {
            const { caller, users, sessions } = buildCaller({
                userIconDir: iconDir,
                flushError: new Error('redis unavailable'),
            });
            const user = await users.createUser({
                username: 'icon-flush-failure',
                password: 'current-password',
            });
            const session = await sessions.createSession(user);
            const png = await sharp({
                create: { width: 64, height: 64, channels: 4, background: '#8899aa' },
            })
                .png()
                .toBuffer();

            const changed = await caller.account.changeIcon({
                sessionToken: session.sessionToken,
                imageData: `data:image/png;base64,${png.toString('base64')}`,
            });
            expect(changed.flushPublished).toBe(false);
            expect((await users.findById(user.id))?.picture).not.toBe('default.jpg');

            await expect(caller.account.prepareIconSync({ sessionToken: session.sessionToken })).resolves.toMatchObject(
                {
                    projection: {
                        revision: changed.revision,
                        imageServer: 0,
                    },
                    profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
                }
            );
        } finally {
            await fs.rm(iconDir, { recursive: true, force: true });
        }
    });
});
