'use client'

/**
 * mizan React Context
 *
 * Provides server function calls via HTTP (default) or WebSocket RPC (opt-in).
 * This is the core React integration for Django server functions.
 *
 * Transport Model:
 * - HTTP-first: Functions use HTTP by default (transport='http' or undefined)
 * - WebSocket opt-in: Functions with transport='websocket' use WebSocket RPC
 *   when connected, falling back to HTTP when disconnected
 *
 * Two layers:
 * 1. MizanProvider (this file) - Generic provider with name-based API
 *    - Libraries like Allauth use this: useMizan(), useContext('current_user')
 *
 * 2. Generated DjangoContext (in @/api) - Typed wrapper around MizanProvider
 *    - Product code uses this: useCurrentUser(), useUpdateProfile()
 *
 * The generated code wraps MizanProvider and adds type-safe hooks.
 */

import {
    createContext,
    useContext as useReactContext,
    useEffect,
    useMemo,
    useRef,
    useState,
    useCallback,
    type ReactNode,
} from 'react'
import { ChannelConnection, RPCError } from 'mizan/channels'
import {
    createMizanCSRClient,
    Auth,
    type FunctionResponse,
} from 'mizan/client'
import { useJWT } from './jwt'
import { MizanError, type ErrorCode, type FunctionErrorResponse } from './errors'

import { getCSRFToken } from './utils'

// ============================================================================
// Types
// ============================================================================

export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected'

/** Push message received from server */
export interface PushMessage<T = unknown> {
    topic: string
    data: T
}

/** Listener for push messages */
export type PushListener<T = unknown> = (message: PushMessage<T>) => void

/** Context data store - maps context names to their data */
export type ContextStore = Record<string, unknown>

/** Hydration data for SSR - maps context names to their initial data */
export type MizanHydration = Record<string, unknown>

/** Transport mode for server function calls */
export type Transport = 'http' | 'websocket'

export interface MizanContextValue {
    /**
     * Call a server function by name.
     *
     * Transport behavior:
     * - 'http' (default): Always use HTTP POST /api/mizan/call/
     * - 'websocket': Use WebSocket RPC when connected, HTTP fallback when not
     *
     * @param functionName - The server function name (e.g., 'echo', 'update_profile')
     * @param input - Optional input data for the function
     * @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
     */
    call: <TInput = unknown, TOutput = unknown>(
        functionName: string,
        input?: TInput,
        transport?: Transport
    ) => Promise<TOutput>

    /**
     * Get cached context data by name.
     * Returns undefined if the context hasn't been loaded yet.
     */
    getContext: <T = unknown>(name: string) => T | undefined

    /**
     * Refresh a specific context by name.
     * Fetches fresh data from the server and updates the cache.
     */
    refreshContext: (name: string) => Promise<void>

    /**
     * Refresh all registered contexts.
     */
    refreshAllContexts: () => Promise<void>

    /**
     * Current WebSocket connection status.
     */
    status: ConnectionStatus

    /**
     * Whether WebSocket RPC is available.
     */
    isRPCAvailable: boolean

    /**
     * Subscribe to push messages for a topic.
     * Returns an unsubscribe function.
     */
    onPush: <T = unknown>(topic: string, listener: PushListener<T>) => () => void

    /**
     * Subscribe to context changes.
     * Returns an unsubscribe function.
     */
    onContextChange: (name: string, listener: (data: unknown) => void) => () => void

    /**
     * Promise that resolves when the session is initialized (CSRF cookie set).
     * Await this before making HTTP calls in contexts where timing matters
     * (e.g., calling a server function immediately on mount).
     */
    whenReady: Promise<void>

    /**
     * Invalidate a named context, triggering a refetch.
     * Only refetches if the context is currently mounted (has a registered provider).
     * No-op if the context is not mounted.
     */
    invalidateContext: (name: string) => Promise<void>

    /**
     * Invalidate specific functions within their contexts.
     * Groups by context and calls invalidateContext per group.
     */
    invalidateFunctions: (names: string[]) => Promise<void>

