import crypto from 'node:crypto'
import fs from 'node:fs/promises'
import { existsSync, createReadStream } from 'node:fs'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import webpush from 'web-push'
import { decryptMembership, encryptMembership } from './encryption.js'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const dataDir = path.join(rootDir, 'server', 'data')
const distDir = path.join(rootDir, 'dist')
const port = Number(process.env.PORT || 3001)
const host = process.env.HOST || '127.0.0.1'
const appMode = ['public', 'admin', 'all'].includes(process.env.APP_MODE) ? process.env.APP_MODE : 'all'
const seedDevMembers = process.env.DEV_SEED_MEMBERS === 'true'
const membershipMonthlyUsd = 350
const bitcoinFallbackUsd = 79592.095
const accessHmacKey = process.env.ACCESS_HMAC_KEY || process.env.MEMBERSHIP_ENCRYPTION_KEY || 'development-access-hmac-key'
const controllerApiToken = process.env.ACCESS_CONTROLLER_TOKEN || ''
const btcpayServerUrl = String(process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, '')
const btcpayApiKey = process.env.BTCPAY_API_KEY || ''
const btcpayStoreId = process.env.BTCPAY_STORE_ID || ''
const btcpayWebhookSecret = process.env.BTCPAY_WEBHOOK_SECRET || ''
const homeAssistantUnlockWebhookUrl = process.env.HOME_ASSISTANT_UNLOCK_WEBHOOK_URL || ''
const homeAssistantUnlockTimeoutMs = Number(process.env.HOME_ASSISTANT_UNLOCK_TIMEOUT_MS || 2500)
const cleanEnvValue = (value) => String(value || '').trim().replace(/^["']|["']$/g, '')
const vapidPublicKey = cleanEnvValue(process.env.VAPID_PUBLIC_KEY)
const vapidPrivateKey = cleanEnvValue(process.env.VAPID_PRIVATE_KEY)
const vapidSubject = cleanEnvValue(process.env.VAPID_SUBJECT) || 'mailto:admin@l484.com'
const isValidVapidPublicKey = (value) => {
  try {
    const clean = cleanEnvValue(value)
    const padding = '='.repeat((4 - (clean.length % 4)) % 4)
    const key = Buffer.from((clean + padding).replace(/-/g, '+').replace(/_/g, '/'), 'base64')
    return key.length === 65 && key[0] === 4
  } catch {
    return false
  }
}
const normalizeAdminPubkey = (value) => {
  const raw = String(value || '').trim().toLowerCase()
  if (/^[0-9a-f]{64}$/.test(raw)) return raw
  if (!raw.startsWith('npub1')) return ''
  try {
    const decoded = nip19.decode(raw)
    return decoded.type === 'npub' && /^[0-9a-f]{64}$/.test(decoded.data) ? decoded.data : ''
  } catch {
    return ''
  }
}
const masterAdminPubkey = normalizeAdminPubkey(process.env.MASTER_ADMIN_PUBKEY)

const files = {
  memberships: path.join(dataDir, 'memberships.json'),
  payments: path.join(dataDir, 'payments.json'),
  cards: path.join(dataDir, 'cards.json'),
  accessLogs: path.join(dataDir, 'access-logs.json'),
  siteContent: path.join(dataDir, 'site-content.json'),
  adminRequests: path.join(dataDir, 'admin-requests.json'),
  notificationSubscriptions: path.join(dataDir, 'notification-subscriptions.json'),
}

const state = {
  memberships: [],
  payments: [],
  cards: [],
  accessLogs: [],
  siteContent: null,
  adminRequests: [],
  notificationSubscriptions: [],
}
let bitcoinPriceCache = null
const adminEventClients = new Set()
const rateBuckets = new Map()

const publicApiEnabled = () => appMode !== 'admin'
const adminApiEnabled = () => appMode !== 'public'

if (isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) {
  webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
}

const json = (res, status, body) => {
  res.writeHead(status, {
    'Content-Type': 'application/json',
    'Cache-Control': 'no-store',
  })
  res.end(JSON.stringify(body))
}

const rateLimit = (req, res) => {
  const method = String(req.method || 'GET').toUpperCase()
  if (!req.url?.startsWith('/api/') || method === 'GET') return false
  const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'unknown').split(',')[0].trim()
  const pathname = new URL(req.url, `http://${req.headers.host}`).pathname
  const key = `${ip}:${pathname}`
  const now = Date.now()
  const windowMs = 60_000
  const limit = pathname.includes('/access/check') ? 120 : 30
  const bucket = rateBuckets.get(key) || { count: 0, resetAt: now + windowMs }
  if (now > bucket.resetAt) {
    bucket.count = 0
    bucket.resetAt = now + windowMs
  }
  bucket.count += 1
  rateBuckets.set(key, bucket)
  if (rateBuckets.size > 2000) {
    for (const [bucketKey, value] of rateBuckets) {
      if (now > value.resetAt) rateBuckets.delete(bucketKey)
    }
  }
  if (bucket.count <= limit) return false
  json(res, 429, { error: 'Too many requests. Try again shortly.' })
  return true
}

const ensureStore = async () => {
  await fs.mkdir(dataDir, { recursive: true, mode: 0o700 })
  for (const [name, file] of Object.entries(files)) {
    if (name === 'siteContent') continue
    if (!existsSync(file)) await fs.writeFile(file, '[]', { mode: 0o600 })
  }
  if (!existsSync(files.siteContent)) {
    await fs.writeFile(files.siteContent, JSON.stringify(defaultSiteContent, null, 2), { mode: 0o600 })
  }
}

const loadJson = async (file) => JSON.parse(await fs.readFile(file, 'utf8'))
const saveJson = async (file, value) => fs.writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 })

const loadStore = async () => {
  await ensureStore()
  state.memberships = await loadJson(files.memberships)
  state.payments = await loadJson(files.payments)
  state.cards = await loadJson(files.cards)
  state.accessLogs = await loadJson(files.accessLogs)
  state.siteContent = normalizeSiteContent(await loadJson(files.siteContent).catch(() => defaultSiteContent))
  state.adminRequests = await loadJson(files.adminRequests)
  state.notificationSubscriptions = await loadJson(files.notificationSubscriptions)
}

const saveMemberships = () => saveJson(files.memberships, state.memberships)
const savePayments = () => saveJson(files.payments, state.payments)
const saveCards = () => saveJson(files.cards, state.cards)
const saveAccessLogs = () => saveJson(files.accessLogs, state.accessLogs)
const saveSiteContent = () => saveJson(files.siteContent, state.siteContent)
const saveAdminRequests = () => saveJson(files.adminRequests, state.adminRequests)
const saveNotificationSubscriptions = () => saveJson(files.notificationSubscriptions, state.notificationSubscriptions)

const readBody = async (req) => {
  const raw = await readRawBody(req)
  return raw.length ? JSON.parse(raw.toString('utf8')) : {}
}

