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

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

// Chainable channel: .listen(event, cb) stores cb and returns the channel itself
const listeners: Record<string, (d: unknown) => void> = {};

// Echo's PusherChannel exposes .error() (pusher:subscription_error) and
// .subscribed() (pusher:subscription_succeeded). Both are captured so the tests
// can drive the authorization round-trip the real broker performs.
let subscriptionErrorCb: ((data: unknown) => void) | null = null;
let subscribedCb: (() => void) | null = null;

const mockChannel = { listen: vi.fn(), error: vi.fn(), subscribed: vi.fn() };

function installChannelImplementations(): void {
    mockChannel.listen.mockImplementation((event: string, cb: (d: unknown) => void) => {
        listeners[event] = cb;
        return mockChannel;
    });
    mockChannel.error.mockImplementation((cb: (data: unknown) => void) => {
        subscriptionErrorCb = cb;
        return mockChannel;
    });
    mockChannel.subscribed.mockImplementation((cb: () => void) => {
        subscribedCb = cb;
        return mockChannel;
    });
}

installChannelImplementations();

const mockEchoChannel = vi.fn(() => mockChannel);
const mockEchoPrivate = vi.fn(() => mockChannel);
const mockEchoLeave = vi.fn();
const connBind = vi.fn();
const connUnbind = vi.fn();
const echoCtorOptions: Record<string, unknown>[] = [];

const mockEcho = {
    channel: mockEchoChannel,
    private: mockEchoPrivate,
    leave: mockEchoLeave,
    connector: { pusher: { connection: { bind: connBind, unbind: connUnbind } } },
};

// Mock Echo as a constructor (must use function, not arrow, to support `new`)
vi.mock('laravel-echo', () => ({
    default: vi.fn().mockImplementation(function (options: Record<string, unknown>) {
        echoCtorOptions.push(options);
        return mockEcho;
    }),
}));

// getEcho authorises private channels through axios (same wiring as useUserChannel)
vi.mock('axios', () => ({ default: { post: vi.fn(() => Promise.resolve({ data: {} })) } }));

// Pusher is only assigned to window.Pusher — a no-op mock is sufficient
vi.mock('pusher-js', () => ({ default: vi.fn() }));

// ---------------------------------------------------------------------------
// Per-test key counter — busts the module-level echoInstances cache so each
// test gets a fresh `new Echo(...)` call and clean connector state.
// ---------------------------------------------------------------------------
let keyCounter = 0;

// The real consumer passes an Inertia page prop, i.e. a *stable* object across
// renders. Caching per counter reproduces that: without it every render would
// hand the hook a new config identity and re-run its teardown effect.
const configCache = new Map<number, UseBuilderPusherOptions['pusherConfig']>();

const baseOptions = (over: Partial<UseBuilderPusherOptions> = {}): UseBuilderPusherOptions => {
    if (!configCache.has(keyCounter)) {
        configCache.set(keyCounter, { provider: 'pusher' as const, key: `test-key-${keyCounter}`, cluster: 'mt1' });
    }

    return {
        pusherConfig: configCache.get(keyCounter)!,
        enabled: true,
        ...over,
    };
};

