import { StreamChatService } from '@linktr.ee/messaging-core'
import type {
  Logger,
  MessagingUser,
  StreamChatServiceConfig,
} from '@linktr.ee/messaging-core'
import { render, waitFor, act } from '@testing-library/react'
import React from 'react'
import type { StreamChat } from 'stream-chat'
import { describe, it, expect, vi, beforeEach } from 'vitest'

import { useMessagingLogger } from '../logging'

import { MessagingProvider, useMessagingContext } from './MessagingProvider'

// Stub stream-chat-react's <Chat /> so we don't need a real, fully-wired
// StreamChat instance just to verify provider state.
vi.mock('stream-chat-react', () => ({
  Chat: (props: { children: React.ReactNode }) => <>{props.children}</>,
}))

// The provider constructs the Chat client via `new StreamChat(apiKey)` and
// injects it into the service. Stub the constructor so <Chat> sees an instance.
vi.mock('stream-chat', () => ({
  StreamChat: vi.fn().mockImplementation(function () {
    return { userID: 'mock' }
  }),
}))

const setupServiceMock = () => {
  const connectUser = vi.fn(
    async () => ({ userID: 'mock' }) as unknown as StreamChat
  )
  const disconnectUser = vi.fn(async () => undefined)

  vi.spyOn(StreamChatService.prototype, 'connectUser').mockImplementation(
    connectUser
  )
  vi.spyOn(StreamChatService.prototype, 'disconnectUser').mockImplementation(
    disconnectUser
  )

  return { connectUser, disconnectUser }
}

const Probe: React.FC<{ onState: (state: unknown) => void }> = ({
  onState,
}) => {
  const ctx = useMessagingContext()
  React.useEffect(() => {
    onState({
      isConnected: ctx.isConnected,
      isLoading: ctx.isLoading,
      error: ctx.error,
      hasClient: !!ctx.client,
    })
  }, [ctx, onState])
  return null
}