const readRawBody = async (req) => {
  const chunks = []
  let size = 0
  for await (const chunk of req) {
    size += chunk.length
    if (size > 8 * 1024 * 1024) throw new Error('Request body too large')
    chunks.push(chunk)
  }
  return Buffer.concat(chunks)
}

const cleanText = (value, max = 160) =>
  String(value || '')
    .normalize('NFKC')
    .replace(/[\u0000-\u001f\u007f<>`{}[\]\\]/g, '')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, max)

const defaultSiteContent = {
  homepage: {
    hero: {
      line1: 'Decentralization',
      line2: 'In Motion',
      benefitsCue: 'Benefits',
    },
    members: {
      title: 'Members Get',
      benefits: [
        { title: 'Raw milk', description: 'Placeholder member benefit.' },
        { title: 'Grass fed beef', description: 'Placeholder member benefit.' },
        { title: 'Upgrade Labs', description: 'Placeholder member benefit.' },
        { title: '5 meals a month', description: 'Placeholder member benefit.' },
        { title: 'Free drinks', description: 'Placeholder member benefit.' },
      ],
    },
    facilities: {
      title: 'Facilities',
      items: [
        { title: 'Sauna', description: '6-8 person sauna available for member use.' },
        { title: 'Cold plunge', description: 'Two XL plunges filtered and cooled to your chosen temperature.' },
        { title: 'Gym', description: 'Outdoor turf space set up for fitness and group workouts.' },
        { title: 'Event space', description: 'Customizable indoor and outdoor space for private gatherings.' },
        { title: 'Grill area / firepit', description: 'Multiple grills and a custom sandbox firepit outside.' },
      ],
    },
    events: {
      navLabel: 'Events',
      title: 'Events',
      description: 'Private member gatherings, workshops, and hosted sessions at L484.',
      enquiryTitle: 'Event Enquiry',
      items: [
        { title: 'Member nights', description: 'Small-format gatherings for active members and invited guests.', date: 'Fridays', price: 'Members', image: '/images/firepit.avif' },
        { title: 'Workshops', description: 'Hands-on sessions around food, wellness, Bitcoin, and local resilience.', date: 'Monthly', price: '$40+', image: '/images/gym.avif' },
        { title: 'Private bookings', description: 'Indoor and outdoor areas available for approved member events.', date: 'By enquiry', price: 'Custom', image: '/images/bg-3.avif' },
      ],
    },
  },
}

const CONTENT_LIMITS = {
  heroLine: 24,
  cue: 16,
  sectionTitle: 32,
  navLabel: 16,
  itemTitle: 36,
  itemDescription: 90,
  eventTitle: 48,
  eventDescription: 140,
  eventImage: 240,
  eventDate: 32,
  eventPrice: 32,
  maxBenefits: 5,
  maxFacilities: 5,
  maxEvents: 6,
}

const limitedList = (value, fallback, limit, normalize) => {
  const source = Array.isArray(value) && value.length ? value : fallback
  return source.slice(0, limit).map(normalize)
}

const normalizeSiteContent = (value = {}) => {
  const homepage = value.homepage || {}
  const defaults = defaultSiteContent.homepage
  const hero = homepage.hero || {}
  const members = homepage.members || {}
  const facilities = homepage.facilities || {}
  const events = homepage.events || {}

  return {
    homepage: {
      hero: {
        line1: cleanText(hero.line1, CONTENT_LIMITS.heroLine) || defaults.hero.line1,
        line2: cleanText(hero.line2, CONTENT_LIMITS.heroLine) || defaults.hero.line2,
        benefitsCue: cleanText(hero.benefitsCue, CONTENT_LIMITS.cue) || defaults.hero.benefitsCue,
      },
      members: {
        title: cleanText(members.title, CONTENT_LIMITS.sectionTitle) || defaults.members.title,
        benefits: limitedList(members.benefits, defaults.members.benefits, CONTENT_LIMITS.maxBenefits, (item, index) => ({
          title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.members.benefits[index]?.title || `Benefit ${index + 1}`,
          description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.members.benefits[index]?.description || '',
        })),
      },
      facilities: {
        title: cleanText(facilities.title, CONTENT_LIMITS.sectionTitle) || defaults.facilities.title,
        items: limitedList(facilities.items, defaults.facilities.items, CONTENT_LIMITS.maxFacilities, (item, index) => ({
          title: cleanText(item?.title, CONTENT_LIMITS.itemTitle) || defaults.facilities.items[index]?.title || `Facility ${index + 1}`,
          description: cleanText(item?.description, CONTENT_LIMITS.itemDescription) || defaults.facilities.items[index]?.description || '',
        })),
      },
      events: {
        navLabel: cleanText(events.navLabel, CONTENT_LIMITS.navLabel) || defaults.events.navLabel,
        title: cleanText(events.title, CONTENT_LIMITS.sectionTitle) || defaults.events.title,
        description: cleanText(events.description, CONTENT_LIMITS.eventDescription) || defaults.events.description,
        enquiryTitle: cleanText(events.enquiryTitle, CONTENT_LIMITS.sectionTitle) || defaults.events.enquiryTitle,
        items: limitedList(events.items, defaults.events.items, CONTENT_LIMITS.maxEvents, (item, index) => ({
          title: cleanText(item?.title, CONTENT_LIMITS.eventTitle) || defaults.events.items[index]?.title || `Event ${index + 1}`,
          description: cleanText(item?.description, CONTENT_LIMITS.eventDescription) || defaults.events.items[index]?.description || '',
          date: cleanText(item?.date, CONTENT_LIMITS.eventDate) || defaults.events.items[index]?.date || '',
          price: cleanText(item?.price, CONTENT_LIMITS.eventPrice) || defaults.events.items[index]?.price || '',
          image: cleanText(item?.image, CONTENT_LIMITS.eventImage) || defaults.events.items[index]?.image || '/images/bg-1.avif',
        })),
      },
    },
  }
}

const createId = (prefix) => `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`
const hmacHex = (value) => crypto.createHmac('sha256', accessHmacKey).update(String(value)).digest('hex')
const decryptMember = (member) => decryptMembership(member)
const encryptedMembers = () => state.memberships
const members = () => state.memberships.map(decryptMember)
const findMember = (predicate) => members().find(predicate)
const activePaymentFor = (membershipId) =>
  state.payments.find((payment) => payment.membershipId === membershipId && payment.status === 'paid')
const activeCardFor = (membershipId) =>
  state.cards.find((card) => card.membershipId === membershipId && card.status === 'active')

const memberAccessStatus = (member) => {
  if (!member) return 'unknown'
  if (member.status === 'revoked' || member.status === 'suspended') return member.status
  if (new Date(member.expiresAt) <= new Date()) return 'expired'
  if (activePaymentFor(member.membershipId) && activeCardFor(member.membershipId)) return 'active'
  if (activePaymentFor(member.membershipId)) return 'pending_card'
  return member.status || 'requested'
}

const validateMember = (data) => {
  const signerNpubs = Array.isArray(data.signerNpubs)
    ? data.signerNpubs.map((item) => cleanText(item, 80)).filter(Boolean)
    : []
  const status = ['pending_payment', 'active', 'suspended', 'expired', 'revoked']
    .includes(data.status) ? data.status : 'requested'
  const member = {
    membershipId: cleanText(data.membershipId, 32).toUpperCase(),
    userId: cleanText(data.userId, 80),
    fullName: cleanText(data.fullName, 80),
    email: cleanText(data.email, 160).toLowerCase(),
    phone: cleanText(data.phone, 32),
    signature: String(data.signature || ''),
    signedDate: String(data.signedDate || ''),
    createdAt: String(data.createdAt || new Date().toISOString()),
    expiresAt: String(data.expiresAt || ''),
    status,
    npub: cleanText(data.npub, 80),
    signerNpubs,
    nsecHash: cleanText(data.nsecHash, 64).toLowerCase(),
  }
  const errors = []
  if (!/^L484-\d{4}-[A-Z0-9]{6}$/.test(member.membershipId)) errors.push('Invalid membership ID.')
  if (member.fullName.length < 2) errors.push('Full name is required.')
  if (member.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(member.email)) errors.push('Invalid email.')
  if (member.phone && !/^[0-9()+.\-\s]{7,32}$/.test(member.phone)) errors.push('Invalid phone.')
  if (!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(member.signature)) errors.push('Signature is required.')
  if (Number.isNaN(new Date(member.signedDate).getTime())) errors.push('Invalid signed date.')
  if (Number.isNaN(new Date(member.createdAt).getTime())) errors.push('Invalid created date.')
  if (member.expiresAt && Number.isNaN(new Date(member.expiresAt).getTime())) errors.push('Invalid expiry date.')
  if (!/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(member.npub)) errors.push('Invalid npub.')
  if (member.signerNpubs.some((npub) => !/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(npub))) errors.push('Invalid signer npub.')
  if (!/^[0-9a-f]{64}$/.test(member.nsecHash)) errors.push('Invalid nsec hash.')

  if (!member.expiresAt) {
    const expiresAt = new Date(member.createdAt)
    expiresAt.setFullYear(expiresAt.getFullYear() + 1)
    member.expiresAt = expiresAt.toISOString()
  }

  return { member, errors }
}

const requireAdmin = (req, res) => {
  if (!adminApiEnabled()) {
    json(res, 404, { error: 'Admin API is disabled on this deployment.' })
    return false
  }
  if (!isAdminPubkey(getAuthPubkey(req))) {
    json(res, 403, { error: 'Admin access required.' })
    return false
  }
  return true
}

const getAuthPubkey = (req) => {
  const auth = req.headers.authorization || ''
  return auth.startsWith('Bearer ') ? normalizeAdminPubkey(auth.slice(7)) : ''
}

const isAdminPubkey = (pubkey) => {
  const normalized = normalizeAdminPubkey(pubkey)
  return Boolean(normalized) && (isMasterAdminPubkey(normalized) || state.adminRequests.some((request) =>
    request.pubkey === normalized && request.status === 'approved'
  ))
}

const isMasterAdminPubkey = (pubkey) => {
  const normalized = normalizeAdminPubkey(pubkey)
  return Boolean(masterAdminPubkey && normalized === masterAdminPubkey)
}

const requireMasterAdmin = (req, res) => {
  if (!requireAdmin(req, res)) return false
  if (!isMasterAdminPubkey(getAuthPubkey(req))) {
    json(res, 403, { error: 'Master admin access required.' })
    return false
  }
  return true
}

const publicAdminRequests = () => state.adminRequests.map((request) => ({
  id: request.id,
  displayName: request.displayName,
  npub: request.npub,
  pubkey: request.pubkey,
  status: request.status,
  requestedAt: request.requestedAt,
  decidedAt: request.decidedAt || '',
  decidedBy: request.decidedBy || '',
}))

const notificationStats = () => ({
  configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey),
  subscriberCount: state.notificationSubscriptions.length,
  publicKeyConfigured: Boolean(vapidPublicKey),
  publicKeyValid: isValidVapidPublicKey(vapidPublicKey),
  privateKeyConfigured: Boolean(vapidPrivateKey),
  subject: vapidSubject,
})

const sendPushNotification = async ({ title, message, url = '/edit', icon = '/images/small-logo.svg', tag = 'l484-update' }) => {
  if (!isValidVapidPublicKey(vapidPublicKey) || !vapidPrivateKey) {
    return { success: false, reason: 'vapid_not_configured', sent: 0, failed: 0 }
  }
  const payload = JSON.stringify({
    title: cleanText(title, 80) || 'L484',
    message: cleanText(message, 220) || 'New L484 update.',
    icon: cleanText(icon, 240) || '/images/small-logo.svg',
    tag: cleanText(tag, 80) || 'l484-update',
    data: { url: cleanText(url, 240) || '/edit' },
  })
  const results = await Promise.allSettled(
    state.notificationSubscriptions.map((subscription) => webpush.sendNotification(subscription, payload)),
  )
  const validSubscriptions = []
  let sent = 0
  let failed = 0
  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      sent += 1
      validSubscriptions.push(state.notificationSubscriptions[index])
      return
    }
    failed += 1
    const statusCode = result.reason?.statusCode
    if (![404, 410].includes(statusCode)) validSubscriptions.push(state.notificationSubscriptions[index])
  })
  if (validSubscriptions.length !== state.notificationSubscriptions.length) {
    state.notificationSubscriptions = validSubscriptions
    await saveNotificationSubscriptions()
  }
  return { success: true, sent, failed }
}

const broadcastAdminEvent = (event, payload = {}) => {
  const message = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`
  for (const client of adminEventClients) {
    client.write(message)
  }
}

const hasControllerAuth = (req) => {
  const token = String(req.headers['x-controller-token'] || '')
  if (!controllerApiToken || !token) return false
  const provided = Buffer.from(token)
  const expected = Buffer.from(controllerApiToken)
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected)
}

