import {
  describe,
  test,
  expect,
  vi,
  beforeEach,
  afterEach,
  type MockInstance,
} from 'vitest'
import { createRef } from 'react'
import { act, render, screen } from '@testing-library/react'
import { ChatContent } from './chat-content'
import type { ChatContentRef } from '../types'

type IOCallback = (entries: IntersectionObserverEntry[]) => void

interface FakeObserver {
  callback: IOCallback
  observed: Element[]
  disconnected: boolean
}

interface FakeMutationObserverInstance {
  callback: MutationCallback
  target: Node | null
  disconnected: boolean
}

let observers: FakeObserver[] = []
let mutationObservers: FakeMutationObserverInstance[] = []
let scrollToSpy: MockInstance<Element['scrollTo']>

// Mutable "DOM measurements" the prototype getters read from.
let scrollHeightValue = 0
let scrollTopValue = 0
let clientHeightValue = 0

class FakeIntersectionObserver {
  _obs: FakeObserver
  constructor(cb: IOCallback) {
    this._obs = { callback: cb, observed: [], disconnected: false }
    observers.push(this._obs)
  }
  observe(el: Element) {
    this._obs.observed.push(el)
  }
  unobserve() {
    // NOOP
  }
  disconnect() {
    this._obs.disconnected = true
  }
  takeRecords(): IntersectionObserverEntry[] {
    return []
  }
}

class FakeMutationObserver {
  _inst: FakeMutationObserverInstance
  constructor(cb: MutationCallback) {
    this._inst = { callback: cb, target: null, disconnected: false }
    mutationObservers.push(this._inst)
  }
  observe(target: Node) {
    this._inst.target = target
  }
  disconnect() {
    this._inst.disconnected = true
  }
  takeRecords(): MutationRecord[] {
    return []
  }
}

/**
 * The component creates the top observer first, then the bottom one — so
 * `observers[0]` is top and `observers[1]` is bottom. Helper trips one of them.
 */
function fireIntersection(index: 0 | 1, isIntersecting: boolean) {
  const obs = observers[index]
  if (!obs) throw new Error(`No observer at index ${index}`)
  act(() => {
    obs.callback([
      { isIntersecting, target: obs.observed[0]! } as IntersectionObserverEntry,
    ])
  })
}

/** Triggers the most recent MutationObserver as if a DOM mutation happened. */
function fireMutation() {
  const obs = mutationObservers.at(-1)
  if (!obs) throw new Error('No MutationObserver registered')
  act(() => {
    obs.callback([], obs as unknown as MutationObserver)
  })
}