describe('MessagingProvider', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
  })

  it('connects a guest user through the underlying service', async () => {
    const { connectUser } = setupServiceMock()
    const states: unknown[] = []
    const guestUser = {
      type: 'guest' as const,
      id: 'guest-1',
      name: 'Guest',
    } as MessagingUser

    render(
      <MessagingProvider
        apiKey="mock-api-key"
        user={guestUser}
        serviceConfig={{
          fetchToken: async () => 'token',
          createChannel: async () => ({ channelId: 'ch-1' }),
        }}
      >
        <Probe onState={(s) => states.push(s)} />
      </MessagingProvider>
    )

    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))
    expect(connectUser).toHaveBeenCalledWith(guestUser)

    await waitFor(() => {
      const last = states[states.length - 1] as { isConnected: boolean }
      expect(last.isConnected).toBe(true)
    })
  })

  it('mounts children once across the connect transition (no subtree remount)', async () => {
    setupServiceMock()

    let mountCount = 0
    const MountCounter: React.FC = () => {
      React.useEffect(() => {
        mountCount += 1
      }, [])
      return null
    }

    const states: unknown[] = []

    render(
      <MessagingProvider
        apiKey="mock-api-key"
        user={{ type: 'guest', id: 'guest-1', name: 'Guest' } as MessagingUser}
        serviceConfig={{
          fetchToken: async () => 'token',
          createChannel: async () => ({ channelId: 'ch-1' }),
        }}
      >
        <MountCounter />
        <Probe onState={(s) => states.push(s)} />
      </MessagingProvider>
    )

    // Wait until the connection has resolved — this is the point where the old
    // implementation swapped `children` into <Chat> and remounted the subtree.
    await waitFor(() => {
      const last = states[states.length - 1] as { isConnected: boolean }
      expect(last.isConnected).toBe(true)
    })

    expect(mountCount).toBe(1)
  })

  it('does not disconnect the reused client when only serviceConfig changes', async () => {
    const { connectUser, disconnectUser } = setupServiceMock()
    const user = {
      type: 'guest' as const,
      id: 'guest-1',
      name: 'Guest',
    } as MessagingUser

    const { rerender } = render(
      <MessagingProvider
        apiKey="mock-api-key"
        user={user}
        serviceConfig={{
          fetchToken: async () => 'token',
          createChannel: async () => ({ channelId: 'ch-1' }),
        }}
      >
        <div />
      </MessagingProvider>
    )

    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))

    // Re-render with a NEW inline serviceConfig object (same apiKey). The
    // service is created once per client lifetime, so a serviceConfig change
    // neither recreates the service nor tears down the live connection.
    await act(async () => {
      rerender(
        <MessagingProvider
          apiKey="mock-api-key"
          user={user}
          serviceConfig={{
            fetchToken: async () => 'token',
            createChannel: async () => ({ channelId: 'ch-2' }),
          }}
        >
          <div />
        </MessagingProvider>
      )
    })

    expect(disconnectUser).not.toHaveBeenCalled()
  })

  it('does not disconnect the reused client when only the debug prop toggles', async () => {
    const { connectUser, disconnectUser } = setupServiceMock()
    const user = {
      type: 'guest' as const,
      id: 'guest-1',
      name: 'Guest',
    } as MessagingUser
    const serviceConfig = {
      fetchToken: async () => 'token',
      createChannel: async () => ({ channelId: 'ch-1' }),
    }

    const { rerender } = render(
      <MessagingProvider
        apiKey="mock-api-key"
        user={user}
        serviceConfig={serviceConfig}
        debug={false}
      >
        <div />
      </MessagingProvider>
    )

    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))

    // Toggling `debug` changes the internal debugLog identity. The client
    // cleanup must stay keyed on the client's lifetime, not on debugLog, so the
    // live connection is not torn down for a logging change.
    await act(async () => {
      rerender(
        <MessagingProvider
          apiKey="mock-api-key"
          user={user}
          serviceConfig={serviceConfig}
          debug={true}
        >
          <div />
        </MessagingProvider>
      )
    })

    expect(disconnectUser).not.toHaveBeenCalled()
  })

  it('disconnects and reconnects when remounted with a new key', async () => {
    const { connectUser, disconnectUser } = setupServiceMock()

    const guestUser = {
      type: 'guest' as const,
      id: 'guest-1',
      name: 'Guest',
    } as MessagingUser
    const authedUser = { id: 'user-1', name: 'Authed User' }

    const { rerender } = render(
      <MessagingProvider
        key="guest"
        apiKey="mock-api-key"
        user={guestUser}
        serviceConfig={{
          fetchToken: async () => 'token',
          createChannel: async () => ({ channelId: 'ch-1' }),
        }}
      >
        <div />
      </MessagingProvider>
    )

    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))
    expect(connectUser).toHaveBeenLastCalledWith(guestUser)

    await act(async () => {
      rerender(
        <MessagingProvider
          key="authed"
          apiKey="mock-api-key"
          user={authedUser}
          serviceConfig={{
            fetchToken: async () => 'token',
            createChannel: async () => ({ channelId: 'ch-1' }),
          }}
        >
          <div />
        </MessagingProvider>
      )
    })

    await waitFor(() => expect(disconnectUser).toHaveBeenCalled())
    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2))
    expect(connectUser).toHaveBeenLastCalledWith(authedUser)
  })

  it('uses an injected client directly and never connects a service', async () => {
    const { connectUser, disconnectUser } = setupServiceMock()
    const injectedClient = {
      userID: 'injected-user',
    } as unknown as StreamChat
    const states: unknown[] = []

    render(
      <MessagingProvider client={injectedClient} user={null}>
        <Probe onState={(s) => states.push(s)} />
      </MessagingProvider>
    )

    // Injected client is reported connected immediately, and its instance is
    // surfaced on the context.
    await waitFor(() => {
      const last = states[states.length - 1] as {
        isConnected: boolean
        hasClient: boolean
      }
      expect(last.isConnected).toBe(true)
      expect(last.hasClient).toBe(true)
    })

    // The caller owns the injected client's lifecycle — the provider must not
    // spin up a StreamChatService or connect/disconnect it.
    expect(connectUser).not.toHaveBeenCalled()
    expect(disconnectUser).not.toHaveBeenCalled()
  })

  it('refreshConnection is a no-op in injected-client mode', async () => {
    const { connectUser, disconnectUser } = setupServiceMock()
    const injectedClient = {
      userID: 'injected-user',
    } as unknown as StreamChat

    let refresh: (() => Promise<void>) | undefined
    const Capture: React.FC = () => {
      refresh = useMessagingContext().refreshConnection
      return null
    }

    render(
      <MessagingProvider
        client={injectedClient}
        user={{ id: 'user-1', name: 'User' } as MessagingUser}
      >
        <Capture />
      </MessagingProvider>
    )

    await waitFor(() => expect(refresh).toBeDefined())

    // A consumer calling refresh must not reach any backend — the injected
    // client owns its lifecycle, so refresh does nothing rather than
    // disconnect/reconnect a stale apiKey-path service.
    await act(async () => {
      await refresh?.()
    })

    expect(connectUser).not.toHaveBeenCalled()
    expect(disconnectUser).not.toHaveBeenCalled()
  })

  it('does not reconnect a stale service after switching to an injected client', async () => {
    const { connectUser } = setupServiceMock()

    const { rerender } = render(
      <MessagingProvider
        apiKey="mock-api-key"
        user={{ id: 'user-1', name: 'User One' } as MessagingUser}
        serviceConfig={{
          fetchToken: async () => 'token',
          createChannel: async () => ({ channelId: 'ch-1' }),
        }}
      >
        <Probe onState={() => {}} />
      </MessagingProvider>
    )

    // apiKey path connects user-1 through its service.
    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))

    const injectedClient = {
      userID: 'injected-user',
    } as unknown as StreamChat

    // Switch the same provider to an injected client and change the user. The
    // user-connection effect must not drive the new user onto the stale
    // apiKey-path service.
    rerender(
      <MessagingProvider
        client={injectedClient}
        user={{ id: 'user-2', name: 'User Two' } as MessagingUser}
      >
        <Probe onState={() => {}} />
      </MessagingProvider>
    )

    await act(async () => {
      await Promise.resolve()
    })

    // Still only the single user-1 connect — user-2 never reached the service.
    expect(connectUser).toHaveBeenCalledTimes(1)
  })

  it('drops the apiKey service on injection so returning to apiKey mode reconnects only the fresh service', async () => {
    const { connectUser } = setupServiceMock()
    const apiKeyProps = {
      apiKey: 'mock-api-key',
      serviceConfig: {
        fetchToken: async () => 'token',
        createChannel: async () => ({ channelId: 'ch-1' }),
      },
    }

    const { rerender } = render(
      <MessagingProvider
        {...apiKeyProps}
        user={{ id: 'user-1', name: 'User One' } as MessagingUser}
      >
        <Probe onState={() => {}} />
      </MessagingProvider>
    )
    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1))

    // Enter injected mode: the apiKey-path service must be dropped, not left
    // dangling for the user-connection effect to reconnect later.
    const injectedClient = {
      userID: 'injected-user',
    } as unknown as StreamChat
    rerender(
      <MessagingProvider
        client={injectedClient}
        user={{ id: 'user-1', name: 'User One' } as MessagingUser}
      >
        <Probe onState={() => {}} />
      </MessagingProvider>
    )
    await act(async () => {
      await Promise.resolve()
    })

    // Return to apiKey mode with a new user. Only the freshly created service
    // connects — a lingering stale service would add a third connect here.
    rerender(
      <MessagingProvider
        {...apiKeyProps}
        user={{ id: 'user-2', name: 'User Two' } as MessagingUser}
      >
        <Probe onState={() => {}} />
      </MessagingProvider>
    )
    await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2))
    expect(connectUser).toHaveBeenLastCalledWith(
      expect.objectContaining({ id: 'user-2' })
    )
  })

  it('clears apiKey client/connection state on injection so it cannot leak back to apiKey mode', async () => {
    setupServiceMock()
    const states: Array<{
      isConnected: boolean
      hasClient: boolean
      error: string | null
    }> = []
    const apiKeyProps = {
      apiKey: 'mock-api-key',
      serviceConfig: {
        fetchToken: async () => 'token',
        createChannel: async () => ({ channelId: 'ch-1' }),
      },
    }
    const onState = (s: unknown) =>
      states.push(
        s as { isConnected: boolean; hasClient: boolean; error: string | null }
      )

    const { rerender } = render(
      <MessagingProvider
        {...apiKeyProps}
        user={{ id: 'user-1', name: 'User One' } as MessagingUser}
      >
        <Probe onState={onState} />
      </MessagingProvider>
    )

    // apiKey path connects: client + isConnected become truthy.
    await waitFor(() => {
      const last = states[states.length - 1]
      expect(last.isConnected).toBe(true)
      expect(last.hasClient).toBe(true)
    })

    const injectedClient = {
      userID: 'injected-user',
    } as unknown as StreamChat
    rerender(
      <MessagingProvider
        client={injectedClient}
        user={{ id: 'user-1', name: 'User One' } as MessagingUser}
      >
        <Probe onState={onState} />
      </MessagingProvider>
    )
    await act(async () => {
      await Promise.resolve()
    })

    // Return to apiKey mode with no user, so nothing reconnects. The stale
    // apiKey client/isConnected must have been cleared on injection, not
    // resurface for MessagingShell to queryChannels against.
    rerender(
      <MessagingProvider {...apiKeyProps} user={null}>
        <Probe onState={onState} />
      </MessagingProvider>
    )
    await act(async () => {
      await Promise.resolve()
    })

    const last = states[states.length - 1]
    expect(last.isConnected).toBe(false)
    expect(last.hasClient).toBe(false)
    expect(last.error).toBeNull()
  })

  describe('logger seam', () => {
    const ServiceProbe: React.FC<{
      onService: (service: StreamChatService | null) => void
    }> = ({ onService }) => {
      const { service } = useMessagingContext()
      React.useEffect(() => {
        onService(service)
      }, [service, onService])
      return null
    }

    const LoggerProbe: React.FC<{ onLogger: (logger: Logger) => void }> = ({
      onLogger,
    }) => {
      const logger = useMessagingLogger()
      React.useEffect(() => {
        onLogger(logger)
      }, [logger, onLogger])
      return null
    }

    // The service stores its resolved sink privately; read it to prove which
    // logger the provider handed to the constructor.
    const serviceLogger = (service: StreamChatService) =>
      (service as unknown as { logger: Logger }).logger

    const renderWithLogger = async (
      props: Partial<React.ComponentProps<typeof MessagingProvider>> & {
        serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>
      }
    ) => {
      setupServiceMock()
      const services: (StreamChatService | null)[] = []
      const loggers: Logger[] = []

      render(
        <MessagingProvider
          apiKey="mock-api-key"
          user={
            { type: 'guest', id: 'guest-1', name: 'Guest' } as MessagingUser
          }
          {...props}
        >
          <ServiceProbe onService={(s) => services.push(s)} />
          <LoggerProbe onLogger={(l) => loggers.push(l)} />
        </MessagingProvider>
      )

      await waitFor(() => expect(services.filter(Boolean).length).toBe(1))
      return {
        service: services.filter(Boolean).pop() as StreamChatService,
        logger: loggers[loggers.length - 1],
      }
    }

    const baseServiceConfig: Omit<StreamChatServiceConfig, 'apiKey'> = {
      fetchToken: async () => 'token',
      createChannel: async () => ({ channelId: 'ch-1' }),
    }

    it('forwards the logger prop into the service and to the subtree', async () => {
      const sink: Logger = { error: vi.fn() }
      const { service, logger } = await renderWithLogger({
        serviceConfig: baseServiceConfig,
        logger: sink,
      })

      expect(serviceLogger(service).error).toBe(sink.error)

      const error = new Error('boom')
      logger.error?.('[MessagingShell] Failed:', error)
      expect(sink.error).toHaveBeenCalledWith('[MessagingShell] Failed:', error)
    })

    it('resolves a partial prop sink before handing it to the service', async () => {
      const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
      const sink: Logger = { error: vi.fn() }
      const { service } = await renderWithLogger({
        serviceConfig: baseServiceConfig,
        logger: sink,
      })

      // The service swaps its own console defaults out wholesale, so an
      // unresolved partial sink would silence the levels it omits.
      serviceLogger(service).warn?.('[StreamChatService] Slow:', 1)
      expect(consoleWarn).toHaveBeenCalledWith('[StreamChatService] Slow:', 1)
    })

    it('lets an explicit serviceConfig.logger win over the logger prop', async () => {
      const configSink: Logger = { error: vi.fn() }
      const propSink: Logger = { error: vi.fn() }
      const { service } = await renderWithLogger({
        serviceConfig: { ...baseServiceConfig, logger: configSink },
        logger: propSink,
      })

      expect(serviceLogger(service)).toBe(configSink)
    })

    it('leaves the service on its console defaults when no logger is given', async () => {
      const consoleError = vi
        .spyOn(console, 'error')
        .mockImplementation(() => {})
      const { service, logger } = await renderWithLogger({
        serviceConfig: baseServiceConfig,
      })

      // Not the react seam object: the service fell back to its own defaults.
      expect(serviceLogger(service)).not.toBe(logger)

      const error = new Error('boom')
      logger.error?.('[MessagingShell] Failed:', error)
      serviceLogger(service).error?.('[StreamChatService] Failed:', error)
      expect(consoleError.mock.calls).toEqual([
        ['[MessagingShell] Failed:', error],
        ['[StreamChatService] Failed:', error],
      ])
    })
  })
})
