require('dotenv').config()
const http = require('http')

// NOTE: `./app` and `./internalApp` are deliberately NOT required here. Requiring
// app.js runs `modules.load()`, which scans the volume and mounts whatever is on
// it (MODULE_API.md §4.1) — so the declared module set has to be resolved before
// that require, not before the listener. They are required inside start(), after
// resolveDeclaredModules(); everything else this file needs is safe to pull in
// now because none of it reaches the loader's scan.
const botScore = require('./middleware/botScore')
const announceWorker = require('./utils/announceWorker')
const { ensureSchema, close } = require('./utils/db')
const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
const settings = require('./model/settings/settings.model')
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
const moduleLifecycle = require('./modules/lifecycle')
const declaredModules = require('./modules/declared')
const moduleInstall = require('./modules/install')
const moduleModel = require('./model/modules/modules.model')
const createLogger = require('./utils/logger')
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
const brand = require('./config/brand')
const pkg = require('../package.json')

const log = createLogger('server')
const PORT = Number(process.env.PORT) || 3000
// Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off
// the public PORT so the decrypted-token route can't ride the listener Pangolin
// proxies to the world (issue #33). Must match the port in the bot's
// SITE_INTERNAL_URL (docker-compose.yml).
const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001
const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it

async function start() {
  log.info(`starting ${brand.name} server v${pkg.version}`, {
    node: process.version,
    env: process.env.NODE_ENV || 'development',
    logLevel: process.env.LOG_LEVEL || 'info',
    logFile: createLogger.logFilePath || 'disabled (console only)',
    db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`,
    cookieSecure: process.env.COOKIE_SECURE || 'auto',
    email: 'gmail-oauth2 (configured in admin → settings)',
  })

  // Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in
  // production (the /internal/bot-config route hands back the decrypted Discord
  // token and this key is its only guard); a warning otherwise.
  const keyCheck = evaluateBotInternalKey({
    key: process.env.BOT_INTERNAL_KEY,
    nodeEnv: process.env.NODE_ENV,
  })
  if (keyCheck.fatal) {
    log.error(keyCheck.message)
    process.exit(1)
  } else if (!keyCheck.ok) {
    log.warn(keyCheck.message)
  }

  log.info('ensuring database schema...')
  // Core's schema only. Each installed module's fragment is replayed further
  // down, after the volume has been scanned — see the require of ./app below.
  await ensureSchema({ replayModules: false })
  log.info('seeding defaults...')
  await seedDefaults()
  await createInitialAdminFromEnv()

  // Clear out session-denylist rows whose token has already expired (dead weight).
  // Best-effort — a prune failure must never block startup.
  try {
    const pruned = await revokedSessions.pruneExpired()
    if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`)
  } catch (err) {
    log.warn('revoked-session prune failed', { error: err.message })
  }

  // Same treatment for the mobile SSO bridge tables (also pruned opportunistically
  // on each bridge write). Boot-time sweep catches rows orphaned by a crash.
  try {
    const pruned = await mobileAuthBridge.pruneExpired()
    if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`)
  } catch (err) {
    log.warn('mobile-auth-bridge prune failed', { error: err.message })
  }

  const mode = await settings.get('site_mode')
  log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)

  // Bring the modules volume in line with what MODULES declares (§2.7.2
  // decision 4), and only then require the app — the loader scans and mounts at
  // require time, so this is the last moment at which a module can be put on the
  // volume and still be part of this process.
  //
  // After the schema and the seed, because the host allowlist it installs under
  // is a settings row that the seed creates on a fresh instance. Never throws:
  // an unreachable release host leaves the site serving without that module
  // rather than taking the site down with it.
  await declaredModules.resolve({
    hosts: moduleInstall.parseHosts(await settings.get(moduleInstall.HOSTS_SETTING)),
    model: moduleModel,
  })

  // Requiring app.js is what scans the volume and mounts what is on it. Every
  // line above this one runs against a core that has no modules in it yet.
  // eslint-disable-next-line global-require
  const app = require('./app')
  // eslint-disable-next-line global-require
  const internalApp = require('./internalApp')

  // Now that the scan has happened, replay each module's schema fragment
  // (MODULE_API.md §2.6). This used to ride inside ensureSchema() and could,
  // because app.js was required at the top of this file; resolving the declared
  // set first moved the scan after it, and a booting server quietly getting no
  // module tables is precisely what §7.6 warns about. Caught by the browser
  // smoke rather than by a test: every suite here stubs one side or the other.
  // eslint-disable-next-line global-require
  await require('./modules/schema').replayFragments()

  // Reconcile installed_modules with what the loader found on the volume at
  // require time, then run each module's onBoot (MODULE_API.md §2.5).
  //
  // This is where the shard now warms up. Core used to do it inline just above —
  // rebuild the spawn atlas from the ServUO tree, refresh the cliloc table, open
  // the uo-link WebSocket — and all of it is module-uo's `onBoot` since Phase 3.
  // The ordering guarantee is unchanged and is why it belongs here rather than
  // after the listener: the tables exist by now, and nothing is served until the
  // warm-up finishes. Placed
  // after core's own boot work and BEFORE the listener binds, for both reasons
  // the contract gives: a module's warm-up may depend on core being up, and a
  // module that must not serve traffic until it has warmed a cache gets that
  // guarantee only if nothing is listening yet. Never throws — a module that
  // fails here keeps its URLs and answers 503.
  await moduleLifecycle.boot()

  const server = http.createServer(app)
  server.listen(PORT, HOST, () => {
    log.info(`listening on http://${HOST}:${PORT}  (API at /api/v1, health at /api/health)`)
  })

  // Internal server<->bot API on a separate, unpublished port. NEVER expose this
  // through Pangolin/the public reverse proxy — it serves the decrypted Discord
  // bot token to the bot process over the private compose network only (#33).
  const internalServer = http.createServer(internalApp)
  internalServer.listen(INTERNAL_PORT, HOST, () => {
    log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT}  (server<->bot only — do NOT proxy)`)
  })

  // Start the news-announcement dispatcher: a light in-process poller that pushes
  // published news posts to the in-game town crier + Discord with independent
  // retry per leg. No-op until a news post is actually published.
  announceWorker.start()

  setupShutdown(server, internalServer)
}

function setupShutdown(server, internalServer) {
  let closing = false
  const shutdown = async (signal) => {
    if (closing) return
    closing = true
    log.warn(`${signal} received — shutting down gracefully`)
    // Modules first, while everything they were handed still works: the database
    // pool, the push dispatcher and the SSE fan-out are all still open here, and
    // a module's onShutdown is the only chance it gets to flush through them
    // (MODULE_API.md §2.5). Each hook is budgeted, so one that will not let go
    // costs five seconds rather than the whole shutdown.
    await moduleLifecycle.shutdown()
    botScore.stopSweeper() // stop the bot-store cleanup interval
    announceWorker.stop() // stop the news-announcement dispatcher poller
    server.close(() => log.info('http server closed'))
    if (internalServer) internalServer.close(() => log.info('internal http server closed'))
    try {
      await close()
      log.info('database pool closed')
    } catch (err) {
      log.error('error closing database pool', err)
    }
    await createLogger.close() // flush the log file
    process.exit(0)
  }

  process.on('SIGINT', () => shutdown('SIGINT'))
  process.on('SIGTERM', () => shutdown('SIGTERM'))
  process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) }))
  process.on('uncaughtException', (err) => {
    log.error('uncaughtException', err)
    process.exit(1)
  })
}

start().catch((err) => {
  log.error('failed to start server', err)
  process.exit(1)
})
