const express = require('express')
const path = require('path')
const fs = require('fs')
const cors = require('cors')
const helmet = require('helmet')
const morgan = require('morgan')
const cookieParser = require('cookie-parser')
require('dotenv').config()

const swaggerUi = require('swagger-ui-express')

const apiRouter = require('./router/api.router')
const wellKnown = require('./router/wellKnown.controller')
const cspReport = require('./router/cspReport.controller')
const brand = require('./config/brand')
const csp = require('./config/csp')
const { cspReportLimiter } = require('./middleware/rateLimit')
const createLogger = require('./utils/logger')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')

const httpLog = createLogger('http')
const errLog = createLogger('error')

const app = express()

// Behind Pangolin: trust the forwarding proxy so req.secure (cookie flag) and
// req.ip (activity log, rate limiting, backoff, bot-ban) reflect the real client
// from X-Forwarded-*. Configurable via TRUST_PROXY; defaults to a single hop and
// never a blanket `true` (which would let clients spoof their IP). Must run
// before any middleware that reads req.ip.
applyTrustProxy(app)

// Optional trust-proxy diagnostics (off unless DEBUG_TRUST_PROXY is set). Before
// the bot guard so it logs scanner/junk source IPs too.
app.use(trustProxyDebug)

// Bot / scanner guard — mounted first (before helmet/routing) so banned IPs and
// obvious scanner probes are 404'd immediately without reaching real handlers.
app.use(botScore.guard)

// Security headers, including a Content-Security-Policy tuned for the built React
// SPA. The policies themselves (and the reasoning behind every non-'self' allowance)
// live in config/csp.js. The interactive API docs at /api/docs get their own looser
// policy below.
app.use(
  helmet({
    contentSecurityPolicy: { useDefaults: true, directives: csp.enforced },
    crossOriginResourcePolicy: { policy: 'cross-origin' },
  }),
)

// The tightened policy rides alongside on Content-Security-Policy-Report-Only for one
// release, then replaces the enforced one (docs/website/API_V2_PLAN.md § Phase 1).
// Both headers are served at once on purpose: the live policy keeps protecting users
// while anything the tightened version would have broken shows up as a report at
// /api/csp-report instead of as a broken page. Reports are same-origin — they
// describe attacks on this site and are not handed to a third party.
app.use(csp.reportingEndpoints)
app.use(
  helmet.contentSecurityPolicy({
    useDefaults: true,
    reportOnly: true,
    directives: csp.reportOnly,
  }),
)

// CORS only when a separate client origin is configured (local Vite dev). In
// production the SPA is same-origin, so no CORS is needed.
if (process.env.CLIENT_ORIGIN) {
  app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true }))
}

// Access logs: real client IP (via trust proxy), the authenticated admin (if any),
// method, URL, status, response time, and size. Bodies/credentials are never logged.
morgan.token('user', (req) => (req.user && req.user.username) || '-')
const accessFormat =
  ':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes'
app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim()) } }))

app.use(express.json({ limit: '2mb' }))
app.use(cookieParser())

// ── Paths ─────────────────────────────────────────────────────────────
const SERVER_ROOT = path.join(__dirname, '..')
const REPO_ROOT = path.join(SERVER_ROOT, '..')
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads')
const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist')
const BRAND_DIR = process.env.BRAND_DIR || path.join(REPO_ROOT, 'brand')
fs.mkdirSync(UPLOAD_DIR, { recursive: true })

