import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import * as ModelAdapterModule from './ModelAdapter'
import type { ModelLoadContext } from './ModelAdapter'
import { SplatModelAdapter } from './SplatModelAdapter'

const splatMeshSpies = {
  ctor: vi.fn<(opts: { fileBytes: ArrayBuffer; fileName?: string }) => void>(),
  dispose: vi.fn(),
  getBoundingBox: vi.fn(
    () =>
      new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1))
  ),
  updateWorldMatrix: vi.fn()
}

vi.mock('@sparkjsdev/spark', async () => {
  const three = await import('three')
  return {
    SplatMesh: class extends three.Object3D {
      initialized = Promise.resolve()
      dispose = splatMeshSpies.dispose
      getBoundingBox = splatMeshSpies.getBoundingBox

      constructor(opts: { fileBytes: ArrayBuffer; fileName?: string }) {
        super()
        splatMeshSpies.ctor(opts)
      }

      override updateWorldMatrix(
        force: boolean,
        updateChildren: boolean
      ): void {
        splatMeshSpies.updateWorldMatrix(force, updateChildren)
        super.updateWorldMatrix(force, updateChildren)
      }
    }
  }
})

function makeContext(): ModelLoadContext {
  return {
    setOriginalModel: vi.fn(),
    registerOriginalMaterial: vi.fn(),
    standardMaterial: new THREE.MeshStandardMaterial(),
    materialMode: 'original'
  }
}

describe('SplatModelAdapter', () => {
  beforeEach(() => {
    splatMeshSpies.ctor.mockClear()
    splatMeshSpies.dispose.mockClear()
    splatMeshSpies.getBoundingBox.mockClear()
    splatMeshSpies.updateWorldMatrix.mockClear()
    vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(
      new ArrayBuffer(8)
    )
  })

  it('exposes splat capabilities on the adapter', () => {
    const adapter = new SplatModelAdapter()
    expect(adapter.kind).toBe('splat')
    expect(adapter.capabilities.lighting).toBe(false)
    expect(adapter.capabilities.exportable).toBe(false)
    expect([...adapter.capabilities.materialModes]).toEqual([])
  })

  it('handles the Gaussian splat extensions', () => {
    const adapter = new SplatModelAdapter()
    expect([...adapter.extensions]).toEqual(['spz', 'splat', 'ksplat', 'ply'])
  })

  it('fetches the file, builds a SplatMesh, and wraps it in a Group', async () => {
    const buf = new ArrayBuffer(128)
    vi.spyOn(ModelAdapterModule, 'fetchModelData').mockResolvedValue(buf)

    const adapter = new SplatModelAdapter()
    const ctx = makeContext()

    const result = await adapter.load(ctx, '/api/view?', 'scene.splat')

    expect(ModelAdapterModule.fetchModelData).toHaveBeenCalledWith(
      '/api/view?',
      'scene.splat'
    )
    expect(splatMeshSpies.ctor).toHaveBeenCalledWith({
      fileBytes: buf,
      fileName: 'scene.splat'
    })
    expect(result!.object).toBeInstanceOf(THREE.Group)
    expect(result!.object.children).toHaveLength(1)
    expect(result!.capabilities.lighting).toBe(false)

    expect(ctx.setOriginalModel).toHaveBeenCalledTimes(1)
    expect(ctx.setOriginalModel).toHaveBeenCalledWith(
      result!.object.children[0]
    )
  })

  it('rotates the splat 180° around X (OpenCV → three.js convention)', async () => {
    const result = await new SplatModelAdapter().load(
      makeContext(),
      '/api/view?',
      'scene.splat'
    )

    const splat = result!.object.children[0]
    expect(splat.quaternion.x).toBe(1)
    expect(splat.quaternion.y).toBe(0)
    expect(splat.quaternion.z).toBe(0)
    expect(splat.quaternion.w).toBe(0)
  })

  it('propagates fetch errors', async () => {
    vi.spyOn(ModelAdapterModule, 'fetchModelData').mockRejectedValue(
      new Error('Failed to fetch model: 500')
    )

    const adapter = new SplatModelAdapter()
    await expect(
      adapter.load(makeContext(), '/api/view?', 'scene.splat')
    ).rejects.toThrow('Failed to fetch model: 500')
  })

  describe('computeBounds', () => {
    it('returns the SplatMesh bounding box transformed to world space', async () => {
      const adapter = new SplatModelAdapter()
      const result = await adapter.load(
        makeContext(),
        '/api/view?',
        'scene.splat'
      )
      const group = result!.object
      const splat = group.children[0]
      splat.position.set(10, 0, 0)

      const bounds = adapter.computeBounds(group)

      expect(bounds).toBeInstanceOf(THREE.Box3)
      expect(splatMeshSpies.getBoundingBox).toHaveBeenCalledWith(false)
      expect(splatMeshSpies.updateWorldMatrix).toHaveBeenCalledWith(true, false)
      // Local bbox was [-1,-1,-1]→[1,1,1]; world matrix translates by +10 X
      // (with the splat's quaternion applied to the inner mesh).
      expect(bounds!.min.x).toBeCloseTo(9)
      expect(bounds!.max.x).toBeCloseTo(11)
    })

    it('returns null when the first child is not a SplatMesh', () => {
      const adapter = new SplatModelAdapter()
      const group = new THREE.Group()
      group.add(new THREE.Mesh())

      expect(adapter.computeBounds(group)).toBeNull()
    })
  })

  describe('disposeModel', () => {
    it('calls dispose on every SplatMesh in the model tree', async () => {
      const adapter = new SplatModelAdapter()
      const result = await adapter.load(
        makeContext(),
        '/api/view?',
        'scene.splat'
      )

      adapter.disposeModel(result!.object)

      expect(splatMeshSpies.dispose).toHaveBeenCalledOnce()
    })

    it('is a no-op when the tree has no SplatMesh', () => {
      const adapter = new SplatModelAdapter()
      const group = new THREE.Group()
      group.add(new THREE.Mesh())

      expect(() => adapter.disposeModel(group)).not.toThrow()
    })
  })

  describe('defaultCameraPose', () => {
    it('returns the (5,5,5) / (0,2.5,0) seat for self-sized splats', () => {
      const adapter = new SplatModelAdapter()
      const pose = adapter.defaultCameraPose()

      expect(pose.size.x).toBe(5)
      expect(pose.size.y).toBe(5)
      expect(pose.size.z).toBe(5)
      expect(pose.center.x).toBe(0)
      expect(pose.center.y).toBe(2.5)
      expect(pose.center.z).toBe(0)
    })
  })
})
