/**
 * Tests for energy/hooks.ts — Modbus API wrappers.
 *
 * Coverage:
 * 1. useDevices — calls GET /api/modbus/devices and returns data.
 * 2. useProfiles — calls GET /api/modbus/profiles.
 * 3. useCreateDevice — calls POST /api/modbus/devices with body.
 * 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body.
 * 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}.
 * 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required).
 */

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'

// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------

const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()

vi.mock('../api/client', () => ({
  default: {
    GET: (...args: unknown[]) => mockGet(...args),
    POST: (...args: unknown[]) => mockPost(...args),
    PATCH: (...args: unknown[]) => mockPatch(...args),
    DELETE: (...args: unknown[]) => mockDelete(...args),
  },
  ApiError: class ApiError extends Error {
    status: number
    body: unknown
    constructor(status: number, body: unknown) {
      super(`API error ${status}`)
      this.name = 'ApiError'
      this.status = status
      this.body = body
    }
  },
  registerLoginRedirect: vi.fn(),
}))

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const DEVICE = {
  uuid: 'test-uuid-1',
  friendly_name: 'SDM120 Main',
  transport: 'tcp',
  host: '192.168.1.100',
  port: 502,
  unit_id: 1,
  profile: 'sdm120',
  poll_interval_s: 5,
  enabled: true,
  last_poll_at: null,
  last_poll_ok: null,
  created_at: '2026-06-01T00:00:00Z',
  updated_at: '2026-06-01T00:00:00Z',
}

// ---------------------------------------------------------------------------
// Wrapper
// ---------------------------------------------------------------------------

function makeWrapper() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
  function Wrapper({ children }: { children: ReactNode }) {
    return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
  }
  return { qc, Wrapper }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('useDevices', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls GET /api/modbus/devices and returns device list', async () => {
    mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } })

    const { Wrapper } = makeWrapper()
    const { useDevices } = await import('./hooks')
    const { result } = renderHook(() => useDevices(), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices')
    expect(result.current.data?.items).toHaveLength(1)
    expect(result.current.data?.items[0].uuid).toBe('test-uuid-1')
  })
})

describe('useProfiles', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls GET /api/modbus/profiles and returns profiles', async () => {
    mockGet.mockResolvedValue({
      data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] },
    })

    const { Wrapper } = makeWrapper()
    const { useProfiles } = await import('./hooks')
    const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
    expect(result.current.data?.profiles[0].name).toBe('sdm120')
  })
})

describe('useCreateDevice', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls POST /api/modbus/devices with the provided body', async () => {
    mockPost.mockResolvedValue({ data: DEVICE })
    // LIST query to satisfy invalidation
    mockGet.mockResolvedValue({ data: { items: [], total: 0 } })

    const { Wrapper } = makeWrapper()
    const { useCreateDevice } = await import('./hooks')
    const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper })

    const body = {
      friendly_name: 'SDM120 Main',
      transport: 'tcp',
      host: '192.168.1.100',
      port: 502,
      unit_id: 1,
      profile: 'sdm120',
      poll_interval_s: 5,
      enabled: true,
    }

    await act(async () => {
      await result.current.mutateAsync(body)
    })

    expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body })
  })
})

describe('useUpdateDevice', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => {
    mockPatch.mockResolvedValue({ data: DEVICE })
    mockGet.mockResolvedValue({ data: { items: [], total: 0 } })

    const { Wrapper } = makeWrapper()
    const { useUpdateDevice } = await import('./hooks')
    const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper })

    await act(async () => {
      await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } })
    })

    expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
      params: { path: { uuid: 'test-uuid-1' } },
      body: { enabled: false },
    })
  })
})

