// Point the DB at a closed port BEFORE the pool is built, and the upload
// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read
// at require time. Every model call is monkeypatched, so no query runs.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const os = require('os')
const path = require('path')
const fs = require('fs')

const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-'))
process.env.UPLOAD_DIR = UPLOAD_DIR

const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')

// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two
// halves are worth locking — what a stored value is allowed to be (these values
// are written straight into HTML as URLs) and the upload route's per-slot rules,
// which tighten the shared allowlist without ever widening it (§9).
const { startApp } = require('./_helper')
const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets')
const settingsRouter = require('../src/router/v1/admin/settings.router')
const settingsDb = require('../src/model/settings/settings.db')
const sessionService = require('../src/auth/session.service')
const { requireAuth } = require('../src/auth/session.middleware')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const htmlShell = require('../src/utils/htmlShell')
const db = require('../src/utils/db')

after(() => {
  db.close()
  fs.rmSync(UPLOAD_DIR, { recursive: true, force: true })
})

const originals = {
  validateSession: sessionService.validateSession,
  isSessionRevoked: sessionService.isSessionRevoked,
  sessionMeta: sessionService.sessionMeta,
  getById: users.getById,
  get: settingsDb.get,
  set: settingsDb.set,
  log: activity.log,
}
afterEach(() => {
  Object.assign(sessionService, {
    validateSession: originals.validateSession,
    isSessionRevoked: originals.isSessionRevoked,
    sessionMeta: originals.sessionMeta,
  })
  users.getById = originals.getById
  settingsDb.get = originals.get
  settingsDb.set = originals.set
  activity.log = originals.log
})

function signInAs(user) {
  sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })
  sessionService.isSessionRevoked = async () => false
  sessionService.sessionMeta = () => ({})
  users.getById = async () => user
  activity.log = async () => {}
}

// ── What a stored asset path may be ───────────────────────────────────

test('only same-origin paths under the directories this server serves are accepted', () => {
  for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) {
    assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`)
  }
  const rejected = [
    'https://evil.example/x.png', // off-origin: an <img src> the operator did not choose
    '//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin
    'javascript:alert(1)', // no scheme survives the prefix check, but be explicit
    '/uploads/../../etc/passwd', // climbing out of the served directory
    '/uploads/a b.png', // whitespace is the raw material for smuggling
    '/uploads/"onerror="alert(1)', // quote would break out of the attribute
    '/etc/passwd', // a path, but not one we serve
    'uploads/1-a.png', // relative to the current route, not to the origin
    '',
    null,
    42,
  ]
  for (const bad of rejected) {
    assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`)
  }
})

// Strict on write: the admin gets told which field is wrong, rather than saving
// something that silently never renders.
test('a write naming an unknown slot or an unusable path is rejected by field', () => {
  assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true)
  assert.equal(validateBrandAssets(null).ok, true) // clearing every slot

  const unknown = validateBrandAssets({ banner: '/uploads/a.png' })
  assert.equal(unknown.ok, false)
  assert.match(unknown.message, /banner/)

  const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' })
  assert.equal(offsite.ok, false)
  assert.match(offsite.message, /favicon/)

  assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false)
})

// Forgiving on read: one hand-edited slot must not cost the admin the other two.
test('a bad stored slot is dropped and its neighbours are kept', () => {
  const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null })
  assert.deepEqual(resolved, { logo: '/uploads/a.png' })
})

test('resolve is also how a cleared slot stops being stored', () => {
  // '' and null are how the UI clears a slot; neither may survive into the row,
  // or "the field is absent" would stop being the single meaning of "use env".
  assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {})
  assert.deepEqual(resolveBrandAssets(null), {})
  assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon'])
})

// ── POST /admin/settings/brand-asset/:slot ────────────────────────────

// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to
// accept by mimetype (which is what the shared allowlist keys off).
const PNG = Buffer.from(
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
  'base64',
)
const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')

function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) {
  const fd = new FormData()
  fd.append('image', new Blob([buffer], { type }), filename)
  return fd
}

const startSettingsApp = () =>
  startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))

const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR)