const serveStatic = (req, res) => {
  const requested = decodeURIComponent(new URL(req.url, `http://${req.headers.host}`).pathname)
  const filePath = requested === '/' ? path.join(distDir, 'index.html') : path.join(distDir, requested)
  const safePath = filePath.startsWith(distDir) && existsSync(filePath) ? filePath : path.join(distDir, 'index.html')
  const ext = path.extname(safePath)
  const types = {
    '.html': 'text/html',
    '.js': 'text/javascript',
    '.css': 'text/css',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.avif': 'image/avif',
    '.json': 'application/json',
    '.webmanifest': 'application/manifest+json',
  }
  const headers = { 'Content-Type': types[ext] || 'application/octet-stream' }
  if (path.basename(safePath) === 'sw.js' || ext === '.html' || ext === '.webmanifest') {
    headers['Cache-Control'] = 'no-store'
  }
  res.writeHead(200, headers)
  createReadStream(safePath).pipe(res)
}

const getBitcoinPrice = async () => {
  const now = Date.now()
  if (bitcoinPriceCache && now - bitcoinPriceCache.fetchedAt < 60_000) return bitcoinPriceCache

  const sources = [
    { source: 'Coinbase BTC-USD spot', url: 'https://api.coinbase.com/v2/prices/BTC-USD/spot', parse: (payload) => Number(payload?.data?.amount) },
    { source: 'CoinGecko BTC-USD spot', url: 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd', parse: (payload) => Number(payload?.bitcoin?.usd) },
    { source: 'Binance US BTC-USD spot', url: 'https://api.binance.us/api/v3/ticker/price?symbol=BTCUSD', parse: (payload) => Number(payload?.price) },
  ]

  const fetchSource = async ({ source, url, parse }) => {
    const controller = new AbortController()
    const timeout = setTimeout(() => controller.abort(), 2500)
    try {
      const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } })
      if (!response.ok) throw new Error(`${source} request failed: ${response.status}`)
      const usd = parse(await response.json())
      if (!Number.isFinite(usd) || usd <= 0) throw new Error(`${source} returned an invalid BTC price.`)
      return { source, usd }
    } finally {
      clearTimeout(timeout)
    }
  }

  try {
    const { source, usd } = await Promise.any(sources.map(fetchSource))
    bitcoinPriceCache = { usd, membershipUsd: membershipMonthlyUsd, membershipBtc: membershipMonthlyUsd / usd, fetchedAt: now, source }
  } catch {
    bitcoinPriceCache = {
      usd: bitcoinFallbackUsd,
      membershipUsd: membershipMonthlyUsd,
      membershipBtc: membershipMonthlyUsd / bitcoinFallbackUsd,
      fetchedAt: now,
      source: 'Fallback BTC-USD estimate',
      fallback: true,
    }
  }
  return bitcoinPriceCache
}

