import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useBuilderReverb } from '../useBuilderReverb';

// Mock Pusher
const mockUnbindAll = vi.fn();
const mockBind = vi.fn();

// Captured channel bindings, so tests can drive the events the broker sends —
// including pusher:subscription_error, the only signal that a private
// subscription was rejected.
const channelBinds: Record<string, (data: unknown) => void> = {};
mockBind.mockImplementation((event: string, cb: (data: unknown) => void) => {
    channelBinds[event] = cb;
});

const mockChannel = {
    bind: mockBind,
    unbind_all: mockUnbindAll,
};

const mockSubscribe = vi.fn().mockReturnValue(mockChannel);
const mockUnsubscribe = vi.fn();
const mockConnectionBind = vi.fn();
const mockConnectionUnbind = vi.fn();
const mockDisconnect = vi.fn();

// Captured so the private-channel auth wiring can be asserted
const pusherCtorArgs: Array<{ key: string; options: Record<string, unknown> }> = [];

function MockPusher(key: string, options: Record<string, unknown>) {
    pusherCtorArgs.push({ key, options });

    return {
        subscribe: mockSubscribe,
        unsubscribe: mockUnsubscribe,
        connection: { bind: mockConnectionBind, unbind: mockConnectionUnbind },
        disconnect: mockDisconnect,
    };
}

vi.mock('pusher-js', () => ({
    default: MockPusher,
}));

// The private-channel authorizer posts through axios (same wiring as useUserChannel)
vi.mock('axios', () => ({ default: { post: vi.fn(() => Promise.resolve({ data: {} })) } }));

// Unique config key per test to avoid module-level pusherInstances cache
let configCounter = 0;

function getConfig() {
    return {
        reverbConfig: {
            provider: 'reverb' as const,
            key: `test-key-${configCounter}`,
            host: 'localhost',
            port: 6001,
            scheme: 'http' as const,
        },
        enabled: true,
    };
}