describe('useBuilderPusher', () => {
    beforeEach(() => {
        // Clear the listeners store
        Object.keys(listeners).forEach((k) => delete listeners[k]);

        // Reset mock call histories (implementations are preserved)
        mockChannel.listen.mockClear();
        mockChannel.error.mockClear();
        mockChannel.subscribed.mockClear();
        installChannelImplementations();
        subscriptionErrorCb = null;
        subscribedCb = null;
        mockEchoChannel.mockClear();
        mockEchoChannel.mockReturnValue(mockChannel);
        mockEchoPrivate.mockClear();
        mockEchoPrivate.mockReturnValue(mockChannel);
        echoCtorOptions.length = 0;
        mockEchoLeave.mockClear();
        connBind.mockClear();
        connUnbind.mockClear();

        // Fresh config key per test → new echoInstances cache entry → new Echo()
        keyCounter++;
    });

    // -----------------------------------------------------------------------
    // 1. subscribe creates the channel and registers all 9 events
    // -----------------------------------------------------------------------
    it('test_subscribe_creates_channel_and_binds_all_events', () => {
        const { result } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });

        // The build stream carries file paths and generated source: it must be
        // subscribed to as a PRIVATE channel so routes/channels.php actually runs.
        // Echo adds the `private-` prefix itself, so the name stays session.{id}.
        expect(mockEchoPrivate).toHaveBeenCalledWith('session.s1');
        expect(mockEchoChannel).not.toHaveBeenCalled();

        const expectedEvents = [
            '.status',
            '.thinking',
            '.action',
            '.tool_call',
            '.tool_result',
            '.message',
            '.error',
            '.complete',
            '.summarization_complete',
        ];
        expectedEvents.forEach((event) => {
            expect(Object.keys(listeners)).toContain(event);
        });

        expect(result.current.error).toBeNull();
    });

    // -----------------------------------------------------------------------
    // 1b. isConnected must wait for pusher:subscription_succeeded
    //
    // Subscribing to a PRIVATE channel is asynchronous: pusher-js POSTs to
    // /broadcasting/auth and only then joins. Reporting "connected" the instant
    // .private() returns claims a stream that may never exist (an expired
    // session, a rotated CSRF token or lost project access all reject).
    // -----------------------------------------------------------------------
    it('test_subscribe_does_not_report_connected_before_the_subscription_is_authorised', () => {
        const { result } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });

        expect(result.current.isConnected).toBe(false);

        // The broker confirms the join → only now is the stream real.
        expect(subscribedCb).toBeTypeOf('function');
        act(() => {
            subscribedCb!();
        });

        expect(result.current.isConnected).toBe(true);
        expect(result.current.error).toBeNull();
    });

    // -----------------------------------------------------------------------
    // 1c. A rejected private subscription must not be silent
    //
    // Without this 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('test_subscription_error_reports_an_auth_rejection', () => {
        const onSubscriptionError = vi.fn();
        const { result } = renderHook(() =>
            useBuilderPusher(baseOptions({ onSubscriptionError }))
        );

        act(() => {
            result.current.subscribe('s1');
        });
        act(() => {
            subscribedCb!();
        });

        expect(subscriptionErrorCb).toBeTypeOf('function');
        act(() => {
            subscriptionErrorCb!({ 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('test_subscription_error_reports_a_generic_transport_failure', () => {
        const onSubscriptionError = vi.fn();
        const { result } = renderHook(() =>
            useBuilderPusher(baseOptions({ onSubscriptionError }))
        );

        act(() => {
            result.current.subscribe('s1');
        });
        act(() => {
            subscriptionErrorCb!({ 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.'
        );
    });

    // -----------------------------------------------------------------------
    // 2. subscribe is a no-op when disabled or session id is empty
    // -----------------------------------------------------------------------
    it('test_subscribe_noop_when_disabled_or_empty_session', () => {
        // Case 1: enabled = false
        const { result: r1 } = renderHook(() =>
            useBuilderPusher(baseOptions({ enabled: false }))
        );
        act(() => {
            r1.current.subscribe('s1');
        });
        expect(mockEchoPrivate).not.toHaveBeenCalled();

        // Use a fresh key for the second hook
        keyCounter++;
        mockEchoPrivate.mockClear();

        // Case 2: empty session id
        const { result: r2 } = renderHook(() => useBuilderPusher(baseOptions()));
        act(() => {
            r2.current.subscribe('');
        });
        expect(mockEchoPrivate).not.toHaveBeenCalled();
    });

    // -----------------------------------------------------------------------
    // 3. subscribe with empty key sets error and does NOT call channel
    // -----------------------------------------------------------------------
    it('test_subscribe_sets_error_when_pusher_not_configured', () => {
        const { result } = renderHook(() =>
            useBuilderPusher({
                pusherConfig: { provider: 'pusher' as const, key: '', cluster: 'mt1' },
                enabled: true,
            })
        );

        act(() => {
            result.current.subscribe('s1');
        });

        expect(result.current.error).toBe(
            'Pusher is not configured. Please configure Pusher in Admin Settings.'
        );
        expect(mockEchoPrivate).not.toHaveBeenCalled();
    });

    // -----------------------------------------------------------------------
    // 4. subscribing to the same session twice does not recreate the channel
    // -----------------------------------------------------------------------
    it('test_resubscribe_same_session_does_not_recreate_channel', () => {
        const { result } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });
        act(() => {
            result.current.subscribe('s1');
        });

        expect(mockEchoPrivate).toHaveBeenCalledTimes(1);
    });

    // -----------------------------------------------------------------------
    // 5. switching sessions leaves the previous channel first
    // -----------------------------------------------------------------------
    it('test_switching_session_leaves_previous_channel', () => {
        const { result } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });
        act(() => {
            result.current.subscribe('s2');
        });

        expect(mockEchoLeave).toHaveBeenCalledWith('session.s1');
        expect(mockEchoPrivate).toHaveBeenCalledTimes(2);
        expect(mockEchoPrivate).toHaveBeenLastCalledWith('session.s2');
    });

    // -----------------------------------------------------------------------
    // 6. event dispatch calls the correct per-event and onAnyEvent callbacks
    // -----------------------------------------------------------------------
    it('test_event_dispatch_invokes_callbacks', () => {
        const onStatus = vi.fn();
        const onComplete = vi.fn();
        const onAnyEvent = vi.fn();

        const { result } = renderHook(() =>
            useBuilderPusher(baseOptions({ onStatus, onComplete, onAnyEvent }))
        );

        act(() => {
            result.current.subscribe('s1');
        });

        // Fire .status
        const statusData = { status: 'building', message: 'x' };
        act(() => {
            listeners['.status'](statusData);
        });
        expect(onStatus).toHaveBeenCalledWith(statusData);
        expect(onAnyEvent).toHaveBeenCalledWith({ type: 'status', data: statusData });

        // Fire .complete
        const completeData = { iterations: 1, tokens_used: 100, files_changed: false };
        act(() => {
            listeners['.complete'](completeData);
        });
        expect(onComplete).toHaveBeenCalledWith(completeData);
        expect(onAnyEvent).toHaveBeenCalledWith({ type: 'complete', data: completeData });
    });

    // -----------------------------------------------------------------------
    // 7. unsubscribe leaves the channel and clears connection state
    // -----------------------------------------------------------------------
    it('test_unsubscribe_leaves_channel', () => {
        const { result } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });
        act(() => {
            subscribedCb!();
        });
        expect(result.current.isConnected).toBe(true);

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

        expect(mockEchoLeave).toHaveBeenCalledWith('session.s1');
        expect(result.current.isConnected).toBe(false);
    });

    // -----------------------------------------------------------------------
    // 8. unmounting the hook leaves the current channel
    // -----------------------------------------------------------------------
    it('test_unmount_leaves_channel', () => {
        const { result, unmount } = renderHook(() => useBuilderPusher(baseOptions()));

        act(() => {
            result.current.subscribe('s1');
        });

        unmount();

        expect(mockEchoLeave).toHaveBeenCalledWith('session.s1');
    });

    // -----------------------------------------------------------------------
    // 9. connection bind fires on mount; handlers update state + call back
    // -----------------------------------------------------------------------
    it('test_connection_bind_on_mount_and_handlers', () => {
        const onReconnected = vi.fn();
        const onDisconnected = vi.fn();

        const { result } = renderHook(() =>
            useBuilderPusher(baseOptions({ onReconnected, onDisconnected }))
        );

        // The connection effect should have bound both events on mount
        expect(connBind).toHaveBeenCalledWith('disconnected', expect.any(Function));
        expect(connBind).toHaveBeenCalledWith('connected', expect.any(Function));

        // Extract the actual handlers from the bind calls
        const connectedCall = connBind.mock.calls.find(([event]) => event === 'connected');
        const disconnectedCall = connBind.mock.calls.find(([event]) => event === 'disconnected');
        const connectedHandler = connectedCall?.[1] as () => void;
        const disconnectedHandler = disconnectedCall?.[1] as () => void;

        // Simulate 'connected' → onReconnected + isConnected = true
        act(() => {
            connectedHandler();
        });
        expect(onReconnected).toHaveBeenCalled();
        expect(result.current.isConnected).toBe(true);

        // Simulate 'disconnected' → onDisconnected + isConnected = false
        act(() => {
            disconnectedHandler();
        });
        expect(onDisconnected).toHaveBeenCalled();
        expect(result.current.isConnected).toBe(false);
    });

    // -----------------------------------------------------------------------
    // 11. Echo is built with an authorizer that hits Laravel's broadcasting auth
    // -----------------------------------------------------------------------
    it('test_echo_is_configured_with_a_broadcasting_auth_authorizer', async () => {
        const axios = (await import('axios')).default as unknown as { post: ReturnType<typeof vi.fn> };
        axios.post.mockClear();

        renderHook(() => useBuilderPusher(baseOptions()));

        expect(echoCtorOptions).toHaveLength(1);
        const authorizer = echoCtorOptions[0].authorizer as
            | ((channel: { name: string }) => { authorize: (socketId: string, cb: unknown) => void })
            | undefined;

        // Without an authorizer a private subscription cannot be authorised at all.
        expect(authorizer).toBeTypeOf('function');

        authorizer!({ name: 'private-session.s1' }).authorize('1234.5678', vi.fn());

        expect(axios.post).toHaveBeenCalledWith('/broadcasting/auth', {
            socket_id: '1234.5678',
            channel_name: 'private-session.s1',
        });
    });

    // -----------------------------------------------------------------------
    // 11b. The authorizer must RESOLVE Pusher's callback, not merely post
    //
    // 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('test_authorizer_resolves_the_pusher_callback', async () => {
        const axios = (await import('axios')).default as unknown as { post: ReturnType<typeof vi.fn> };
        axios.post.mockClear();

        renderHook(() => useBuilderPusher(baseOptions()));

        const authorizer = echoCtorOptions[0].authorizer as (channel: { name: string }) => {
            authorize: (
                socketId: string,
                cb: (error: Error | null, authData: { auth: string } | null) => void,
            ) => void;
        };

        // Success → callback(null, authData)
        axios.post.mockResolvedValueOnce({ data: { auth: 'app-key:signature' } });
        const okCallback = vi.fn();
        authorizer({ name: 'private-session.s1' }).authorize('1234.5678', okCallback);
        await vi.waitFor(() => expect(okCallback).toHaveBeenCalledTimes(1));
        expect(okCallback).toHaveBeenCalledWith(null, { auth: 'app-key:signature' });

        // Rejection → callback(Error, null)
        const rejection = new Error('Request failed with status code 403');
        axios.post.mockRejectedValueOnce(rejection);
        const failCallback = vi.fn();
        authorizer({ name: 'private-session.s1' }).authorize('1234.5678', failCallback);
        await vi.waitFor(() => expect(failCallback).toHaveBeenCalledTimes(1));
        expect(failCallback).toHaveBeenCalledWith(rejection, null);
    });

    // -----------------------------------------------------------------------
    // 11c. Cleanup must clear the channel ref, or resubscribing is impossible
    //
    // The cleanup leaves the channel but the "already subscribed" short-circuit
    // still sees the stale name, so a subscribe() for the same session after a
    // config change is dropped and the stream never comes back.
    // -----------------------------------------------------------------------
    it('test_cleanup_clears_the_channel_ref_so_the_same_session_can_resubscribe', () => {
        const { result, rerender } = renderHook(
            (props: UseBuilderPusherOptions) => useBuilderPusher(props),
            { initialProps: baseOptions() }
        );

        act(() => {
            result.current.subscribe('s1');
        });
        expect(mockEchoPrivate).toHaveBeenCalledTimes(1);

        // A new config tears the old channel down via the cleanup effect.
        keyCounter++;
        rerender(baseOptions());
        expect(mockEchoLeave).toHaveBeenCalledWith('session.s1');

        act(() => {
            result.current.subscribe('s1');
        });

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

    // -----------------------------------------------------------------------
    // 10. unmounting unbinds both connection handlers
    // -----------------------------------------------------------------------
    it('test_connection_unbinds_on_cleanup', () => {
        const { unmount } = renderHook(() => useBuilderPusher(baseOptions()));

        unmount();

        expect(connUnbind).toHaveBeenCalledWith('disconnected', expect.any(Function));
        expect(connUnbind).toHaveBeenCalledWith('connected', expect.any(Function));
    });
});
