// ─────────────────────────────────────────────────────────────
// server.js — entry point.
//
// Binds to 127.0.0.1 only. Nginx reverse-proxies to this port.
// Never expose Node directly to the public internet.
// ─────────────────────────────────────────────────────────────
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import authRouter from './src/auth.js';
import { requireAuth, requireAdmin } from './src/middleware.js';
import { q } from './src/db.js'; // also side-effect: opens DB + runs schema

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();

// ─── Trust Nginx as the single upstream proxy ────────────────
// This makes req.ip reflect X-Forwarded-For (the real client IP)
// instead of 127.0.0.1. Only "loopback" is trusted, so spoofed
// XFF headers from the public internet are ignored.
app.set('trust proxy', 'loopback');

// ─── Body parsers ────────────────────────────────────────────
app.use(express.json({ limit: '4kb' }));
app.use(cookieParser());

// ─── Security headers on every response ──────────────────────
app.use((req, res, next) => {
  res.set({
    'X-Content-Type-Options': 'nosniff',
    'X-Frame-Options': 'DENY',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
    // Tight CSP — only our own origin, and inline <style> blocks are allowed
    // because the design system ships styles inline inside the artifacts.
    'Content-Security-Policy': [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join('; '),
  });
  next();
});

// ─── Tiny request log ────────────────────────────────────────
app.use((req, res, next) => {
  const started = Date.now();
  res.on('finish', () => {
    const ms = Date.now() - started;
    const line = `${new Date().toISOString()} ${req.ip} ${req.method} ${req.path} → ${res.statusCode} (${ms}ms)`;
    console.log(line);
  });
  next();
});

// ─── Auth endpoints (public) ─────────────────────────────────
app.use('/auth', authRouter);

// ─── Bifrost join tracking (gated) ───────────────────────────
// Records every click of the final CTA button as its own row so the
// full history per user is preserved (who, when, which session).
app.post('/api/bifrost-join', requireAuth, (req, res) => {
  const { email, id: sessionId } = req.session;
  const clickedAt = Date.now();
  q.recordJoin.run(email, clickedAt, sessionId);
  res.json({ clicked_at: clickedAt });
});

// ─── Admin surface (gated, unlisted) ─────────────────────────
// Accessible only to invite rows with `is_admin=1`. Non-admins —
// authed or not — get a plain 404 from requireAdmin so the route's
// existence is not leaked. Admin files live in ./admin/ (outside
// both public/ and protected/) so they can only be reached through
// these explicit routes.
//
// The public URL path is deliberately obscure — `/fenjaops` rather
// than `/admin` — so scripted probes of common admin paths miss.
// Internal names (files, middleware, CLI subcommand) stay as "admin"
// since the obscurity is a URL concern only, not an identity one.
// Grant admin via:
//   node bin/invite.js admin add <email>
app.use('/fenjaops',
  requireAuth,
  requireAdmin,
  express.static(path.join(__dirname, 'admin'), { index: 'index.html', maxAge: 0 })
);

app.get('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
  res.json(q.listInvites.all());
});

// Create a non-admin invite from the admin UI. Admin promotion is
// intentionally NOT exposed — granting admin stays on the CLI
// (`bin/invite.js admin add`) so that compromising a web session
// cannot escalate the invite list. Audit trail: invited_by is the
// admin's email (not "cli").
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
app.post('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
  const { email, first_name } = req.body ?? {};

  if (typeof email !== 'string' || !EMAIL_RE.test(email.trim())) {
    return res.status(400).json({ error: 'invalid_email' });
  }
  const normalized = email.trim().toLowerCase();

  let firstName = null;
  if (first_name != null && first_name !== '') {
    if (typeof first_name !== 'string') {
      return res.status(400).json({ error: 'invalid_first_name' });
    }
    const trimmed = first_name.trim();
    if (trimmed.length > 64) {
      return res.status(400).json({ error: 'first_name_too_long' });
    }
    firstName = trimmed || null;
  }

  if (q.getInvite.get(normalized)) {
    return res.status(409).json({ error: 'already_invited' });
  }

  q.upsertInvite.run(normalized, firstName, Date.now(), req.session.email);
  return res.status(201).json({ email: normalized, first_name: firstName });
});

app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
  res.json({
    clicks:       q.listJoins.all(),
    summary:      q.summariseJoins.all(),
    total_clicks: q.countJoins.get().n,
    unique_users: q.countUniqueJoiners.get().n,
  });
});

// ─── Root dispatch ───────────────────────────────────────────
// GET /           → always the entrance shell. If authed, entrance.js routes
//                   the user to the welcome step client-side.
// GET /timeline   → gated timeline page (protected/index.html).
app.get('/', (req, res) => {
  return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
});

// UA sniff for the mobile minimum-viable view. Covers the common
// phone cases; tablets on iPadOS falsely identify as desktop and will
// see the full animated site, which is the right default for a 10"+
// screen. The `?view=mobile` / `?view=desktop` query override exists
// for cases where the guess is wrong or someone wants to preview the
// other version — it takes precedence over the UA regex.
const MOBILE_UA_RE = /\b(iPhone|iPod|Android.*Mobile|Mobile.*Firefox|IEMobile|BlackBerry|Opera Mini)\b/i;
function wantsMobileView(req) {
  const forced = (req.query.view || '').toLowerCase();
  if (forced === 'mobile')  return true;
  if (forced === 'desktop') return false;
  return MOBILE_UA_RE.test(req.headers['user-agent'] || '');
}

app.get('/timeline', requireAuth, (req, res) => {
  if (wantsMobileView(req)) {
    return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html'));
  }
  return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
});

// ─── Public static assets (entrance.js, etc.) ────────────────
// Fallthrough so Express can still try the routes below if nothing matches.
app.use(
  express.static(path.join(__dirname, 'public'), {
    index: false,        // don't auto-serve entrance.html at /
    extensions: ['html'],
    fallthrough: true,
    maxAge: 0,
  })
);

// ─── GATED — everything in protected/ needs a session cookie ─
// Handles the authed home page assets:
//   /timeline.js, /vendor/*, /fenja/colors_and_type.css, /archive.html, etc.
app.use(
  requireAuth,
  express.static(path.join(__dirname, 'protected'), {
    index: false,        // we dispatch / ourselves above
    extensions: ['html'],
    maxAge: 0,
  })
);

// ─── 404 fallback ────────────────────────────────────────────
app.use((req, res) => {
  if (req.accepts('html')) {
    return res.status(404).send('<h1>404 — not found</h1>');
  }
  res.status(404).end();
});

// ─── Error handler ───────────────────────────────────────────
app.use((err, req, res, _next) => {
  console.error('[error]', err);
  res.status(500).json({ error: 'internal' });
});

// ─── Start ───────────────────────────────────────────────────
const port = Number(process.env.PORT || 3000);
const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`;

app.listen(port, '127.0.0.1', () => {
  console.log(`[bifrost] listening on 127.0.0.1:${port}`);
  console.log(`[bifrost] public origin: ${origin}`);
});

// ─── Graceful shutdown ───────────────────────────────────────
for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, () => {
    console.log(`[bifrost] ${sig} received, exiting.`);
    process.exit(0);
  });
}