    /**
     * Register a named context provider for invalidation support.
     * Called by generated context providers on mount.
     * Returns an unregister function (call on unmount).
     */
    registerContextProvider: (
        name: string,
        refetch: () => Promise<void>,
    ) => () => void

    /**
     * Base URL for HTTP calls (for use by generated context providers).
     */
    baseUrl: string

    /**
     * Set context data directly without triggering a network request.
     * Used by generated providers that fetch bundled responses.
     */
    setContextData: (name: string, data: unknown) => void

    /**
     * Make an authenticated HTTP request.
     * Handles JWT Bearer or session cookie auth automatically.
     * Waits for session init before making the request.
     */
    request: (method: string, path: string, data?: unknown) => Promise<Response>
}

export interface MizanProviderProps {
    children: ReactNode

    /**
     * Initial hydration data for contexts (from SSR).
     * Keys are context names, values are the data.
     */
    hydration?: MizanHydration

    /**
     * List of context names to auto-fetch if not in hydration.
     * These will be fetched on mount.
     */
    contexts?: string[]

    /**
     * Base URL for HTTP fallback calls.
     * @default '/api/mizan'
     */
    baseUrl?: string

    /**
     * WebSocket URL for RPC calls.
     * @default '/ws/'
     */
    wsUrl?: string

    /**
     * Whether to connect WebSocket automatically.
     * @default true
     */
    autoConnect?: boolean

    /**
     * WebSocket reconnection options.
     */
    reconnect?: boolean
    reconnectDelay?: number
    maxReconnectAttempts?: number

    /**
     * Custom connection instance (for testing).
     */
    connection?: ChannelConnection
}

// ============================================================================
// Context
// ============================================================================

const MizanContextInternal = createContext<MizanContextValue | null>(null)

// ============================================================================
// Provider
// ============================================================================