// Escape user/brand text for safe interpolation into the HTML shell.
const htmlEscape = (s) =>
  String(s).replace(
    /[&<>"']/g,
    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
  )

// Template the built index.html <head> with instance branding (title, meta
// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
// so the prebuilt SPA image serves per-instance metadata without a rebuild.
function renderIndexHtml(html) {
  const title = htmlEscape(brand.name)
  const desc = htmlEscape(brand.description)
  const tags = [
    `<meta property="og:title" content="${title}" />`,
    `<meta property="og:description" content="${desc}" />`,
    '<meta property="og:type" content="website" />',
    brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
    brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
    '<meta name="twitter:card" content="summary_large_image" />',
    `<meta name="twitter:title" content="${title}" />`,
    `<meta name="twitter:description" content="${desc}" />`,
    brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
  ]
    .filter(Boolean)
    .join('\n    ')
  return html
    .replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
    .replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
    .replace(/<\/head>/i, `    ${tags}\n  </head>`)
}

// Uploaded images — always served, even during maintenance. Force nosniff so a
// stored file is never interpreted as anything other than its declared type
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
// case that global config is ever changed).
app.use(
  '/uploads',
  express.static(UPLOAD_DIR, {
    setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
  }),
)

// ── API docs (Swagger UI) ─────────────────────────────────────────────
// Interactive OpenAPI docs at /api/docs, raw spec at /api/docs.json. The spec
// is generated from route annotations by `npm run swagger` (server/swagger/).
// Loaded lazily and guarded so a missing spec never crashes the server.
try {
  // eslint-disable-next-line global-require
  const swaggerSpec = require('../swagger/swagger-output.json')
  app.get('/api/docs.json', (req, res) => {
    // #swagger.ignore = true
    res.json(swaggerSpec)
  })
  // swagger-ui-express injects an inline bootstrap script and inline styles, which
  // the global 'self'-only script-src would block — relax CSP for this route only.
  const swaggerCsp = helmet.contentSecurityPolicy({
    useDefaults: true,
    directives: {
      'script-src': ["'self'", "'unsafe-inline'"],
      'style-src': ["'self'", "'unsafe-inline'"],
      'img-src': ["'self'", 'data:', 'https:'],
      'upgrade-insecure-requests': null,
    },
  })
  app.use('/api/docs', swaggerCsp, swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
    customSiteTitle: `${brand.name} API docs`,
    swaggerOptions: { persistAuthorization: true },
  }))
} catch (err) {
  errLog.error('Swagger spec not found — run `npm run swagger` to generate it. API docs disabled.', {
    message: err.message,
  })
}

// ── API ───────────────────────────────────────────────────────────────
app.get(
  '/api/health',
  // #swagger.tags = ['Health']
  // #swagger.summary = 'Liveness probe'
  /* #swagger.responses[200] = { description: 'Service is up', content: { "application/json": { schema: { type: "object", properties: { status: { type: "string", example: "ok" } } } } } } */
  (req, res) => res.json({ status: 'ok' }),
)
// CSP violation sink. Mounted here, ahead of the /api 404, and outside /api/v1: it is
// not part of the versioned client contract — it exists for the browser, which learns
// the path from the policy header, never from a client build.
app.post(csp.REPORT_PATH, cspReportLimiter, ...cspReport.parsers, cspReport.receive)

app.use('/api', apiRouter)
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))

// ── /.well-known ──────────────────────────────────────────────────────
// Android App Links verification file at the web root (M9 follow-up). Mounted
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless
// the admin has enabled App Links for this shard (docs/android/APP_LINKS.md).
app.get('/.well-known/assetlinks.json', wellKnown.assetlinks)

// ── Client SPA ────────────────────────────────────────────────────────
// Serve the built React app if present; otherwise show a placeholder so the
// server is usable API-only before the frontend phase.
// Brand assets (logo/hero/favicon) from a mounted directory, used when BRAND_*
// paths point at /brand/*. Optional — the defaults live under the SPA's /assets,
// so this only matters for a custom mount.
if (fs.existsSync(BRAND_DIR)) {
  app.use(
    '/brand',
    express.static(BRAND_DIR, {
      setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'),
    }),
  )
}

if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
  // Serve a branded copy of the index.html shell for every SPA route; assets keep
  // their own cache-friendly static handler.
  const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
  app.use(express.static(CLIENT_DIST, { index: false }))
  app.get('*', (req, res) => res.type('html').send(indexHtml))
} else {
  app.get('*', (req, res) =>
    res
      .type('html')
      .send(
        `<h1>${htmlEscape(brand.name)} API</h1><p>The web client has not been built yet. ` +
          'The API is available under <code>/api/v1</code>.</p>',
      ),
  )
}

// ── Error handler ─────────────────────────────────────────────────────
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
  const status = err.status || (err.name === 'MulterError' ? 400 : 500)
  // Log the stack for server faults; client (4xx) errors stay terse.
  errLog.error(
    `${req.method} ${req.originalUrl} -> ${status} ${err.message}`,
    status >= 500 ? { stack: err.stack } : undefined,
  )
  res.status(status).json({ message: err.message || 'Internal Server Error' })
})

module.exports = app
