import { afterEach, describe, expect, it, vi } from 'vitest'
import {
  adapterIdForPanel,
  fetchClusterNode,
  fetchHarnessAttempts,
  fetchHarnessConfig,
  fetchHarnessEvents,
  fetchHarnessPlan,
  fetchActivity,
  fetchActivitySourceEntries,
  CollectorHttpError,
  postCollectorAction,
  fetchHookControls,
  fetchCollectorState,
  fetchObservabilityReport,
  ObservabilityReportTimeoutError,
  repairHookControls,
  setHookControl,
} from './collector-client'

const originalFetch = globalThis.fetch

afterEach(() => {
  globalThis.fetch = originalFetch
  vi.useRealTimers()
})

describe('collector client', () => {
  it('uses exact hook-control routes and bodies', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({ controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, issue: null }))
    globalThis.fetch = fetchMock as typeof fetch
    await fetchHookControls(); await setHookControl('background-jobs-blocker', false); await repairHookControls()
    expect(fetchMock.mock.calls.map(([url, init]) => [url, init?.method, init?.body])).toEqual([
      ['/api/collector/config/hooks', undefined, undefined],
      ['/api/collector/config/hooks/background-jobs-blocker', 'POST', JSON.stringify({ enabled: false })],
      ['/api/collector/config/hooks/repair', 'POST', JSON.stringify({ confirm: 'replace-invalid-config' })],
    ])
    expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ cache: 'no-store' })
  })

  it('forwards AbortSignal only for hook-control GET', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } },
      issue: null,
    }))
    globalThis.fetch = fetchMock as typeof fetch
    const controller = new AbortController()
    await fetchHookControls({ signal: controller.signal })
    expect(fetchMock).toHaveBeenCalledWith('/api/collector/config/hooks', {
      cache: 'no-store',
      signal: controller.signal,
    })
  })

  it('stops a silent observability report request at its deadline', async () => {
    vi.useFakeTimers()
    const fetchMock = vi.fn<typeof fetch>((_url, init) => new Promise<Response>((_resolve, reject) => {
      init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true })
    }))
    globalThis.fetch = fetchMock as typeof fetch

    const result = fetchObservabilityReport({
      from: '2026-08-16T00:00:00.000Z',
      to: '2026-08-17T00:00:00.000Z',
      timezone: 'UTC',
    }, undefined, 25)
    const rejected = expect(result).rejects.toEqual(expect.objectContaining({
      name: 'ObservabilityReportTimeoutError',
      timeoutMs: 25,
    }) satisfies Partial<ObservabilityReportTimeoutError>)

    await vi.advanceTimersByTimeAsync(25)
    await rejected
    expect(fetchMock.mock.calls[0]?.[1]?.signal).toHaveProperty('aborted', true)
  })

  it('uses bare fetch(url) for generic collector GET callers', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
      observedAt: '2026-08-08T10:00:00.000Z',
      sources: {},
      node: { name: 'builder-1' },
    }))
    globalThis.fetch = fetchMock as typeof fetch
    await fetchClusterNode('builder-1')
    expect(fetchMock).toHaveBeenCalledOnce()
    expect(fetchMock).toHaveBeenCalledWith('/api/collector/cluster/nodes/builder-1')
  })


  it('prefers server detail for hook-control POST errors', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(
      JSON.stringify({ detail: 'invalid hook controls', message: 'bad request', error: 'generic' }),
      { status: 400 },
    )) as typeof fetch
    await expect(setHookControl('background-jobs-blocker', false)).rejects.toMatchObject({
      message: expect.stringContaining('invalid hook controls'),
    })
    globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(
      JSON.stringify({ message: 'bad request', error: 'generic' }),
      { status: 409 },
    )) as typeof fetch
    await expect(repairHookControls()).rejects.toMatchObject({
      message: expect.stringContaining('bad request'),
    })
    globalThis.fetch = vi.fn<typeof fetch>(async () => new Response(
      JSON.stringify({ error: 'generic only' }),
      { status: 500 },
    )) as typeof fetch
    await expect(setHookControl('background-jobs-blocker', true)).rejects.toMatchObject({
      message: expect.stringContaining('generic only'),
    })
  })

  it('returns observed hook-control state on indeterminate persistence success', async () => {
    const observed = {
      controls: { version: 'hook-controls/v1' as const, hooks: { 'background-jobs-blocker': false } },
      issue: null,
      persistence: 'indeterminate' as const,
      persistenceDetail: 'parent directory fsync failed after commit',
    }
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json(observed, { status: 200 })) as typeof fetch
    await expect(setHookControl('background-jobs-blocker', true)).resolves.toEqual(observed)
  })

  it('rejects malformed hook-control GET payloads at the trust boundary', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json({ controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': 'yes' } }, issue: null }, { status: 200 })) as typeof fetch
    await expect(fetchHookControls()).rejects.toMatchObject({
      message: expect.stringContaining('controls.hooks.background-jobs-blocker must be a boolean'),
    })
  })

  it('rejects null hook-control POST payloads at the trust boundary', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => new Response('null', { status: 200, headers: { 'content-type': 'application/json' } })) as typeof fetch
    await expect(setHookControl('background-jobs-blocker', false)).rejects.toMatchObject({
      message: expect.stringContaining('expected an object with controls and issue'),
    })
  })

  it('rejects hook-control payloads missing controls', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json({ issue: null }, { status: 200 })) as typeof fetch
    await expect(repairHookControls()).rejects.toMatchObject({
      message: expect.stringContaining('missing required controls or issue'),
    })
  })

  it('rejects confirmed hook-control persistence with persistenceDetail', async () => {
    const payload = {
      controls: { version: 'hook-controls/v1' as const, hooks: { 'background-jobs-blocker': true } },
      issue: null,
      persistence: 'confirmed' as const,
      persistenceDetail: 'unexpected detail',
    }
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json(payload, { status: 200 })) as typeof fetch
    await expect(fetchHookControls()).rejects.toMatchObject({
      message: expect.stringContaining('persistenceDetail requires persistence indeterminate'),
    })
  })

  it('rejects omitted hook-control persistence with persistenceDetail', async () => {
    const payload = {
      controls: { version: 'hook-controls/v1' as const, hooks: { 'background-jobs-blocker': true } },
      issue: null,
      persistenceDetail: 'unexpected detail',
    }
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json(payload, { status: 200 })) as typeof fetch
    await expect(setHookControl('background-jobs-blocker', false)).rejects.toMatchObject({
      message: expect.stringContaining('persistenceDetail requires persistence indeterminate'),
    })
  })

  it('rejects indeterminate hook-control persistence without persistenceDetail', async () => {
    const payload = {
      controls: { version: 'hook-controls/v1' as const, hooks: { 'background-jobs-blocker': true } },
      issue: null,
      persistence: 'indeterminate' as const,
    }
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json(payload, { status: 200 })) as typeof fetch
    await expect(repairHookControls()).rejects.toMatchObject({
      message: expect.stringContaining('indeterminate persistence requires non-empty persistenceDetail'),
    })
  })

  it('normalizes null collector state collections at the HTTP trust boundary', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json({ panels: null, adapters: null })) as typeof fetch

    await expect(fetchCollectorState()).resolves.toEqual({ panels: [], adapters: [] })
  })

  it('maps factory panel ids to the factory adapter', () => {
    expect(adapterIdForPanel('factory-runs')).toBe('factory')
  })

  it('uses encoded authoritative harness URLs and preserves opaque event cursors', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({ events: [], capabilities: {} }))
    globalThis.fetch = fetchMock as typeof fetch

    await fetchHarnessEvents('run / one', 'cursor+/=')
    await fetchHarnessConfig('run / one')
    await fetchHarnessPlan('run / one')
    await fetchHarnessAttempts('run / one')

    expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
      '/api/collector/harness/runs/run%20%2F%20one/events?since=cursor%2B%2F%3D',
      '/api/collector/harness/runs/run%20%2F%20one/config',
      '/api/collector/harness/runs/run%20%2F%20one/plan',
      '/api/collector/harness/runs/run%20%2F%20one/attempts',
    ])
  })

  it('follows opaque event cursors and deduplicates IDs', async () => {
    const fetchMock = vi
      .fn<typeof fetch>()
      .mockResolvedValueOnce(Response.json({ events: [{ id: 'a' }], nextSince: 'cursor-1', capabilities: {}, hasMore: true }))
      .mockResolvedValueOnce(Response.json({ events: [{ id: 'a' }, { id: 'b' }], nextSince: 'cursor-2', capabilities: {}, hasMore: false }))
    globalThis.fetch = fetchMock as typeof fetch

    await expect(fetchHarnessEvents('run-1', 'resume')).resolves.toMatchObject({ events: [{ id: 'a' }, { id: 'b' }], nextSince: 'cursor-2' })
    expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
      '/api/collector/harness/runs/run-1/events?since=resume',
      '/api/collector/harness/runs/run-1/events?since=cursor-1',
    ])
  })

  it('rejects a cursor that repeats explicit since without requesting it again', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({ events: [], nextSince: 'resume', capabilities: {}, hasMore: true }))
    globalThis.fetch = fetchMock as typeof fetch

    await expect(fetchHarnessEvents('run-1', 'resume')).rejects.toThrow('collector event pagination repeated an opaque cursor')
    expect(fetchMock).toHaveBeenCalledTimes(1)
  })

  it('fails closed when harness event pagination reaches its page limit', async () => {
    const fetchMock = vi.fn<typeof fetch>((input) => {
      const page = new URL(String(input), 'http://collector.test').searchParams.get('since') ?? '0'
      return Promise.resolve(Response.json({ events: [{ id: page }], nextSince: String(Number(page) + 1), capabilities: {}, hasMore: true }))
    })
    globalThis.fetch = fetchMock as typeof fetch

    await expect(fetchHarnessEvents('run-1')).rejects.toThrow('collector event pagination exceeded 64 pages')
    expect(fetchMock).toHaveBeenCalledTimes(64)
  })

  it('serializes existing action callers without credentials and preserves actionable errors', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ error: 'stale revision' }), { status: 409 }))
    globalThis.fetch = fetchMock as typeof fetch

    await expect(postCollectorAction('harness.config.patch', {
      runId: 'run-1',
      revision: 'r1',
      patch: '{"watchdog":{"idleTimeoutMs":50}}',
    }, 'overdeck-web')).rejects.toMatchObject({ status: 409, message: expect.stringContaining('stale revision') })

    const [url, init] = fetchMock.mock.calls[0]!
    expect(url).toBe('/api/collector/actions/harness.config.patch')
    expect(init).toMatchObject({ method: 'POST', headers: { 'content-type': 'application/json' } })
    expect(init?.body).toBe(JSON.stringify({
      args: { runId: 'run-1', revision: 'r1', patch: '{"watchdog":{"idleTimeoutMs":50}}' },
      requestedBy: 'overdeck-web',
    }))
    expect(JSON.stringify(init)).not.toMatch(/authorization|bearer|token/i)
  })

  it('preserves action HTTP status in a typed error', async () => {
    globalThis.fetch = vi.fn<typeof fetch>(async () => Response.json({ error: 'stale attempt' }, { status: 409 })) as typeof fetch

    await expect(postCollectorAction('harness.task.pause', {
      runId: 'run-1', taskId: 't1', attemptId: 'stale', requestId: 'request-1',
    })).rejects.toBeInstanceOf(CollectorHttpError)
  })

  it('serializes source evidence selection as an exact record query', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({}))
    globalThis.fetch = fetchMock as typeof fetch

    await fetchActivitySourceEntries({ sourceId: 'actions', recordId: 'actions:42', limit: 100 })

    expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/collector/activity/sources/actions/entries?recordId=actions%3A42&limit=100')
  })

  it('serializes activity query params with URLSearchParams and omits absent values', async () => {
    const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
      events: [],
      total: 0,
      truncated: false,
      limit: 25,
      categoryCounts: [],
      sourceCounts: [],
      coverage: [],
      suppressedNotifications: {
        status: 'ok',
        path: '/tmp/activity',
        rows: [],
        sourceCount: 0,
        attemptCount: 0,
        skipped: 0,
      },
    }))
    globalThis.fetch = fetchMock as typeof fetch

    await fetchActivity({
      from: '2026-08-07T10:00:00.000Z',
      to: '2026-08-07T11:00:00.000Z',
      category: 'run',
      severity: 'warn',
      limit: 25,
      q: 'deploy plan',
    })
    await fetchActivity({ limit: 5, q: '' })

    expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
      '/api/collector/activity?from=2026-08-07T10%3A00%3A00.000Z&to=2026-08-07T11%3A00%3A00.000Z&category=run&severity=warn&limit=25&q=deploy+plan',
      '/api/collector/activity?limit=5',
    ])
  })
})