export function MizanProvider({
    children,
    hydration,
    contexts: contextNames = [],
    baseUrl = '/api/mizan',
    wsUrl = '/ws/',
    autoConnect = true,
    reconnect = true,
    reconnectDelay = 1000,
    maxReconnectAttempts = 10,
    connection: providedConnection,
}: MizanProviderProps) {
    const connectionRef = useRef<ChannelConnection | null>(null)

    // Push listeners: Map<topic, Set<listener>>
    const pushListenersRef = useRef<Map<string, Set<PushListener>>>(new Map())

    // Context change listeners: Map<name, Set<listener>>
    const contextListenersRef = useRef<Map<string, Set<(data: unknown) => void>>>(new Map())

    // Context data store
    const [contextStore, setContextStore] = useState<ContextStore>(() => {
        // Initialize from hydration if provided
        return hydration ?? {}
    })

    // Check if JWT is available - use JWT auth if so, otherwise session auth
    const jwt = useJWT()
    const hasJWT = jwt !== null && jwt.tokens !== null
    const [sessionReady, setSessionReady] = useState(false)

    // Promise that resolves when session is initialized.
    // Exposed via context so any code that needs to wait for CSRF can await it.
    const sessionRef = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null)
    if (!sessionRef.current) {
        let resolve!: () => void
        const promise = new Promise<void>(r => { resolve = r })
        sessionRef.current = { promise, resolve }
    }

    // Create HTTP client with appropriate auth method
    const httpClient = useMemo(() => {
        if (jwt?.getAccessToken) {
            return createMizanCSRClient(Auth.JWT, {
                baseUrl,
                getAccessToken: jwt.getAccessToken,
            })
        }
        return createMizanCSRClient(Auth.SESSION, { baseUrl })
    }, [hasJWT, jwt?.getAccessToken, baseUrl])

    // Create or use provided connection
    if (!connectionRef.current) {
        connectionRef.current = providedConnection ?? new ChannelConnection({
            url: wsUrl,
            reconnect,
            reconnectDelay,
            maxReconnectAttempts,
        })
    }

    const connection = connectionRef.current

    // Track connection status
    const [status, setStatus] = useState<ConnectionStatus>(
        connection.status as ConnectionStatus
    )

    // The core call function: HTTP-first, WebSocket opt-in
    const call = useCallback(
        async <TInput = unknown, TOutput = unknown>(
            functionName: string,
            input?: TInput,
            transport: Transport = 'http'
        ): Promise<TOutput> => {
            // Only attempt WebSocket if explicitly requested AND connected
            if (transport === 'websocket' && connection.status === 'connected') {
                try {
                    return await connection.rpc<TInput, TOutput>(functionName, input as TInput)
                } catch (e) {
                    // If it's an RPC error (function error), re-throw as MizanError
                    if (e instanceof RPCError) {
                        throw new MizanError({
                            error: true,
                            code: e.code as ErrorCode,
                            message: e.message,
                            details: e.details,
                        })
                    }

                    // Connection error - fall through to HTTP
                    console.warn(
                        `[mizan] WebSocket RPC failed for '${functionName}', falling back to HTTP:`,
                        e
                    )
                }
            }

            // Wait for session init (CSRF cookie) before making HTTP requests
            await sessionRef.current!.promise

            const response = await httpClient.request(
                'POST',
                `${baseUrl}/call/`,
                { fn: functionName, args: input }
            )

            const data = await response.json()

            if (data.error) {
                throw new MizanError(data as FunctionErrorResponse)
            }

            // Server-driven invalidation: process the invalidate array
            if (data.invalidate && Array.isArray(data.invalidate)) {
                for (const entry of data.invalidate) {
                    if (typeof entry === 'string') {
                        const provider = contextProvidersRef.current.get(entry)
                        if (provider) provider.refetch()
                    } else if (entry.context) {
                        const provider = contextProvidersRef.current.get(entry.context)
                        if (provider) provider.refetch()
                    }
                }
            }

            return data.result as TOutput
        },
        [connection, baseUrl, httpClient]
    )

    // Get cached context data
    const getContext = useCallback(
        <T = unknown>(name: string): T | undefined => {
            return contextStore[name] as T | undefined
        },
        [contextStore]
    )

    // Refresh a specific context via GET /ctx/<name>/
    const refreshContext = useCallback(
        async (name: string): Promise<void> => {
            try {
                const response = await httpClient.request('GET', `${baseUrl}/ctx/${name}/`)
                const data = await response.json()
                setContextStore(prev => {
                    const next = { ...prev, [name]: data }
                    // Notify listeners
                    const listeners = contextListenersRef.current.get(name)
                    if (listeners) {
                        listeners.forEach(listener => {
                            try {
                                listener(data)
                            } catch (e) {
                                console.error(`[mizan] Context listener error for '${name}':`, e)
                            }
                        })
                    }
                    return next
                })
            } catch (e) {
                console.error(`[mizan] Failed to refresh context '${name}':`, e)
                throw e
            }
        },
        [call]
    )

    // Refresh all registered contexts
    const refreshAllContexts = useCallback(
        async (): Promise<void> => {
            await Promise.all(contextNames.map(name => refreshContext(name)))
        },
        [contextNames, refreshContext]
    )

    // Subscribe to context changes
    const onContextChange = useCallback(
        (name: string, listener: (data: unknown) => void): (() => void) => {
            const listeners = contextListenersRef.current.get(name) ?? new Set()
            listeners.add(listener)
            contextListenersRef.current.set(name, listeners)

            return () => {
                const nameListeners = contextListenersRef.current.get(name)
                if (nameListeners) {
                    nameListeners.delete(listener)
                    if (nameListeners.size === 0) {
                        contextListenersRef.current.delete(name)
                    }
                }
            }
        },
        []
    )

    // Subscribe to push messages
    const onPush = useCallback(
        <T = unknown>(topic: string, listener: PushListener<T>): (() => void) => {
            const listeners = pushListenersRef.current.get(topic) ?? new Set()
            listeners.add(listener as PushListener)
            pushListenersRef.current.set(topic, listeners)

            return () => {
                const topicListeners = pushListenersRef.current.get(topic)
                if (topicListeners) {
                    topicListeners.delete(listener as PushListener)
                    if (topicListeners.size === 0) {
                        pushListenersRef.current.delete(topic)
                    }
                }
            }
        },
        []
    )

    // Connect on mount and listen for push messages
    useEffect(() => {
        const unsubscribeStatus = connection.onStatusChange((newStatus) => {
            setStatus(newStatus as ConnectionStatus)
        })

        // Listen for all messages (including push)
        const unsubscribeMessages = connection.onMessage((payload) => {
            if (payload && typeof payload === 'object' && 'type' in payload && payload.type === 'push') {
                const topic = (payload as { topic?: string }).topic
                const data = (payload as { data?: unknown }).data

                if (topic) {
                    const listeners = pushListenersRef.current.get(topic)
                    if (listeners) {
                        const message: PushMessage = { topic, data }
                        listeners.forEach(listener => {
                            try {
                                listener(message)
                            } catch (e) {
                                console.error('[mizan] Push listener error:', e)
                            }
                        })
                    }
                }
            }
        })

        if (autoConnect) {
            connection.connect()
        }

        return () => {
            unsubscribeStatus()
            unsubscribeMessages()
        }
    }, [connection, autoConnect])

    // Session init for CSR (fallback if proxy didn't run)
    useEffect(() => {
        if (hasJWT || getCSRFToken()) {
            setSessionReady(true)
            sessionRef.current?.resolve()
            return
        }
        fetch(`${baseUrl}/session/`, { credentials: 'include' })
            .catch(e => console.error('[MizanProvider] Session init failed:', e))
            .finally(() => {
                setSessionReady(true)
                sessionRef.current?.resolve()
            })
    }, [hasJWT, baseUrl])

    // Auto-fetch contexts that weren't hydrated
    useEffect(() => {
        if (!sessionReady) return
        if (!hydration) {
            refreshAllContexts()
        } else {
            const missing = contextNames.filter(name => !(name in hydration))
            if (missing.length > 0) {
                Promise.all(missing.map(name => refreshContext(name)))
            }
        }
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [sessionReady])

    const isRPCAvailable = status === 'connected'

    // Named context provider registry for invalidation
    const contextProvidersRef = useRef<Map<string, { refetch: () => Promise<void> }>>(new Map())

    const registerContextProvider = useCallback(
        (name: string, refetch: () => Promise<void>): (() => void) => {
            contextProvidersRef.current.set(name, { refetch })
            return () => {
                contextProvidersRef.current.delete(name)
            }
        },
        []
    )

    const invalidateContext = useCallback(
        async (name: string): Promise<void> => {
            const provider = contextProvidersRef.current.get(name)
            if (provider) {
                await provider.refetch()
            }
            // If not mounted, no-op — no wasted request
        },
        []
    )

    const invalidateFunctions = useCallback(
        async (names: string[]): Promise<void> => {
            // Function names are passed directly as context invalidation targets.
            // The server already resolved function → context mapping.
            // Dedupe and invalidate each.
            const contexts = new Set(names)
            await Promise.all(
                Array.from(contexts).map(ctx => invalidateContext(ctx))
            )
        },
        [invalidateContext]
    )

    // Set context data directly (used by generated providers that fetch bundles)
    const setContextData = useCallback(
        (name: string, data: unknown) => {
            setContextStore(prev => {
                const next = { ...prev, [name]: data }
                const listeners = contextListenersRef.current.get(name)
                if (listeners) {
                    listeners.forEach(listener => {
                        try {
                            listener(data)
                        } catch (e) {
                            console.error(`[mizan] Context listener error for '${name}':`, e)
                        }
                    })
                }
                return next
            })
        },
        []
    )

    // Auth-transparent HTTP request (used by generated context providers)
    const request = useCallback(
        async (method: string, path: string, data?: unknown): Promise<Response> => {
            await sessionRef.current!.promise
            return httpClient.request(method, path, data)
        },
        [httpClient]
    )

    const value = useMemo<MizanContextValue>(
        () => ({
            call,
            getContext,
            refreshContext,
            refreshAllContexts,
            status,
            isRPCAvailable,
            onPush,
            onContextChange,
            whenReady: sessionRef.current!.promise,
            invalidateContext,
            invalidateFunctions,
            registerContextProvider,
            baseUrl,
            setContextData,
            request,
        }),
        [call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange, invalidateContext, invalidateFunctions, registerContextProvider, baseUrl, setContextData, request]
    )

    return (
        <MizanContextInternal value={value}>
            {children}
        </MizanContextInternal>
    )
}