test('uploading a slot stores the file and points brand_assets at it', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.get = async () => null // never set before
  let stored = null
  settingsDb.set = async (key, value) => {
    stored = { key, value }
  }
  const before = filesInUploadDir().length
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
      method: 'POST',
      body: form(PNG),
    })
    assert.equal(res.status, 201)
    const body = await res.json()
    assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/)
    assert.deepEqual(body.brand_assets, { logo: body.url })
    assert.equal(stored.key, 'brand_assets')
    assert.deepEqual(JSON.parse(stored.value), { logo: body.url })
    assert.equal(filesInUploadDir().length, before + 1, 'the file is kept')
  } finally {
    await app.close()
  }
})

// §6.3: uploading a logo does not force the admin to also pick a hero — and must
// not silently discard the hero they picked last week.
test('an upload merges into the existing overrides rather than replacing them', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' })
  let stored = null
  settingsDb.set = async (key, value) => {
    stored = value
  }
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
      method: 'POST',
      body: form(PNG),
    })
    assert.equal(res.status, 201)
    const saved = JSON.parse(stored)
    assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives')
    assert.match(saved.favicon, /^\/uploads\//)
  } finally {
    await app.close()
  }
})

// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension
// coming from that map is what makes the upload path safe. PNG only, and the
// rejected file does not stay on disk.
test('a favicon that is not a PNG is refused and the file is discarded', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.set = async () => assert.fail('a refused upload must not write the row')
  const before = filesInUploadDir().length
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
      method: 'POST',
      body: form(GIF, { filename: 'x.gif', type: 'image/gif' }),
    })
    assert.equal(res.status, 400)
    assert.match((await res.json()).message, /PNG/)
    assert.equal(filesInUploadDir().length, before, 'no orphan file left behind')
  } finally {
    await app.close()
  }
})

test('a file over the slot cap is refused and discarded', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.set = async () => assert.fail('a refused upload must not write the row')
  // Valid PNG header, then padding past the favicon's 512 KB cap — the shared
  // multer limit is 8 MB, so only the per-slot rule can reject this.
  const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
  const before = filesInUploadDir().length
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
      method: 'POST',
      body: form(big),
    })
    assert.equal(res.status, 400)
    assert.match((await res.json()).message, /512 KB or smaller/)
    assert.equal(filesInUploadDir().length, before)
  } finally {
    await app.close()
  }
})

test('the same file is accepted for a slot with a bigger cap', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.get = async () => null
  settingsDb.set = async () => {}
  const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, {
      method: 'POST',
      body: form(big),
    })
    assert.equal(res.status, 201)
  } finally {
    await app.close()
  }
})

test('an unknown slot is refused and the file is discarded', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.set = async () => assert.fail('an unknown slot must not write the row')
  const before = filesInUploadDir().length
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, {
      method: 'POST',
      body: form(PNG),
    })
    assert.equal(res.status, 400)
    assert.equal(filesInUploadDir().length, before)
  } finally {
    await app.close()
  }
})

// The generic POST /admin/uploads is reachable by editors. The site's identity
// is not theirs to change, so this route carries the same admin gate as the
// settings row it writes.
test('an editor cannot upload a brand asset', async () => {
  signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
  settingsDb.set = async () => assert.fail('an editor must not write brand_assets')
  const before = filesInUploadDir().length
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
      method: 'POST',
      body: form(PNG),
    })
    assert.equal(res.status, 403)
    assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes')
  } finally {
    await app.close()
  }
})

// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ──────
//
// There is no per-slot delete route: clearing the logo is a write of the
// remaining slots, and clearing the last one is the existing reset-by-delete.

test('clearing a slot through the settings write drops it from the row', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  let stored = null
  settingsDb.set = async (key, value) => {
    stored = value
  }
  settingsDb.getAll = async () => []
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }),
    })
    assert.equal(res.status, 200)
    assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row')
  } finally {
    await app.close()
  }
})

test('a settings write carrying an off-origin asset URL is rejected by field', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored')
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }),
    })
    assert.equal(res.status, 400)
    assert.match((await res.json()).message, /brand_assets\.logo/)
  } finally {
    await app.close()
  }
})

test('a successful upload invalidates the cached HTML shell', async () => {
  signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
  settingsDb.get = async () => null
  settingsDb.set = async () => {}
  let invalidated = 0
  const realInvalidate = htmlShell.invalidate
  htmlShell.invalidate = () => {
    invalidated += 1
  }
  const app = await startSettingsApp()
  try {
    const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
      method: 'POST',
      body: form(PNG),
    })
    assert.equal(res.status, 201)
    assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL')
  } finally {
    htmlShell.invalidate = realInvalidate
    await app.close()
  }
})