describe('useDeleteDevice', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls DELETE /api/modbus/devices/{uuid} without query param when cascade omitted', async () => {
    mockDelete.mockResolvedValue({ data: null })
    mockGet.mockResolvedValue({ data: { items: [], total: 0 } })

    const { Wrapper } = makeWrapper()
    const { useDeleteDevice } = await import('./hooks')
    const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })

    await act(async () => {
      await result.current.mutateAsync({ uuid: 'test-uuid-1' })
    })

    expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
      params: { path: { uuid: 'test-uuid-1' } },
    })
  })

  it('calls DELETE /api/modbus/devices/{uuid} with cascade=true in query when cascade=true', async () => {
    mockDelete.mockResolvedValue({
      data: { deleted: true, readings_deleted: 5, toggles_deleted: 2 },
    })
    mockGet.mockResolvedValue({ data: { items: [], total: 0 } })

    const { Wrapper } = makeWrapper()
    const { useDeleteDevice } = await import('./hooks')
    const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })

    await act(async () => {
      await result.current.mutateAsync({ uuid: 'test-uuid-1', cascade: true })
    })

    expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
      params: { path: { uuid: 'test-uuid-1' }, query: { cascade: true } },
    })
  })
})

describe('useTestReadDevice', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => {
    mockPost.mockResolvedValue({
      data: { ok: true, payload: { voltage: 230.2 } },
    })

    const { Wrapper } = makeWrapper()
    const { useTestReadDevice } = await import('./hooks')
    const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper })

    await act(async () => {
      await result.current.mutateAsync('test-uuid-1')
    })

    expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', {
      params: { path: { uuid: 'test-uuid-1' } },
    })
  })
})

describe('useLatestReading', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls GET /api/modbus/devices/{uuid}/latest with path param', async () => {
    mockGet.mockResolvedValue({
      data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
    })

    const { Wrapper } = makeWrapper()
    const { useLatestReading } = await import('./hooks')
    const { result } = renderHook(() => useLatestReading('test-uuid-1'), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/latest', {
      params: { path: { uuid: 'test-uuid-1' } },
    })
    expect(result.current.data?.found).toBe(true)
    expect(result.current.data?.payload).toEqual({ voltage: 230.2 })
  })

  it('returns found=false when device has no readings', async () => {
    mockGet.mockResolvedValue({
      data: { found: false, recorded_at: null, payload: null },
    })

    const { Wrapper } = makeWrapper()
    const { useLatestReading } = await import('./hooks')
    const { result } = renderHook(() => useLatestReading('test-uuid-2'), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    expect(result.current.data?.found).toBe(false)
    expect(result.current.data?.payload).toBeNull()
  })
})

describe('useMetrics', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls GET /api/modbus/devices/{uuid}/metrics with path param', async () => {
    mockGet.mockResolvedValue({
      data: {
        profile: 'sdm120',
        metrics: [{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' }],
      },
    })

    const { Wrapper } = makeWrapper()
    const { useMetrics } = await import('./hooks')
    const { result } = renderHook(() => useMetrics('test-uuid-1'), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/metrics', {
      params: { path: { uuid: 'test-uuid-1' } },
    })
    expect(result.current.data?.metrics).toHaveLength(1)
    expect(result.current.data?.metrics[0].key).toBe('voltage')
  })
})

describe('useLatestReading — auto-refresh option', () => {
  beforeEach(() => vi.clearAllMocks())

  it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
    mockGet.mockResolvedValue({
      data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } },
    })

    const { Wrapper } = makeWrapper()
    const { useLatestReading } = await import('./hooks')
    const { result } = renderHook(
      () => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    // Query succeeds — refetchIntervalMs is wired through without error.
    expect(result.current.data?.found).toBe(true)
  })

  it('works without options (no auto-refresh)', async () => {
    mockGet.mockResolvedValue({
      data: { found: false, recorded_at: null, payload: null },
    })

    const { Wrapper } = makeWrapper()
    const { useLatestReading } = await import('./hooks')
    const { result } = renderHook(
      () => useLatestReading('test-uuid-2'),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    expect(result.current.data?.found).toBe(false)
  })
})

describe('useReadings — auto-refresh option', () => {
  beforeEach(() => vi.clearAllMocks())

  it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => {
    mockGet.mockResolvedValue({
      data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
    })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const { result } = renderHook(
      () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    // Query succeeds — refetchIntervalMs is wired through without error.
    expect(result.current.data?.items).toHaveLength(1)
  })

  it('enforces minimum refetchInterval of 2000ms', async () => {
    mockGet.mockResolvedValue({ data: { items: [] } })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    // Pass a very small value — should be clamped to 2000ms internally.
    const { result } = renderHook(
      () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))
    // No crash; the hook accepted the option and clamped it internally.
    expect(result.current.isSuccess).toBe(true)
  })
})