// ============================================================================
// Hooks
// ============================================================================

/**
 * Access the mizan context.
 *
 * Provides generic name-based API for server functions and contexts.
 * Libraries should use this hook, not the typed generated hooks.
 *
 * @example
 * ```tsx
 * // Library code (e.g., Allauth)
 * import { useMizan } from 'mizan'
 *
 * function useUser() {
 *     const { getContext } = useMizan()
 *     return getContext('current_user')
 * }
 * ```
 */
export function useMizan(): MizanContextValue {
    const context = useReactContext(MizanContextInternal)
    if (!context) {
        throw new Error('useMizan must be used within a MizanProvider')
    }
    return context
}

/**
 * Get cached context data by name.
 *
 * For use by libraries that need to access context data without knowing types.
 *
 * @example
 * ```tsx
 * // In Allauth library
 * function useUser() {
 *     return useMizanContext('current_user')
 * }
 * ```
 */
export function useMizanContext<T = unknown>(name: string): T | undefined {
    const { getContext } = useMizan()
    return getContext<T>(name)
}

/**
 * Get a function caller by name with transport control.
 *
 * For use by libraries that need to call functions without knowing types.
 * The transport parameter is baked into the returned function.
 *
 * @param functionName - The server function name
 * @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
 *
 * @example
 * ```tsx
 * // HTTP-only function (default)
 * function useUpdateProfile() {
 *     return useMizanCall('update_profile')
 * }
 *
 * // WebSocket-enabled function
 * function useSendMessage() {
 *     return useMizanCall('send_message', 'websocket')
 * }
 * ```
 */