describe('useBuilderReverb', () => {
    beforeEach(() => {
        mockUnbindAll.mockClear();
        mockBind.mockClear();
        mockBind.mockImplementation((event: string, cb: (data: unknown) => void) => {
            channelBinds[event] = cb;
        });
        Object.keys(channelBinds).forEach((k) => delete channelBinds[k]);
        mockSubscribe.mockClear().mockReturnValue(mockChannel);
        mockUnsubscribe.mockClear();
        mockConnectionBind.mockClear();
        mockConnectionUnbind.mockClear();
        mockDisconnect.mockClear();
        pusherCtorArgs.length = 0;
        configCounter++;
    });

    it('calls unbind_all before unsubscribing on explicit unsubscribe', () => {
        const config = getConfig();
        const { result } = renderHook(() => useBuilderReverb(config));

        act(() => {
            result.current.subscribe('session-1');
        });
        // The build stream carries file paths and generated source, so it is a
        // PRIVATE channel gated by routes/channels.php. Raw pusher-js — unlike
        // Echo — does not add the `private-` prefix, so it is spelled out.
        expect(mockSubscribe).toHaveBeenCalledWith('private-session.session-1');
        expect(mockSubscribe).not.toHaveBeenCalledWith('session.session-1');

        mockUnbindAll.mockClear();

        act(() => {
            result.current.unsubscribe();
        });

        expect(mockUnbindAll).toHaveBeenCalled();
        expect(mockUnsubscribe).toHaveBeenCalledWith('private-session.session-1');
    });

    it('calls unbind_all when switching channels', () => {
        const config = getConfig();
        const { result } = renderHook(() => useBuilderReverb(config));

        act(() => {
            result.current.subscribe('session-a');
        });

        mockUnbindAll.mockClear();

        act(() => {
            result.current.subscribe('session-b');
        });

        // Should have called unbind_all on the previous channel before subscribing to new
        expect(mockUnbindAll).toHaveBeenCalled();
        expect(mockUnsubscribe).toHaveBeenCalledWith('private-session.session-a');
        expect(mockSubscribe).toHaveBeenLastCalledWith('private-session.session-b');
    });

    it('calls unbind_all on unmount', () => {
        const config = getConfig();
        const { result, unmount } = renderHook(() => useBuilderReverb(config));

        act(() => {
            result.current.subscribe('session-1');
        });

        mockUnbindAll.mockClear();

        unmount();

        expect(mockUnbindAll).toHaveBeenCalled();
    });

    it('configures pusher to authorise private channels against Laravel', () => {
        renderHook(() => useBuilderReverb(getConfig()));

        expect(pusherCtorArgs).toHaveLength(1);
        const options = pusherCtorArgs[0].options as {
            channelAuthorization?: { endpoint?: string; customHandler?: unknown };
        };

        // Without this every private subscription is rejected and the build
        // stream silently never arrives.
        expect(options.channelAuthorization?.endpoint).toBe('/broadcasting/auth');
        expect(options.channelAuthorization?.customHandler).toBeTypeOf('function');
    });

    it('posts the subscription to /broadcasting/auth with the CSRF token', async () => {
        const meta = document.createElement('meta');
        meta.setAttribute('name', 'csrf-token');
        meta.setAttribute('content', 'test-csrf-token');
        document.head.appendChild(meta);

        try {
            const axios = (await import('axios')).default as unknown as { post: ReturnType<typeof vi.fn> };
            axios.post.mockClear();

            renderHook(() => useBuilderReverb(getConfig()));

            const handler = (
                pusherCtorArgs[0].options as {
                    channelAuthorization: {
                        customHandler: (
                            p: { socketId: string; channelName: string },
                            cb: (e: Error | null, d: unknown) => void,
                        ) => void;
                    };
                }
            ).channelAuthorization.customHandler;

            handler({ socketId: '1234.5678', channelName: 'private-session.s1' }, vi.fn());

            expect(axios.post).toHaveBeenCalledWith(
                '/broadcasting/auth',
                { socket_id: '1234.5678', channel_name: 'private-session.s1' },
                { headers: { 'X-CSRF-TOKEN': 'test-csrf-token' } },
            );
        } finally {
            meta.remove();
        }
    });

    /**
     * Posting to /broadcasting/auth is worthless if the response never reaches
     * pusher-js: the subscription hangs unauthorised forever and the build
     * stream silently never arrives. Replacing the `.then`/`.catch` bodies with
     * no-ops must fail here.
     */
    it('resolves the pusher auth callback on success and on rejection', async () => {
        const axios = (await import('axios')).default as unknown as { post: ReturnType<typeof vi.fn> };
        axios.post.mockClear();

        renderHook(() => useBuilderReverb(getConfig()));

        const handler = (
            pusherCtorArgs[0].options as {
                channelAuthorization: {
                    customHandler: (
                        p: { socketId: string; channelName: string },
                        cb: (e: Error | null, d: unknown) => void,
                    ) => void;
                };
            }
        ).channelAuthorization.customHandler;

        axios.post.mockResolvedValueOnce({ data: { auth: 'app-key:signature' } });
        const okCallback = vi.fn();
        handler({ socketId: '1234.5678', channelName: 'private-session.s1' }, okCallback);
        await vi.waitFor(() => expect(okCallback).toHaveBeenCalledTimes(1));
        expect(okCallback).toHaveBeenCalledWith(null, { auth: 'app-key:signature' });

        const rejection = new Error('Request failed with status code 403');
        axios.post.mockRejectedValueOnce(rejection);
        const failCallback = vi.fn();
        handler({ socketId: '1234.5678', channelName: 'private-session.s1' }, failCallback);
        await vi.waitFor(() => expect(failCallback).toHaveBeenCalledTimes(1));
        expect(failCallback).toHaveBeenCalledWith(rejection, null);
    });

    /**
     * A rejected private subscription must not be silent. The chat shows a
     * "running" build (that state is driven by progress.status, not the socket)
     * with zero thinking bubbles, actions or messages, and a mid-run failure
     * never reaches the user at all.
     */
    it('surfaces an auth rejection from pusher:subscription_error', () => {
        const onSubscriptionError = vi.fn();
        const { result } = renderHook(() =>
            useBuilderReverb({ ...getConfig(), onSubscriptionError })
        );

        act(() => {
            result.current.subscribe('session-1');
        });

        expect(channelBinds['pusher:subscription_error']).toBeTypeOf('function');

        act(() => {
            channelBinds['pusher:subscription_error']({ type: 'AuthError', error: 'Forbidden', status: 403 });
        });

        expect(result.current.isConnected).toBe(false);
        expect(result.current.error).toBe(
            'Could not connect to live build updates. You may need to sign in again.'
        );
        expect(onSubscriptionError).toHaveBeenCalledWith(
            'Could not connect to live build updates. You may need to sign in again.'
        );
    });

    it('surfaces a generic transport failure from pusher:subscription_error', () => {
        const onSubscriptionError = vi.fn();
        const { result } = renderHook(() =>
            useBuilderReverb({ ...getConfig(), onSubscriptionError })
        );

        act(() => {
            result.current.subscribe('session-1');
        });

        act(() => {
            channelBinds['pusher:subscription_error']({ type: 'PusherError', error: 'boom', status: 500 });
        });

        expect(result.current.isConnected).toBe(false);
        expect(result.current.error).toBe(
            'Could not connect to live build updates. The build is still running and will finish in the background.'
        );
        expect(onSubscriptionError).toHaveBeenCalledWith(
            'Could not connect to live build updates. The build is still running and will finish in the background.'
        );
    });

    /**
     * The cleanup unsubscribes but leaves the channel-name ref set, so the
     * "already subscribed" short-circuit drops a subscribe() for the same
     * session afterwards and the stream never comes back.
     */
    it('clears the channel refs on cleanup so the same session can resubscribe', () => {
        const { result, rerender } = renderHook(
            (props: ReturnType<typeof getConfig>) => useBuilderReverb(props),
            { initialProps: getConfig() }
        );

        act(() => {
            result.current.subscribe('session-1');
        });
        expect(mockSubscribe).toHaveBeenCalledTimes(1);

        // A new config tears the old channel down via the cleanup effect.
        configCounter++;
        rerender(getConfig());
        expect(mockUnsubscribe).toHaveBeenCalledWith('private-session.session-1');

        act(() => {
            result.current.subscribe('session-1');
        });

        expect(mockSubscribe).toHaveBeenCalledTimes(2);
    });
});