describe('useReadings', () => {
  beforeEach(() => vi.clearAllMocks())

  it('calls GET /api/modbus/devices/{uuid}/readings with window and limit', async () => {
    mockGet.mockResolvedValue({
      data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] },
    })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const params = { start: '2026-06-22T09:00:00Z', end: '2026-06-22T10:00:00Z', limit: 500 }
    const { result } = renderHook(() => useReadings('test-uuid-1', params), { wrapper: Wrapper })

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings', {
      params: {
        path: { uuid: 'test-uuid-1' },
        query: {
          start: '2026-06-22T09:00:00Z',
          end: '2026-06-22T10:00:00Z',
          limit: 500,
        },
      },
    })
    expect(result.current.data?.items).toHaveLength(1)
  })

  it('caps limit at READINGS_MAX_LIMIT (1000) even if caller requests more', async () => {
    mockGet.mockResolvedValue({ data: { items: [] } })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const { result } = renderHook(
      () => useReadings('test-uuid-1', { limit: 99999 }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    // The query param limit should be capped at 1000.
    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
      expect.objectContaining({
        params: expect.objectContaining({
          query: expect.objectContaining({ limit: 1000 }),
        }),
      }),
    )
  })

  it('omits start/end from query params when not provided', async () => {
    mockGet.mockResolvedValue({ data: { items: [] } })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const { result } = renderHook(
      () => useReadings('test-uuid-1', {}),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/readings',
      expect.objectContaining({
        params: expect.objectContaining({
          query: expect.not.objectContaining({ start: expect.anything() }),
        }),
      }),
    )
  })

  // -------------------------------------------------------------------------
  // Rolling window: spanMs causes queryFn to compute end=now() on each call
  // -------------------------------------------------------------------------

  it('when spanMs is provided, sends computed start/end (rolling window) instead of fixed params', async () => {
    const tBefore = Date.now()
    mockGet.mockResolvedValue({ data: { items: [] } })

    const { Wrapper } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const SPAN_MS = 60 * 60 * 1000 // 1 hour
    const { result } = renderHook(
      () => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    const tAfter = Date.now()

    // The GET should have been called with start and end computed from now().
    expect(mockGet).toHaveBeenCalledWith(
      '/api/modbus/devices/{uuid}/readings',
      expect.objectContaining({
        params: expect.objectContaining({
          query: expect.objectContaining({
            end: expect.any(String),
            start: expect.any(String),
          }),
        }),
      }),
    )

    const call = mockGet.mock.calls[mockGet.mock.calls.length - 1]
    const query = call[1].params.query as { start: string; end: string }
    const endMs = new Date(query.end).getTime()
    const startMs = new Date(query.start).getTime()

    // end must be within the test window (tBefore..tAfter).
    expect(endMs).toBeGreaterThanOrEqual(tBefore)
    expect(endMs).toBeLessThanOrEqual(tAfter + 100) // small tolerance

    // start must be approximately end - spanMs.
    expect(endMs - startMs).toBeCloseTo(SPAN_MS, -2) // within 100ms
  })

  it('when spanMs is provided, queryKey uses spanMs (not absolute timestamps) for stable caching', async () => {
    mockGet.mockResolvedValue({ data: { items: [] } })

    const { Wrapper, qc } = makeWrapper()
    const { useReadings } = await import('./hooks')
    const SPAN_MS = 3600000 // 1 hour

    const { result } = renderHook(
      () => useReadings('test-uuid-1', { spanMs: SPAN_MS }),
      { wrapper: Wrapper },
    )

    await waitFor(() => expect(result.current.isSuccess).toBe(true))

    // The query cache should have a key containing spanMs, not an absolute timestamp.
    const queries = qc.getQueryCache().findAll({
      predicate: (q) => {
        const key = q.queryKey as unknown[]
        return key[0] === 'modbus-readings' && key[1] === 'test-uuid-1'
      },
    })
    expect(queries).toHaveLength(1)
    const keyParams = queries[0].queryKey[2] as Record<string, unknown>
    expect(keyParams).toHaveProperty('spanMs', SPAN_MS)
    expect(keyParams).not.toHaveProperty('start')
    expect(keyParams).not.toHaveProperty('end')
  })
})