describe('ChatContent', () => {
  beforeEach(() => {
    observers = []
    mutationObservers = []
    scrollHeightValue = 0
    scrollTopValue = 0
    clientHeightValue = 0
    vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
    vi.stubGlobal('MutationObserver', FakeMutationObserver)
    // Run rAF synchronously so MutationObserver-driven scroll checks happen
    // inside `act`.
    vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
      cb(0)
      return 1
    })
    vi.stubGlobal('cancelAnimationFrame', () => {
      // NOOP
    })
    scrollToSpy = vi
      .spyOn(Element.prototype, 'scrollTo')
      .mockImplementation(() => {
        // NOOP
      })
    vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
      () => scrollHeightValue,
    )
    vi.spyOn(HTMLElement.prototype, 'scrollTop', 'get').mockImplementation(
      () => scrollTopValue,
    )
    vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(
      () => clientHeightValue,
    )
  })

  afterEach(() => {
    vi.unstubAllGlobals()
    vi.restoreAllMocks()
  })

  test('renders children', () => {
    render(<ChatContent>Scrollable content</ChatContent>)
    expect(screen.getByText('Scrollable content')).toBeTruthy()
  })

  test('observes the top and bottom sentinels', () => {
    render(<ChatContent>content</ChatContent>)
    expect(observers).toHaveLength(2)
    expect(observers[0]?.observed).toHaveLength(1)
    expect(observers[1]?.observed).toHaveLength(1)
  })

  test('ref starts with isAtTop and isAtBottom both true', () => {
    const ref = createRef<ChatContentRef>()
    render(<ChatContent ref={ref}>content</ChatContent>)
    expect(ref.current?.isAtTop).toBe(true)
    expect(ref.current?.isAtBottom).toBe(true)
  })

  test('isAtBottom flips when the bottom sentinel leaves the viewport', () => {
    const ref = createRef<ChatContentRef>()
    render(<ChatContent ref={ref}>content</ChatContent>)
    fireIntersection(1, false)
    expect(ref.current?.isAtBottom).toBe(false)
    fireIntersection(1, true)
    expect(ref.current?.isAtBottom).toBe(true)
  })

  test('isAtTop flips when the top sentinel leaves the viewport', () => {
    const ref = createRef<ChatContentRef>()
    render(<ChatContent ref={ref}>content</ChatContent>)
    fireIntersection(0, false)
    expect(ref.current?.isAtTop).toBe(false)
    fireIntersection(0, true)
    expect(ref.current?.isAtTop).toBe(true)
  })

  test('scrollToBottom() scrolls to scrollHeight with smooth behaviour', () => {
    scrollHeightValue = 1000
    const ref = createRef<ChatContentRef>()
    render(<ChatContent ref={ref}>content</ChatContent>)
    act(() => ref.current?.scrollToBottom())
    expect(scrollToSpy).toHaveBeenCalledWith({
      top: 1000,
      behavior: 'smooth',
    })
  })

  test('scrollToTop() scrolls the container to top 0', () => {
    const ref = createRef<ChatContentRef>()
    render(<ChatContent ref={ref}>content</ChatContent>)
    act(() => ref.current?.scrollToTop())
    expect(scrollToSpy).toHaveBeenCalledWith({
      top: 0,
      behavior: 'smooth',
    })
  })

  test('clicking the jump-to-latest FAB scrolls to the bottom', () => {
    render(<ChatContent>content</ChatContent>)
    fireIntersection(1, false) // not at bottom anymore
    const fab = screen.getByLabelText('Jump to latest')
    act(() => fab.click())
    expect(scrollToSpy).toHaveBeenCalled()
  })

  test('uses custom jumpToLatest label', () => {
    render(
      <ChatContent labels={{ jumpToLatest: 'Ir al final' }}>x</ChatContent>,
    )
    expect(screen.getByLabelText('Ir al final')).toBeTruthy()
  })

  test('disconnects observers on unmount', () => {
    const { unmount } = render(<ChatContent>content</ChatContent>)
    unmount()
    expect(observers[0]?.disconnected).toBe(true)
    expect(observers[1]?.disconnected).toBe(true)
  })

  describe('autoScroll', () => {
    test('attaches a MutationObserver by default', () => {
      render(<ChatContent>content</ChatContent>)
      expect(mutationObservers).toHaveLength(1)
    })

    test('does not attach a MutationObserver when autoScroll={false}', () => {
      render(<ChatContent autoScroll={false}>content</ChatContent>)
      expect(mutationObservers).toHaveLength(0)
    })

    test('scrolls to the new bottom when content grows and the user is at the bottom', () => {
      // At-bottom: scrollHeight - scrollTop - clientHeight = 0
      scrollHeightValue = 1000
      clientHeightValue = 500
      scrollTopValue = 500

      render(<ChatContent autoScroll>content</ChatContent>)
      expect(mutationObservers).toHaveLength(1)

      // Content grows by 200px while the user is still at the bottom.
      scrollHeightValue = 1200
      fireMutation()

      expect(scrollToSpy).toHaveBeenCalledWith({
        top: 1200,
        behavior: 'smooth',
      })
    })

    test('does NOT scroll when the user has scrolled up to read history', () => {
      // 400px from the bottom: 1000 - 100 - 500 = 400 (well past the 32px slack)
      scrollHeightValue = 1000
      clientHeightValue = 500
      scrollTopValue = 100

      render(<ChatContent autoScroll>content</ChatContent>)

      // Content grows by 200px — distance-from-bottom is now 600, growth is
      // 200, so 600 > 200 + 32 → leave the user alone.
      scrollHeightValue = 1200
      fireMutation()

      expect(scrollToSpy).not.toHaveBeenCalled()
    })

    test('disconnects the MutationObserver on unmount', () => {
      const { unmount } = render(<ChatContent autoScroll>content</ChatContent>)
      unmount()
      expect(mutationObservers[0]?.disconnected).toBe(true)
    })
  })
})