const upsertMember = async (member) => {
  const existingIndex = members().findIndex((item) =>
    item.membershipId === member.membershipId ||
    item.userId === member.userId ||
    item.npub === member.npub ||
    item.signerNpubs?.includes(member.npub) ||
    member.signerNpubs.includes(item.npub) ||
    member.signerNpubs.some((npub) => item.signerNpubs?.includes(npub)) ||
    item.nsecHash === member.nsecHash
  )
  const encrypted = encryptMembership(member)
  if (existingIndex >= 0) encryptedMembers()[existingIndex] = encrypted
  else encryptedMembers().unshift(encrypted)
  await saveMemberships()
  return existingIndex
}

const updateMemberStatus = async (membershipId, status) => {
  const index = members().findIndex((member) => member.membershipId === membershipId)
  if (index < 0) return null
  const member = { ...decryptMember(state.memberships[index]), status }
  state.memberships[index] = encryptMembership(member)
  await saveMemberships()
  return member
}

const serializeAdmin = (pubkey = '') => {
  const allMembers = members()
  return {
    success: true,
    admin: {
      isMaster: isMasterAdminPubkey(pubkey),
    },
    memberships: allMembers.map((member) => ({ ...member, accessStatus: memberAccessStatus(member) })),
    payments: state.payments,
    cards: state.cards.map(({ cardSecretHash, uidHash, ...card }) => card),
    accessLogs: state.accessLogs.slice(0, 200),
  }
}

const recordAccess = async (entry) => {
  state.accessLogs.unshift({
    id: createId('access'),
    seenAt: new Date().toISOString(),
    ...entry,
  })
  state.accessLogs = state.accessLogs.slice(0, 1000)
  await saveAccessLogs()
}

const triggerDoorUnlock = async ({ doorId, member, card }) => {
  if (!homeAssistantUnlockWebhookUrl) {
    return { attempted: false, ok: false, reason: 'unlock_webhook_not_configured' }
  }

  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), homeAssistantUnlockTimeoutMs)
  try {
    const response = await fetch(homeAssistantUnlockWebhookUrl, {
      method: 'POST',
      signal: controller.signal,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        doorId,
        membershipId: member.membershipId,
        fullName: member.fullName,
        cardId: card.id,
        cardPublicId: card.cardPublicId,
        requestedAt: new Date().toISOString(),
      }),
    })
    return {
      attempted: true,
      ok: response.ok,
      status: response.status,
      reason: response.ok ? 'unlock_sent' : 'unlock_webhook_failed',
    }
  } catch (error) {
    return {
      attempted: true,
      ok: false,
      reason: error?.name === 'AbortError' ? 'unlock_webhook_timeout' : 'unlock_webhook_error',
    }
  } finally {
    clearTimeout(timeout)
  }
}

const createSeedMember = (index, status, daysAgo = index) => {
  const createdAt = new Date()
  createdAt.setDate(createdAt.getDate() - daysAgo)
  const expiresAt = new Date(createdAt)
  expiresAt.setFullYear(expiresAt.getFullYear() + 1)
  if (status === 'expired') expiresAt.setDate(new Date().getDate() - 2)

  const secretKey = generateSecretKey()
  const pubkey = getPublicKey(secretKey)
  const names = [
    'Zaza Stone',
    'Mara Vale',
    'Nico Ward',
    'Ada Cross',
    'Iris Hale',
    'Theo Knox',
    'Lena Frost',
    'Oren Pike',
    'Mina Sol',
    'Jules Voss',
    'Rafe Quinn',
    'Sage Rowan',
  ]
  const suffix = String(index + 1).padStart(2, '0')
  return {
    membershipId: `L484-${createdAt.getFullYear()}-DEV${suffix}X`,
    userId: `l484-dev-user-${suffix}`,
    fullName: names[index],
    email: `member${suffix}@example.test`,
    phone: `+1 305 555 01${suffix}`,
    signature: 'data:image/png;base64,iVBORw0KGgo=',
    signedDate: createdAt.toISOString(),
    createdAt: createdAt.toISOString(),
    expiresAt: expiresAt.toISOString(),
    status,
    npub: nip19.npubEncode(pubkey),
    signerNpubs: [],
    nsecHash: crypto.createHash('sha256').update(nip19.nsecEncode(secretKey)).digest('hex'),
  }
}