export function useMizanCall<TInput = unknown, TOutput = unknown>(
    functionName: string,
    transport: Transport = 'http'
): (input?: TInput) => Promise<TOutput> {
    const { call } = useMizan()
    return useCallback(
        (input?: TInput) => call<TInput, TOutput>(functionName, input, transport),
        [call, functionName, transport]
    )
}

/**
 * Get the current WebSocket connection status.
 */
export function useMizanStatus(): ConnectionStatus {
    const { status } = useMizan()
    return status
}

/**
 * Subscribe to push messages for a topic.
 * Automatically unsubscribes when the component unmounts.
 */
export function usePush<T = unknown>(
    topic: string,
    callback: PushListener<T>
): void {
    const { onPush } = useMizan()
    const callbackRef = useRef(callback)

    useEffect(() => {
        callbackRef.current = callback
    }, [callback])

    useEffect(() => {
        const listener: PushListener<T> = (message) => {
            callbackRef.current(message)
        }

        return onPush(topic, listener)
    }, [topic, onPush])
}

// ============================================================================
// Legacy Aliases (for backwards compatibility during migration)
// ============================================================================

/** @deprecated Use MizanProvider instead */
export const DjangoContext = MizanProvider

/** @deprecated Use useMizan instead */
export const useDjango = useMizan

/** @deprecated Use useMizanStatus instead */
export const useDjangoStatus = useMizanStatus

/** @deprecated Use useMizanCall instead */
export function useServerFunction<TInput = unknown, TOutput = unknown>(
    functionName: string
): (input: TInput) => Promise<TOutput> {
    const { call } = useMizan()
    return useCallback(
        (input: TInput) => call<TInput, TOutput>(functionName, input),
        [call, functionName]
    )
}

// Re-export types for the legacy API
export type DjangoContextValue = MizanContextValue
export type DjangoContextProps = MizanProviderProps
