import crypto from 'crypto';
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';

// In-process service token for the scheduler's loopback self-calls
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
// a per-boot random constant needs no env/compose config and is never exposed:
// it only travels over the loopback fetch inside the same process. Multi-replica
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
// matching that replica's token. Requests bearing it are treated as the seeded
// admin (DEV_USER) so RBAC + FK-bearing routes work.
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
const INTERNAL_HEADER = 'x-internal-token';

function isInternalCall(req) {
  const got = req.headers[INTERNAL_HEADER];
  if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
  return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
}

// Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
// RBAC v2 gates — matches migration 023's seeded dev row.
export const DEV_USER    = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };

const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS     = 1 * 3600 * 1000;

async function destroyAnd401(req, res) {
  if (req.session?.destroy) {
    await new Promise(r => req.session.destroy(() => r()));
  }
  return res.status(401).json({ error: 'unauthorized' });
}

async function loadUser(id) {
  const { rows } = await pool.query(
    `SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
  return rows[0] || null;
}

export async function requireAuth(req, res, next) {
  // Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
  // and FK-bearing routes work, regardless of AUTH_ENABLED.
  if (isInternalCall(req)) {
    req.user = DEV_USER;
    return next();
  }

  // Dev mode — attach the seeded dev user so FK-bearing routes work.
  if (process.env.AUTH_ENABLED !== 'true') {
    req.user = DEV_USER;
    return next();
  }

  // 1. Session
  if (req.session?.user_id) {
    const now = Date.now();
    const first = req.session.first_seen_at || 0;
    const last  = req.session.last_seen_at  || 0;
    if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
    if (now - last  > IDLE_MS)     return destroyAnd401(req, res);
    const u = await loadUser(req.session.user_id);
    if (!u) return destroyAnd401(req, res);
    req.session.last_seen_at = now;   // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
    req.user = u;
    return next();
  }

  // 2. Bearer
  const bearer = parseBearer(req.headers.authorization);
  if (bearer) {
    const hash = hashToken(bearer);
    const { rows } = await pool.query(
      `SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname,
              u.username, u.display_name, u.role
         FROM api_tokens t JOIN users u ON u.id = t.user_id
        WHERE t.token_hash = $1`, [hash]);
    if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
      pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
        .catch(err => console.error('[auth] token last_used_at update failed:', err.message));
      req.user = {
        id: rows[0].user_id,
        username: rows[0].username,
        display_name: rows[0].display_name,
        role: rows[0].role,
      };
      // Per migration 019: tokens with a bound_hostname can only be used by
      // node-agents reporting that hostname. The /cluster/heartbeat handler
      // enforces this; we just surface the binding here.
      if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
      return next();
    }
  }

  // 3. Nothing matched
  return res.status(401).json({ error: 'unauthorized' });
}

// Gate a route to admins only. requireAuth must run first (it sets req.user).
// 401 when unauthenticated, 403 when authenticated but not an admin.
export function requireAdmin(req, res, next) {
  if (!req.user) return res.status(401).json({ error: 'unauthorized' });
  if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
  return next();
}

// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';

// Paths exempt from the CSRF header check. The bearer-auth exemption (above)
// already covers node-agent because it sends Authorization: Bearer; this set
// is the belt for any future service path that might call us without a
// bearer header. Today it just lets an unauthenticated heartbeat probe
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);

export function requireUiHeader(req, res, next) {
  if (!MUTATING.has(req.method)) return next();
  // Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
  if (isInternalCall(req)) return next();
  // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
  // browsers and can't be drive-by'd from another origin.
  if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
  // Service path carve-outs (e.g. node-agent heartbeat — not a browser).
  if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
  if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
  return res.status(403).json({ error: 'missing X-Requested-With header' });
}