const seedDevelopmentStore = async () => {
  if (!seedDevMembers) return

  const specs = [
    ['requested', 1],
    ['requested', 2],
    ['requested', 3],
    ['pending_payment', 4],
    ['pending_payment', 5],
    ['pending_payment', 6],
    ['active', 7],
    ['active', 8],
    ['active', 9],
    ['suspended', 10],
    ['suspended', 11],
    ['expired', 380],
  ]
  const seedMembers = specs.map(([status, daysAgo], index) => createSeedMember(index, status, daysAgo))
  const existingMembers = members()
  const existingMembershipIds = new Set(existingMembers.map((member) => member.membershipId))
  const newSeedMembers = seedMembers.filter((member) => !existingMembershipIds.has(member.membershipId))

  if (newSeedMembers.length) {
    state.memberships = [...state.memberships, ...newSeedMembers.map(encryptMembership)]
  }

  const existingPaymentIds = new Set(state.payments.map((payment) => payment.membershipId))
  const paidMembers = seedMembers.filter((member) => ['active', 'suspended', 'expired'].includes(member.status) && !existingPaymentIds.has(member.membershipId))
  const pendingMembers = seedMembers.filter((member) => member.status === 'pending_payment' && !existingPaymentIds.has(member.membershipId))
  const newPayments = [
    ...paidMembers.map((member, index) => ({
      id: `payment-dev-paid-${member.membershipId.slice(-6).toLowerCase()}`,
      membershipId: member.membershipId,
      provider: index % 2 ? 'btcpay' : 'cash',
      amountUsd: membershipMonthlyUsd,
      amountSats: index % 2 ? 420000 : 0,
      status: 'paid',
      btcpayInvoiceId: index % 2 ? `dev-invoice-paid-${index + 1}` : '',
      checkoutLink: index % 2 ? `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-paid-${index + 1}` : '',
      createdAt: member.createdAt,
      paidAt: member.createdAt,
    })),
    ...pendingMembers.map((member, index) => ({
      id: `payment-dev-pending-${member.membershipId.slice(-6).toLowerCase()}`,
      membershipId: member.membershipId,
      provider: 'btcpay',
      amountUsd: membershipMonthlyUsd,
      amountSats: 420000,
      status: 'pending',
      btcpayInvoiceId: `dev-invoice-pending-${index + 1}`,
      checkoutLink: `${btcpayServerUrl || 'https://shop.tx1138.com'}/i/dev-pending-${index + 1}`,
      createdAt: member.createdAt,
      paidAt: '',
    })),
  ]
  state.payments = [...state.payments, ...newPayments]

  const existingCardIds = new Set(state.cards.map((card) => card.membershipId))
  const cardMembers = seedMembers.filter((member) => ['active', 'suspended'].includes(member.status) && !existingCardIds.has(member.membershipId))
  const newCards = cardMembers.map((member, index) => {
    const credential = `dev-card-${index + 1}-${member.membershipId}`
    return {
      id: `card-dev-${member.membershipId.slice(-6).toLowerCase()}`,
      membershipId: member.membershipId,
      cardPublicId: `L484-CARD-DEV${String(index + 1).padStart(2, '0')}`,
      cardSecretHash: hmacHex(credential),
      uidHash: hmacHex(credential),
      status: 'active',
      issuedAt: member.createdAt,
      lastSeenAt: index < 3 ? new Date().toISOString() : '',
      notes: 'Development seed card',
    }
  })

  state.cards = [...state.cards, ...newCards]

  const logCards = newCards.length ? newCards : state.cards
  const shouldSeedAccessLogs = newCards.length || !state.accessLogs.length
  const newAccessLogs = shouldSeedAccessLogs ? Array.from({ length: Math.min(18, Math.max(logCards.length * 3, 0)) }, (_, index) => {
    const card = logCards[index % logCards.length]
    const member = newSeedMembers.find((item) => item.membershipId === card?.membershipId)
      || existingMembers.find((item) => item.membershipId === card?.membershipId)
    const seenAt = new Date()
    seenAt.setHours(seenAt.getHours() - index * 3)
    const allow = member?.status === 'active'
    return {
      id: `access-dev-${Date.now()}-${index + 1}`,
      seenAt: seenAt.toISOString(),
      membershipId: member?.membershipId || '',
      cardId: card?.id || '',
      cardPublicId: card?.cardPublicId || '',
      doorId: index % 2 ? 'plunge-door' : 'front-door',
      decision: allow ? 'allow' : 'deny',
      reason: allow ? 'active_member_card' : `member_${member?.status || 'unknown'}`,
    }
  }) : []
  state.accessLogs = [...newAccessLogs, ...state.accessLogs]

  if (!newSeedMembers.length && !newPayments.length && !newCards.length && !newAccessLogs.length) return

  await Promise.all([saveMemberships(), savePayments(), saveCards(), saveAccessLogs()])
  console.log(`Seeded development fixtures: ${newSeedMembers.length} members, ${newPayments.length} payments, ${newCards.length} cards.`)
}

const btcpayConfigured = () => Boolean(btcpayServerUrl && btcpayApiKey && btcpayStoreId)

const extractBitcoinAddress = (link) => {
  if (typeof link !== 'string' || !link.startsWith('bitcoin:')) return ''
  return link.slice('bitcoin:'.length).split('?')[0] || ''
}

const extractBolt11 = (link) => {
  if (typeof link !== 'string') return ''
  if (link.startsWith('lightning:')) return link.slice('lightning:'.length)
  return link.toLowerCase().startsWith('lnbc') ? link : ''
}

const normalizeBtcpayPaymentMethods = (paymentMethods = []) => {
  let lightning = null
  let bitcoin = null

  for (const method of paymentMethods) {
    const id = String(method.paymentMethodId || method.paymentMethod || method.id || '')
    const idUpper = id.toUpperCase()
    const paymentLink = String(method.paymentLink || '')
    const destination = method.destination || method.address || method.bolt11 || extractBolt11(paymentLink) || extractBitcoinAddress(paymentLink) || ''

    if (!lightning && (idUpper.includes('LN') || idUpper.includes('LIGHTNING'))) {
      const bolt11 = method.bolt11 || extractBolt11(paymentLink) || (String(destination).toLowerCase().startsWith('lnbc') ? destination : '')
      if (bolt11) lightning = { destination: bolt11, paymentLink }
    }

    if (!bitcoin && idUpper.includes('BTC') && !idUpper.includes('LN')) {
      const address = method.address
        || (String(destination).match(/^(bc1|[13])/i) ? destination : '')
        || extractBitcoinAddress(paymentLink)
      if (address) bitcoin = { destination: address, paymentLink }
    }

    if (lightning && bitcoin) break
  }

  return { lightning, bitcoin }
}

