/**
 * Fastify app assembly. All collaborators are injected (config, CP client, token minter, static
 * root, clock) so the whole surface is unit-testable via `app.inject()` with fakes and NO network.
 * Route order matters: auth + api plugins register BEFORE the static wildcard so specific routes win.
 */
import Fastify, { type FastifyInstance } from 'fastify'
import type { PanelConfig } from './config.js'
import type { CpClient } from './cp-client.js'
import type { ManageTokenMinter } from './manage-token.js'
import type { RateLimiter } from './security/rate-limit.js'
import { buildAuthRoutes } from './routes/auth-routes.js'
import { buildApiRoutes } from './routes/api-routes.js'
import { buildStaticRoutes } from './static.js'

/**
 * Baseline security response headers applied to EVERY response (via an onRequest hook, so they are
 * present on 404/401/error paths and on streamed static files too). The CSP is tuned for this
 * self-contained SPA: it loads /build/app.js (script) and /build/styles.css (stylesheet) same-origin
 * and renders the pairing QR as a `data:` image — hence `img-src 'self' data:`. `style-src` allows
 * inline styles defensively; everything else is locked to 'self', framing is forbidden, and
 * object-src/base-uri are neutralised.
 */
const SECURITY_HEADERS: Readonly<Record<string, string>> = {
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'Referrer-Policy': 'no-referrer',
  'Content-Security-Policy':
    "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
}

export interface AppDeps {
  readonly config: PanelConfig
  readonly cpClient: CpClient
  readonly minter: ManageTokenMinter
  /** Clock (ms) — injectable for tests. */
  readonly now?: () => number
  /** Per-IP login limiter — injectable for tests. */
  readonly rateLimiter?: RateLimiter
  /** Absolute path to the built SPA (public/build). `null` ⇒ skip static serving (tests). */
  readonly staticRoot?: string | null
  /** Server-side error log (no secrets). Defaults to a no-op. */
  readonly logError?: (message: string) => void
}

export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
  // Fastify's own logger is disabled: we control what is logged so no secret/token/password leaks.
  //
  // trustProxy: the panel sits behind nginx on loopback. The nginx vhost MUST set
  // `X-Forwarded-For $remote_addr` (a clean, non-client-controllable value — NOT
  // `$proxy_add_x_forwarded_for`, which would append attacker-supplied hops). Trusting the proxy makes
  // `req.ip` reflect the REAL client address; without it every request looks like 127.0.0.1 and the
  // /login rate limiter collapses into a single global bucket → a trivial unauthenticated lockout DoS.
  const app = Fastify({ logger: false, bodyLimit: 64 * 1024, trustProxy: true })

  // Defense-in-depth: stamp security headers on every response before routing (so 404/401/error
  // responses and streamed static files all carry them). Registered first, applies to all child plugins.
  app.addHook('onRequest', async (_req, reply) => {
    for (const [name, value] of Object.entries(SECURITY_HEADERS)) reply.header(name, value)
  })

  // Conditional spreads keep `exactOptionalPropertyTypes` happy (never pass an explicit `undefined`).
  const nowPart = deps.now !== undefined ? { now: deps.now } : {}
  await app.register(
    buildAuthRoutes({
      config: deps.config,
      ...nowPart,
      ...(deps.rateLimiter !== undefined ? { rateLimiter: deps.rateLimiter } : {}),
    }),
  )
  await app.register(
    buildApiRoutes({
      config: deps.config,
      cpClient: deps.cpClient,
      minter: deps.minter,
      ...nowPart,
      ...(deps.logError !== undefined ? { logError: deps.logError } : {}),
    }),
  )

  if (deps.staticRoot != null) {
    await app.register(buildStaticRoutes(deps.staticRoot))
  }

  return app
}
