import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    register,
    login,
    logout,
    me,
    changePassword,
    createToken,
    deleteToken,
    getAuthConfig
} from './auth';

function ok(body: unknown, status = 200): Response {
    return new Response(JSON.stringify(body), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

function noContent(): Response {
    return new Response(null, { status: 204 });
}

function envelope(status: number, code: string, message: string): Response {
    return new Response(JSON.stringify({ error: { code, message } }), {
        status,
        headers: { 'content-type': 'application/json' }
    });
}

const userFixture = {
    id: 'user-1',
    username: 'alice',
    created_at: '2026-01-01T00:00:00Z'
};

describe('auth api client', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });

    it('register POSTs JSON to /v1/auth/register and returns the user', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }, 201));
        const user = await register({ username: 'alice', password: 'hunter2hunter2' });
        expect(user).toEqual(userFixture);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/register$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(JSON.parse(init.body as string)).toEqual({
            username: 'alice',
            password: 'hunter2hunter2'
        });
    });

    it('register surfaces 409 conflict via ApiError.code', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(409, 'conflict', 'username is already taken'));
        await expect(
            register({ username: 'alice', password: 'hunter2hunter2' })
        ).rejects.toMatchObject({ status: 409, code: 'conflict' });
    });

    it('login POSTs JSON to /v1/auth/login and returns the user', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }));
        const user = await login({ username: 'alice', password: 'hunter2hunter2' });
        expect(user).toEqual(userFixture);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/login$/);
    });

    it('login surfaces 401 unauthenticated via ApiError.code', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
        await expect(
            login({ username: 'alice', password: 'wrong' })
        ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' });
    });

    it('logout POSTs to /v1/auth/logout and handles 204', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await expect(logout()).resolves.toBeUndefined();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/logout$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        // Consistent content-type for all mutation requests, matching
        // the rest of the module — axum doesn't require it but the
        // header keeps the request style uniform.
        const headers = new Headers(init.headers);
        expect(headers.get('content-type')).toBe('application/json');
    });

    it('me returns the user on 200', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ user: userFixture }));
        await expect(me()).resolves.toEqual(userFixture);
    });

    it('me returns null on 401 (anonymous user)', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
        await expect(me()).resolves.toBeNull();
    });

    it('me re-throws non-401 errors', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(500, 'internal_error', 'internal error'));
        await expect(me()).rejects.toMatchObject({ status: 500 });
    });

    it('changePassword PATCHes /v1/auth/me/password and handles 204', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await expect(
            changePassword({
                current_password: 'hunter2hunter2',
                new_password: 'freshpassfreshpass'
            })
        ).resolves.toBeUndefined();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/me\/password$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('PATCH');
        expect(JSON.parse(init.body as string)).toEqual({
            current_password: 'hunter2hunter2',
            new_password: 'freshpassfreshpass'
        });
    });

    it('changePassword surfaces 401 (wrong current) via ApiError', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(401, 'unauthenticated', 'unauthenticated'));
        await expect(
            changePassword({ current_password: 'wrong', new_password: 'freshpassfreshpass' })
        ).rejects.toMatchObject({ status: 401, code: 'unauthenticated' });
    });

    it('changePassword surfaces 400 (weak new) via ApiError', async () => {
        fetchSpy.mockResolvedValueOnce(envelope(400, 'invalid_input', 'password must be at least 8 characters'));
        await expect(
            changePassword({ current_password: 'hunter2hunter2', new_password: 'short' })
        ).rejects.toMatchObject({ status: 400, code: 'invalid_input' });
    });

    it('createToken POSTs to /v1/auth/tokens and returns CreatedToken with bearer', async () => {
        fetchSpy.mockResolvedValueOnce(
            ok(
                {
                    id: 't1',
                    user_id: 'user-1',
                    name: 'ci-bot',
                    created_at: '2026-01-01T00:00:00Z',
                    last_used_at: null,
                    bearer: 'raw-token-abc'
                },
                201
            )
        );
        const t = await createToken('ci-bot');
        expect(t.name).toBe('ci-bot');
        expect(t.bearer).toBe('raw-token-abc');
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/tokens$/);
    });

    it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => {
        fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false }));
        const cfg = await getAuthConfig();
        expect(cfg.self_register_enabled).toBe(false);
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/config$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        // Public endpoint; no method override means default GET.
        expect(init?.method ?? 'GET').toBe('GET');
    });

    it('deleteToken DELETEs to /v1/auth/tokens/{id} and handles 204', async () => {
        fetchSpy.mockResolvedValueOnce(noContent());
        await expect(deleteToken('t1')).resolves.toBeUndefined();
        const url = fetchSpy.mock.calls[0][0] as string;
        expect(url).toMatch(/\/v1\/auth\/tokens\/t1$/);
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('DELETE');
    });
});
