/**
 * test/statusline.test.ts — N-statusline-script tests (B2)
 *
 * Tests the pure exported functions from scripts/statusline.mjs:
 *   - parseRaw: parse raw Claude Code statusLine JSON
 *   - buildSummary: format one-line status string
 *   - postToServer: POST to /hook/status (mocked fetch)
 *
 * readStdin is not tested here (it wraps process.stdin which blocks in tests).
 * Integration-level acceptance (actual POST to /hook/status) is covered by V-integration.
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// vitest/esbuild handles .mjs imports; tsc does not check test/ files
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { parseRaw, buildSummary, postToServer } from '../scripts/statusline.mjs'

// ── parseRaw ──────────────────────────────────────────────────────────────────

describe('parseRaw', () => {
  it('returns null for invalid JSON', () => {
    expect(parseRaw('not-json')).toBeNull()
    expect(parseRaw('')).toBeNull()
    expect(parseRaw('{')).toBeNull()
  })

  it('returns null for non-object JSON values', () => {
    expect(parseRaw('null')).toBeNull()
    expect(parseRaw('"string"')).toBeNull()
    expect(parseRaw('42')).toBeNull()
    expect(parseRaw('true')).toBeNull()
  })

  it('returns null for JSON arrays', () => {
    expect(parseRaw('[]')).toBeNull()
    expect(parseRaw('[1,2,3]')).toBeNull()
  })

  it('returns an object (not null) for empty JSON object', () => {
    const result = parseRaw('{}')
    expect(result).not.toBeNull()
    expect(result).toEqual({ contextUsedPct: undefined, costUsd: undefined, model: undefined })
  })

  it('extracts contextUsedPct from context_window.used_percentage', () => {
    const raw = JSON.stringify({ context_window: { used_percentage: 45.7 } })
    const result = parseRaw(raw)
    expect(result?.contextUsedPct).toBe(45.7)
  })

  it('returns undefined contextUsedPct when context_window is missing', () => {
    const result = parseRaw('{}')
    expect(result?.contextUsedPct).toBeUndefined()
  })

  it('returns undefined contextUsedPct when used_percentage is not a number', () => {
    const raw = JSON.stringify({ context_window: { used_percentage: '45%' } })
    expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
  })

  it('returns undefined contextUsedPct for Infinity and NaN', () => {
    const raw = JSON.stringify({ context_window: { used_percentage: null } })
    expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
  })

  it('returns undefined contextUsedPct when context_window is not an object', () => {
    const raw = JSON.stringify({ context_window: 'bad' })
    expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
  })

  it('extracts costUsd from cost.total_cost_usd', () => {
    const raw = JSON.stringify({ cost: { total_cost_usd: 0.532 } })
    expect(parseRaw(raw)?.costUsd).toBe(0.532)
  })

  it('returns undefined costUsd when cost is missing', () => {
    expect(parseRaw('{}')?.costUsd).toBeUndefined()
  })

  it('returns undefined costUsd for non-numeric values', () => {
    const raw = JSON.stringify({ cost: { total_cost_usd: 'expensive' } })
    expect(parseRaw(raw)?.costUsd).toBeUndefined()
  })

  it('returns undefined costUsd when cost is not an object', () => {
    const raw = JSON.stringify({ cost: 42 })
    expect(parseRaw(raw)?.costUsd).toBeUndefined()
  })

  it('extracts model from model.display_name', () => {
    const raw = JSON.stringify({ model: { display_name: 'claude-opus-4-5' } })
    expect(parseRaw(raw)?.model).toBe('claude-opus-4-5')
  })

  it('extracts model from model string (fallback)', () => {
    const raw = JSON.stringify({ model: 'claude-sonnet' })
    expect(parseRaw(raw)?.model).toBe('claude-sonnet')
  })

  it('returns undefined model when model is missing', () => {
    expect(parseRaw('{}')?.model).toBeUndefined()
  })

  it('returns undefined model for empty model string', () => {
    const raw = JSON.stringify({ model: '' })
    expect(parseRaw(raw)?.model).toBeUndefined()
  })

  it('returns undefined model when model.display_name is not a string', () => {
    const raw = JSON.stringify({ model: { display_name: 42 } })
    expect(parseRaw(raw)?.model).toBeUndefined()
  })

  it('truncates model to 100 characters', () => {
    const longModel = 'a'.repeat(150)
    const raw = JSON.stringify({ model: { display_name: longModel } })
    expect(parseRaw(raw)?.model).toHaveLength(100)
  })

  it('extracts all three fields together (full statusLine JSON)', () => {
    const raw = JSON.stringify({
      context_window: { used_percentage: 60.0 },
      cost: { total_cost_usd: 1.23, total_lines_added: 100, total_lines_removed: 20 },
      model: { display_name: 'claude-opus-4-5' },
      effort: { level: 'high' },
    })
    const result = parseRaw(raw)
    expect(result).toEqual({
      contextUsedPct: 60.0,
      costUsd: 1.23,
      model: 'claude-opus-4-5',
    })
  })

  it('handles partial fields gracefully (only model provided)', () => {
    const raw = JSON.stringify({ model: { display_name: 'claude-haiku' } })
    const result = parseRaw(raw)
    expect(result?.contextUsedPct).toBeUndefined()
    expect(result?.costUsd).toBeUndefined()
    expect(result?.model).toBe('claude-haiku')
  })

  it('does not throw on any input (never-throw contract)', () => {
    const cases = [null, undefined, 42, [], {}, 'random', '{"a":1}']
    for (const input of cases) {
      expect(() => parseRaw(String(input))).not.toThrow()
    }
  })
})

// ── buildSummary ──────────────────────────────────────────────────────────────

describe('buildSummary', () => {
  it('returns empty string for null parsed', () => {
    expect(buildSummary(null)).toBe('')
  })

  it('returns empty string when all fields are undefined', () => {
    expect(buildSummary({ contextUsedPct: undefined, costUsd: undefined, model: undefined })).toBe('')
  })

  it('formats contextUsedPct as ctx:XX%', () => {
    expect(buildSummary({ contextUsedPct: 45.7 })).toBe('ctx:46%')
  })

  it('rounds contextUsedPct to nearest integer', () => {
    expect(buildSummary({ contextUsedPct: 45.4 })).toBe('ctx:45%')
    expect(buildSummary({ contextUsedPct: 99.9 })).toBe('ctx:100%')
    expect(buildSummary({ contextUsedPct: 0 })).toBe('ctx:0%')
  })

  it('formats costUsd as $X.XX', () => {
    expect(buildSummary({ costUsd: 0.532 })).toBe('$0.53')
    expect(buildSummary({ costUsd: 1.0 })).toBe('$1.00')
    expect(buildSummary({ costUsd: 12.345 })).toBe('$12.35')
  })

  it('formats model as plain string', () => {
    expect(buildSummary({ model: 'claude-opus-4-5' })).toBe('claude-opus-4-5')
  })

  it('combines all fields with " · " separator', () => {
    const result = buildSummary({ contextUsedPct: 45, costUsd: 0.53, model: 'claude-opus' })
    expect(result).toBe('ctx:45% · $0.53 · claude-opus')
  })

  it('combines only present fields (ctx + model, no cost)', () => {
    const result = buildSummary({ contextUsedPct: 20, model: 'claude-haiku' })
    expect(result).toBe('ctx:20% · claude-haiku')
  })

  it('combines only present fields (cost + model, no ctx)', () => {
    const result = buildSummary({ costUsd: 0.01, model: 'claude-sonnet' })
    expect(result).toBe('$0.01 · claude-sonnet')
  })

  it('omits empty model string', () => {
    expect(buildSummary({ model: '' })).toBe('')
    expect(buildSummary({ costUsd: 1.0, model: '' })).toBe('$1.00')
  })
})

// ── postToServer ──────────────────────────────────────────────────────────────

describe('postToServer', () => {
  const mockFetch = vi.fn()

  beforeEach(() => {
    vi.stubGlobal('fetch', mockFetch)
    mockFetch.mockResolvedValue({ ok: true, status: 204 })
  })

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

  it('calls fetch with POST method and correct URL', async () => {
    await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
    expect(mockFetch).toHaveBeenCalledOnce()
    const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
    expect(url).toBe('http://127.0.0.1:3000/hook/status')
    expect(opts.method).toBe('POST')
  })

  it('sends Content-Type: application/json header', async () => {
    await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
    const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
    expect((opts.headers as Record<string, string>)['Content-Type']).toBe('application/json')
  })

  it('sends X-Webterm-Session header with the sessionId', async () => {
    await postToServer('http://127.0.0.1:3000/hook/status', 'my-session-id', '{}')
    const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
    expect((opts.headers as Record<string, string>)['X-Webterm-Session']).toBe('my-session-id')
  })

  it('sends the raw body verbatim', async () => {
    const body = '{"context_window":{"used_percentage":50}}'
    await postToServer('http://127.0.0.1:3000/hook/status', 's1', body)
    const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
    expect(opts.body).toBe(body)
  })

  it('does not throw when fetch rejects (server down)', async () => {
    mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
    await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')).resolves.toBeUndefined()
  })

  it('does not throw on network timeout (AbortError)', async () => {
    mockFetch.mockRejectedValue(new DOMException('Aborted', 'AbortError'))
    await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}', 1)).resolves.toBeUndefined()
  })

  it('passes an AbortSignal to fetch', async () => {
    await postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')
    const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
    expect(opts.signal).toBeDefined()
  })
})
