import { test, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { api, ApiError } from '../src/api/client.js'

// Unit-test the fetch wrapper that every API call flows through. The behaviors
// that matter to the whole app:
//   - it always sends the session cookie (credentials: 'include');
//   - a non-2xx response becomes a thrown ApiError carrying status + a message
//     (server body.message → statusText → generic), never a silent bad value;
//   - an empty body resolves to null (not a JSON parse throw);
//   - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
//     browser can set the multipart boundary);
//   - query strings and path params are built/encoded correctly.
// We drive the real req() by mocking global.fetch and inspecting what it received.

let calls
const realFetch = global.fetch

// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
  return {
    ok: status >= 200 && status < 300,
    status,
    statusText,
    text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
  }
}

beforeEach(() => {
  calls = []
  global.fetch = async (url, opts) => {
    calls.push({ url, opts })
    return calls.nextReply || reply({ body: { ok: true } })
  }
})
afterEach(() => {
  global.fetch = realFetch
})

// helper to queue the next response
function willReply(r) {
  global.fetch = async (url, opts) => {
    calls.push({ url, opts })
    return reply(r)
  }
}

// ── happy path + cookie + base path ─────────────────────────────────────
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
  willReply({ body: { user: { id: 1 } } })
  const out = await api.me()
  assert.equal(calls[0].url, '/api/v1/auth/me')
  assert.equal(calls[0].opts.credentials, 'include')
  assert.equal(calls[0].opts.method, 'GET')
  assert.deepEqual(out, { user: { id: 1 } })
})

// ── error mapping ───────────────────────────────────────────────────────
test('a non-ok response throws an ApiError with status and the server message', async () => {
  willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
  await assert.rejects(
    () => api.login('u', 'bad'),
    (err) => {
      assert.ok(err instanceof ApiError)
      assert.equal(err.status, 401)
      assert.equal(err.message, 'Incorrect username or password.')
      assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
      return true
    },
  )
})

test('an error with no JSON message falls back to statusText', async () => {
  willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
  await assert.rejects(
    () => api.status(),
    (err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
  )
})

// ── empty body ──────────────────────────────────────────────────────────
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
  willReply({ status: 200, body: '' })
  const out = await api.logout()
  assert.equal(out, null)
})

test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
  willReply({ status: 200, body: 'plain text' })
  const out = await api.me()
  assert.equal(out, 'plain text')
})

// ── request body encoding ───────────────────────────────────────────────
test('a JSON POST serializes the body and sets Content-Type', async () => {
  willReply({ body: { user: { id: 9 } } })
  await api.register('newbie', 'pw', { company: '' })
  const { opts } = calls[0]
  assert.equal(opts.method, 'POST')
  assert.equal(opts.headers['Content-Type'], 'application/json')
  assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
})

test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
  willReply({ body: { url: '/uploads/x.png' } })
  const fakeFile = { name: 'x.png' }
  await api.admin.upload(fakeFile)
  const { opts } = calls[0]
  assert.equal(opts.method, 'POST')
  assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
  assert.ok(opts.body instanceof FormData)
})

// ── query strings + path param encoding ─────────────────────────────────
test('wiki() builds a query string only from the params that are set', async () => {
  willReply({ body: [] })
  await api.wiki({ category: 'lore', q: 'dragon slayer' })
  const url = new URL(calls[0].url, 'http://x')
  assert.equal(url.pathname, '/api/v1/public/wiki')
  assert.equal(url.searchParams.get('category'), 'lore')
  assert.equal(url.searchParams.get('q'), 'dragon slayer')
  assert.equal(url.searchParams.get('tag'), null) // omitted when unset
})

test('wiki() with no options sends no query string at all', async () => {
  willReply({ body: [] })
  await api.wiki()
  assert.equal(calls[0].url, '/api/v1/public/wiki')
})

test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
  willReply({ body: {} })
  await api.shard.governorHistory('Serpent’s Hold', 5)
  assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
})

test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
  willReply({ body: {} })
  await api.revokeMySession('a b/c')
  assert.equal(calls[0].opts.method, 'DELETE')
  assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
})

// ── spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────
// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard
// content parsed from the shard's own files, so it must not look sidecar-backed.
// Asserted here because the split is a design decision, not an accident of
// spelling.
test('atlas reads hit /public/atlas, not /public/shard', async () => {
  willReply({ body: { creatures: [] } })
  await api.atlas.creatures()
  assert.equal(calls[0].url, '/api/v1/public/atlas/creatures')
})

test('atlas.creatures() sends only the filters that are set', async () => {
  willReply({ body: { creatures: [] } })
  await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 })
  const url = new URL(calls[0].url, 'http://x')
  assert.equal(url.pathname, '/api/v1/public/atlas/creatures')
  assert.equal(url.searchParams.get('q'), 'lizard man')
  assert.equal(url.searchParams.get('facet'), 'Ter Mur')
  assert.equal(url.searchParams.get('limit'), '25')
  assert.equal(url.searchParams.get('offset'), null) // 0 is not sent
})

test('atlas.creature() encodes the slug and carries the facet filter through', async () => {
  willReply({ body: {} })
  await api.atlas.creature('lizardman/rare', { facet: 'Felucca' })
  assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/)
})

test('admin atlas actions use the right methods and bodies', async () => {
  willReply({ body: {} })
  await api.admin.atlas.import(true)
  assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import')
  assert.equal(calls[0].opts.method, 'POST')
  assert.equal(calls[0].opts.body, JSON.stringify({ force: true }))

  willReply({ body: {} })
  await api.admin.atlas.setPath('/srv/servuo')
  assert.equal(calls[1].opts.method, 'PUT')
  assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' }))
})