const fetchBtcpayPaymentMethods = async (invoiceId) => {
  if (!btcpayConfigured() || !invoiceId) return []
  const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices/${invoiceId}/payment-methods`, {
    headers: {
      Accept: 'application/json',
      Authorization: `token ${btcpayApiKey}`,
    },
  })
  if (!response.ok) return []
  const data = await response.json().catch(() => [])
  return Array.isArray(data) ? data : []
}

const createBtcpayInvoice = async (member) => {
  if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.')
  const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `token ${btcpayApiKey}`,
    },
    body: JSON.stringify({
      amount: membershipMonthlyUsd,
      currency: 'USD',
      metadata: {
        orderId: member.membershipId,
        itemDesc: 'L484 monthly membership',
      },
    }),
  })
  const data = await response.json().catch(() => ({}))
  if (!response.ok) throw new Error(data.message || data.error || `BTCPay request failed: ${response.status}`)
  return data
}

const fetchBtcpayInvoice = async (invoiceId) => {
  if (!btcpayConfigured()) throw new Error('BTCPay credentials are not configured.')
  const response = await fetch(`${btcpayServerUrl}/api/v1/stores/${btcpayStoreId}/invoices/${invoiceId}`, {
    headers: {
      Accept: 'application/json',
      Authorization: `token ${btcpayApiKey}`,
    },
  })
  const data = await response.json().catch(() => ({}))
  if (!response.ok) throw new Error(data.message || data.error || `BTCPay status request failed: ${response.status}`)
  return data
}

const verifyBtcpayWebhook = (req, rawBody) => {
  if (!btcpayWebhookSecret) return false
  const header = String(req.headers['btcpay-sig'] || '')
  const digest = `sha256=${crypto.createHmac('sha256', btcpayWebhookSecret).update(rawBody).digest('hex')}`
  const headerBuffer = Buffer.from(header, 'utf8')
  const digestBuffer = Buffer.from(digest, 'utf8')
  return headerBuffer.length === digestBuffer.length && crypto.timingSafeEqual(headerBuffer, digestBuffer)
}

const markInvoicePaid = async (invoiceId) => {
  const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId)
  if (!payment) return null
  payment.status = 'paid'
  payment.paidAt = new Date().toISOString()
  await savePayments()
  await updateMemberStatus(payment.membershipId, activeCardFor(payment.membershipId) ? 'active' : 'pending_payment')
  broadcastAdminEvent('payment-paid', {
    invoiceId,
    membershipId: payment.membershipId,
    paymentId: payment.id,
    paidAt: payment.paidAt,
  })
  return payment
}

const paymentStatusPayload = async (payment) => {
  let invoice = null
  let paymentMethods = []
  if (payment.provider === 'btcpay' && payment.btcpayInvoiceId && btcpayConfigured()) {
    invoice = await fetchBtcpayInvoice(payment.btcpayInvoiceId).catch(() => null)
    paymentMethods = await fetchBtcpayPaymentMethods(payment.btcpayInvoiceId).catch(() => [])
    const status = cleanText(invoice?.status || invoice?.invoiceStatus, 80)
    if (['Settled', 'Processing'].includes(status) && payment.status !== 'paid') {
      await markInvoicePaid(payment.btcpayInvoiceId)
      payment = state.payments.find((item) => item.id === payment.id) || payment
    }
  }
  const { lightning, bitcoin } = normalizeBtcpayPaymentMethods(paymentMethods)
  const checkoutLink = payment.checkoutLink || invoice?.checkoutLink || invoice?.paymentLink || ''
  const lightningInvoice = lightning?.destination || invoice?.lightningInvoice || invoice?.invoice || ''
  const paymentAddress = bitcoin?.destination || invoice?.paymentAddress || ''
  return {
    success: true,
    paid: payment.status === 'paid',
    status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'),
    invoice: {
      id: payment.btcpayInvoiceId || payment.id,
      amount: payment.amountUsd,
      currency: 'USD',
      status: invoice?.status || (payment.status === 'paid' ? 'Settled' : 'New'),
      paymentUrl: checkoutLink,
      checkoutLink,
      lightningInvoice,
      paymentAddress,
      qrCodeData: lightningInvoice || paymentAddress || invoice?.qrCodeData || checkoutLink,
      lightningPaymentLink: lightning?.paymentLink || '',
      bitcoinPaymentLink: bitcoin?.paymentLink || '',
      createdAt: payment.createdAt,
      expiresAt: invoice?.expirationTime ? new Date(Number(invoice.expirationTime) * 1000).toISOString() : '',
    },
  }
}

const handleApi = async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)

  if (req.method === 'GET' && url.pathname === '/api/health') return json(res, 200, { ok: true, mode: appMode })
  if (req.method === 'GET' && url.pathname === '/api/config') {
    return json(res, 200, {
      mode: appMode,
      publicMembershipEnabled: publicApiEnabled(),
      adminEnabled: adminApiEnabled(),
      accessEnabled: adminApiEnabled(),
      btcpayEnabled: btcpayConfigured(),
      masterAdminConfigured: Boolean(masterAdminPubkey),
    })
  }

  if (req.method === 'POST' && url.pathname === '/api/btcpay/webhook') {
    const rawBody = await readRawBody(req)
    if (!verifyBtcpayWebhook(req, rawBody)) return json(res, 401, { error: 'Invalid BTCPay signature.' })
    const body = rawBody.length ? JSON.parse(rawBody.toString('utf8')) : {}
    const invoiceId = cleanText(body.invoiceId || body.id || body.data?.invoiceId || body.data?.id, 120)
    const eventType = cleanText(body.type, 80)
    const status = cleanText(body.status || body.invoiceStatus || body.data?.status || body.data?.invoiceStatus, 80)
    const paid = ['InvoiceSettled', 'InvoiceProcessing'].includes(eventType) || ['Settled', 'Processing'].includes(status)
    if (invoiceId && paid) await markInvoicePaid(invoiceId)
    return json(res, 200, { success: true })
  }

  if (req.method === 'GET' && url.pathname === '/api/bitcoin-price') return json(res, 200, await getBitcoinPrice())

  if (req.method === 'GET' && url.pathname === '/api/site-content') {
    return json(res, 200, { success: true, content: state.siteContent || defaultSiteContent, limits: CONTENT_LIMITS })
  }

  if (req.method === 'GET' && url.pathname === '/api/notifications/vapid-public-key') {
    return json(res, 200, { success: true, publicKey: isValidVapidPublicKey(vapidPublicKey) ? vapidPublicKey : '', configured: Boolean(isValidVapidPublicKey(vapidPublicKey) && vapidPrivateKey) })
  }

  if (req.method === 'GET' && url.pathname === '/api/notifications/test-vapid') {
    return json(res, 200, { success: true, ...notificationStats() })
  }

  if (req.method === 'POST' && url.pathname === '/api/notifications/subscribe') {
    const body = await readBody(req)
    const subscription = body.subscription || body
    const endpoint = cleanText(subscription?.endpoint, 1000)
    if (!endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
      return json(res, 400, { error: 'Invalid push subscription.' })
    }
    const normalized = {
      endpoint,
      expirationTime: subscription.expirationTime || null,
      keys: {
        p256dh: cleanText(subscription.keys.p256dh, 500),
        auth: cleanText(subscription.keys.auth, 200),
      },
      userAgent: cleanText(req.headers['user-agent'], 240),
      createdAt: new Date().toISOString(),
    }
    const existingIndex = state.notificationSubscriptions.findIndex((item) => item.endpoint === endpoint)
    if (existingIndex >= 0) state.notificationSubscriptions[existingIndex] = normalized
    else state.notificationSubscriptions.unshift(normalized)
    await saveNotificationSubscriptions()
    return json(res, 201, { success: true, subscriberCount: state.notificationSubscriptions.length })
  }

  if (req.method === 'POST' && url.pathname === '/api/admin/request-access') {
    if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' })
    const body = await readBody(req)
    const pubkey = cleanText(body.pubkey, 64).toLowerCase()
    const npub = cleanText(body.npub, 90)
    const displayName = cleanText(body.displayName, 80) || 'Admin request'
    if (!/^[0-9a-f]{64}$/.test(pubkey)) return json(res, 400, { error: 'Invalid admin public key.' })
    if (npub && !/^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/.test(npub)) return json(res, 400, { error: 'Invalid admin npub.' })
    const existing = state.adminRequests.find((request) => request.pubkey === pubkey)
    if (existing) return json(res, 200, { success: true, request: { ...existing, existing: true } })
    const request = {
      id: createId('admin-request'),
      displayName,
      npub,
      pubkey,
      status: isMasterAdminPubkey(pubkey) ? 'approved' : 'requested',
      requestedAt: new Date().toISOString(),
      decidedAt: isMasterAdminPubkey(pubkey) ? new Date().toISOString() : '',
      decidedBy: isMasterAdminPubkey(pubkey) ? 'system' : '',
    }
    state.adminRequests.unshift(request)
    await saveAdminRequests()
    return json(res, 201, { success: true, request })
  }

  if (req.method === 'POST' && url.pathname === '/api/membership/create') {
    if (!publicApiEnabled()) return json(res, 404, { error: 'Public membership signup is disabled on this deployment.' })
    const { member, errors } = validateMember(await readBody(req))
    if (errors.length) return json(res, 400, { error: 'Validation failed.', errors })
    member.status = 'requested'
    const existingIndex = await upsertMember(member)
    broadcastAdminEvent('membership-created', {
      membershipId: member.membershipId,
      fullName: member.fullName,
      createdAt: member.createdAt,
      updatedExisting: existingIndex >= 0,
    })
    sendPushNotification({
      title: 'New member request',
      message: `${member.fullName} submitted a membership request.`,
      url: '/admin',
      tag: 'l484-membership-created',
    }).catch((error) => console.warn('Push notification failed:', error.message))
    return json(res, existingIndex >= 0 ? 200 : 201, { success: true, membership: member })
  }

  if (req.method === 'GET' && url.pathname === '/api/membership/check') {
    const membershipId = cleanText(url.searchParams.get('membershipId'), 32).toUpperCase()
    const userId = cleanText(url.searchParams.get('userId'), 80)
    const npub = cleanText(url.searchParams.get('npub'), 80)
    const found = findMember((item) =>
      (membershipId && item.membershipId === membershipId) ||
      (userId && item.userId === userId) ||
      (npub && (item.npub === npub || item.signerNpubs?.includes(npub)))
    )
    return json(res, 200, found ? { hasMembership: true, membership: found } : { hasMembership: false })
  }

  if (req.method === 'POST' && url.pathname === '/api/membership/recover') {
    const body = await readBody(req)
    const nsecHash = cleanText(body.nsecHash, 64).toLowerCase()
    const found = findMember((item) => item.nsecHash === nsecHash)
    return json(res, found ? 200 : 404, found ? { success: true, membership: found } : { error: 'No membership found for that nsec.' })
  }

  if (req.method === 'GET' && url.pathname === '/api/admin/state') {
    if (!requireAdmin(req, res)) return
    return json(res, 200, serializeAdmin(getAuthPubkey(req)))
  }

  if (req.method === 'PUT' && url.pathname === '/api/admin/site-content') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    state.siteContent = normalizeSiteContent(body.content || body)
    await saveSiteContent()
    return json(res, 200, { success: true, content: state.siteContent, limits: CONTENT_LIMITS })
  }

  if (req.method === 'GET' && url.pathname === '/api/admin/access-requests') {
    if (!requireAdmin(req, res)) return
    return json(res, 200, { success: true, isMasterAdmin: isMasterAdminPubkey(getAuthPubkey(req)), requests: publicAdminRequests() })
  }

  if (req.method === 'GET' && url.pathname === '/api/admin/notifications/stats') {
    if (!requireAdmin(req, res)) return
    return json(res, 200, { success: true, ...notificationStats() })
  }

  if (req.method === 'POST' && url.pathname === '/api/admin/notifications/send') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    const result = await sendPushNotification({
      title: body.title || 'L484 update',
      message: body.message || 'There is a new L484 update.',
      url: body.url || '/edit',
      tag: body.tag || 'l484-admin-test',
    })
    return json(res, result.success ? 200 : 400, result.success ? result : { error: result.reason, ...result })
  }

  if (req.method === 'POST' && url.pathname === '/api/admin/access-requests/status') {
    if (!requireMasterAdmin(req, res)) return
    const body = await readBody(req)
    const id = cleanText(body.id, 120)
    const status = cleanText(body.status, 24)
    if (!['approved', 'rejected'].includes(status)) return json(res, 400, { error: 'Invalid request status.' })
    const request = state.adminRequests.find((item) => item.id === id)
    if (!request) return json(res, 404, { error: 'Admin request not found.' })
    request.status = status
    request.decidedAt = new Date().toISOString()
    request.decidedBy = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').slice(0, 64)
    await saveAdminRequests()
    return json(res, 200, { success: true, request })
  }

  if (req.method === 'GET' && url.pathname === '/api/admin/events') {
    if (!adminApiEnabled()) return json(res, 404, { error: 'Admin API is disabled on this deployment.' })
    const pubkey = cleanText(url.searchParams.get('pubkey'), 80).toLowerCase()
    if (!isAdminPubkey(pubkey)) return json(res, 403, { error: 'Admin access required.' })
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-store',
      Connection: 'keep-alive',
      'X-Accel-Buffering': 'no',
    })
    res.write('event: ready\ndata: {"success":true}\n\n')
    adminEventClients.add(res)
    req.on('close', () => {
      adminEventClients.delete(res)
    })
    return
  }

  if (req.method === 'GET' && url.pathname === '/api/memberships') {
    if (!requireAdmin(req, res)) return
    return json(res, 200, { success: true, memberships: serializeAdmin(getAuthPubkey(req)).memberships })
  }

  if (req.method === 'PATCH' && url.pathname === '/api/membership/status') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    const membershipId = cleanText(body.membershipId, 32).toUpperCase()
    const status = cleanText(body.status, 32)
    if (!['requested', 'pending_payment', 'active', 'suspended', 'expired', 'revoked'].includes(status)) {
      return json(res, 400, { error: 'Invalid membership status.' })
    }
    const member = await updateMemberStatus(membershipId, status)
    return json(res, member ? 200 : 404, member ? { success: true, membership: member } : { error: 'Member not found.' })
  }

  if (req.method === 'DELETE' && url.pathname === '/api/membership') {
    if (!requireAdmin(req, res)) return
    const { membershipId } = await readBody(req)
    const id = cleanText(membershipId, 32).toUpperCase()
    const before = state.memberships.length
    state.memberships = members().filter((member) => member.membershipId !== id).map(encryptMembership)
    state.cards = state.cards.filter((card) => card.membershipId !== id)
    state.payments = state.payments.filter((payment) => payment.membershipId !== id)
    await Promise.all([saveMemberships(), saveCards(), savePayments()])
    return json(res, 200, { success: true, deleted: before - state.memberships.length })
  }

  if (req.method === 'POST' && url.pathname === '/api/payment/manual') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    const membershipId = cleanText(body.membershipId, 32).toUpperCase()
    const member = findMember((item) => item.membershipId === membershipId)
    if (!member) return json(res, 404, { error: 'Member not found.' })
    const payment = {
      id: createId('payment'),
      membershipId,
      provider: cleanText(body.provider, 24) || 'manual',
      amountUsd: Number(body.amountUsd || membershipMonthlyUsd),
      amountSats: Number(body.amountSats || 0),
      status: 'paid',
      btcpayInvoiceId: cleanText(body.btcpayInvoiceId, 120),
      createdAt: new Date().toISOString(),
      paidAt: new Date().toISOString(),
    }
    state.payments.unshift(payment)
    await savePayments()
    await updateMemberStatus(membershipId, activeCardFor(membershipId) ? 'active' : 'pending_payment')
    return json(res, 201, { success: true, payment })
  }

  if (req.method === 'POST' && url.pathname === '/api/payment/btcpay') {
    if (!requireAdmin(req, res)) return
    if (!btcpayConfigured()) return json(res, 400, { error: 'BTCPay credentials are not configured.' })
    const body = await readBody(req)
    const membershipId = cleanText(body.membershipId, 32).toUpperCase()
    const member = findMember((item) => item.membershipId === membershipId)
    if (!member) return json(res, 404, { error: 'Member not found.' })
    const invoice = await createBtcpayInvoice(member)
    const payment = {
      id: createId('payment'),
      membershipId,
      provider: 'btcpay',
      amountUsd: membershipMonthlyUsd,
      amountSats: 0,
      status: 'pending',
      btcpayInvoiceId: cleanText(invoice.id, 120),
      checkoutLink: cleanText(invoice.checkoutLink, 500),
      createdAt: new Date().toISOString(),
      paidAt: '',
    }
    state.payments.unshift(payment)
    await savePayments()
    await updateMemberStatus(membershipId, 'pending_payment')
    return json(res, 201, await paymentStatusPayload(payment))
  }

  if (req.method === 'GET' && url.pathname.startsWith('/api/payment/status/')) {
    if (!requireAdmin(req, res)) return
    const invoiceId = cleanText(url.pathname.split('/').pop(), 120)
    const payment = state.payments.find((item) => item.btcpayInvoiceId === invoiceId || item.id === invoiceId)
    if (!payment) return json(res, 404, { error: 'Payment not found.' })
    return json(res, 200, await paymentStatusPayload(payment))
  }

  if (req.method === 'POST' && url.pathname === '/api/card/issue') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    const membershipId = cleanText(body.membershipId, 32).toUpperCase()
    const member = findMember((item) => item.membershipId === membershipId)
    const cardCredential = cleanText(body.cardCredential, 160)
    if (!member) return json(res, 404, { error: 'Member not found.' })
    if (cardCredential.length < 6) return json(res, 400, { error: 'Card credential is required.' })
    const now = new Date().toISOString()
    const existing = state.cards.find((card) => card.membershipId === membershipId && card.status === 'active')
    if (existing) existing.status = 'revoked'
    const card = {
      id: createId('card'),
      membershipId,
      cardPublicId: cleanText(body.cardPublicId, 80) || `L484-CARD-${crypto.randomBytes(3).toString('hex').toUpperCase()}`,
      cardSecretHash: hmacHex(cardCredential),
      uidHash: hmacHex(cardCredential),
      status: 'active',
      issuedAt: now,
      lastSeenAt: '',
      notes: cleanText(body.notes, 240),
    }
    state.cards.unshift(card)
    await saveCards()
    await updateMemberStatus(membershipId, activePaymentFor(membershipId) ? 'active' : 'pending_payment')
    return json(res, 201, { success: true, card: { ...card, cardSecretHash: undefined, uidHash: undefined } })
  }

  if (req.method === 'POST' && url.pathname === '/api/card/status') {
    if (!requireAdmin(req, res)) return
    const body = await readBody(req)
    const id = cleanText(body.id, 80)
    const status = cleanText(body.status, 24)
    const card = state.cards.find((item) => item.id === id)
    if (!card) return json(res, 404, { error: 'Card not found.' })
    if (!['active', 'revoked', 'lost'].includes(status)) return json(res, 400, { error: 'Invalid card status.' })
    card.status = status
    await saveCards()
    return json(res, 200, { success: true, card: { ...card, cardSecretHash: undefined, uidHash: undefined } })
  }

  if (req.method === 'POST' && url.pathname === '/api/access/check') {
    const controllerAuthed = hasControllerAuth(req)
    const isAdmin = (req.headers.authorization || '').startsWith('Bearer ')
    if (!controllerAuthed && (!isAdmin || !requireAdmin(req, res))) return
    const body = await readBody(req)
    const doorId = cleanText(body.doorId, 80) || 'front-door'
    const cardCredential = cleanText(body.cardCredential || body.uid, 160)
    if (cardCredential.length < 1) return json(res, 400, { error: 'Card credential is required.' })
    const credentialHash = hmacHex(cardCredential)
    const card = state.cards.find((item) => item.status === 'active' && (item.cardSecretHash === credentialHash || item.uidHash === credentialHash))
    const member = card ? findMember((item) => item.membershipId === card.membershipId) : null
    const status = memberAccessStatus(member)
    const allow = Boolean(card && member && status === 'active')
    const reason = allow ? 'active_member_card' : card ? `member_${status}` : 'card_not_found'
    const unlock = allow && controllerAuthed
      ? await triggerDoorUnlock({ doorId, member, card })
      : { attempted: false, ok: false, reason: controllerAuthed ? 'access_denied' : 'admin_test_only' }
    if (card) card.lastSeenAt = new Date().toISOString()
    await recordAccess({
      membershipId: member?.membershipId || '',
      cardId: card?.id || '',
      cardPublicId: card?.cardPublicId || '',
      doorId,
      decision: allow ? 'allow' : 'deny',
      reason: unlock.attempted && !unlock.ok ? `${reason}_${unlock.reason}` : reason,
    })
    if (card) await saveCards()
    return json(res, 200, {
      allow,
      reason,
      unlock,
      member: member ? { membershipId: member.membershipId, fullName: member.fullName, status } : null,
    })
  }

  json(res, 404, { error: 'Not found.' })
}

await loadStore()
await seedDevelopmentStore()

http.createServer(async (req, res) => {
  try {
    if (req.url.startsWith('/api/')) {
      if (rateLimit(req, res)) return
      await handleApi(req, res)
    }
    else serveStatic(req, res)
  } catch (error) {
    console.error(error)
    json(res, 500, { error: error.message || 'Server error.' })
  }
}).listen(port, host, () => {
  console.log(`L484 server listening on http://${host}:${port} in ${appMode} mode`)
})
