import express from 'express';
import pool from '../db/pool.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
import {
  generateSecret, verifyToken, otpauthURI, generateRecoveryCodes,
} from '../auth/totp.js';
import { issueTicket, redeemTicket } from '../auth/mfa-tickets.js';
import {
  isConfigured as googleConfigured, buildAuthUrl, exchangeAndVerify,
} from '../auth/google-oauth.js';
import { randomBytes } from 'node:crypto';

const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';

const router = express.Router();

// Real users = anyone except the seeded dev row.
async function realUserCount() {
  const { rows } = await pool.query(
    `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
  return rows[0].n;
}

// GET /api/v1/auth/setup-required
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
router.get('/setup-required', async (_req, res, next) => {
  try {
    res.json({ required: (await realUserCount()) === 0 });
  } catch (err) { next(err); }
});


const MIN_PASSWORD_LEN = 12;

function badRequest(res, msg) { return res.status(400).json({ error: msg }); }

// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
router.post('/setup', async (req, res, next) => {
  try {
    const { username, password } = req.body || {};
    if (!username || typeof username !== 'string') return badRequest(res, 'username required');
    if (!password || typeof password !== 'string') return badRequest(res, 'password required');
    if (password.length < MIN_PASSWORD_LEN)        return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');

    if ((await realUserCount()) > 0) {
      return res.status(409).json({ error: 'setup already complete' });
    }

    const hash = await hashPassword(password);
    const { rows } = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role)
       VALUES ($1, $2, $1, 'admin')
       RETURNING id, username, display_name`,
      [username.trim(), hash]
    );
    const user = rows[0];

    // Immediately log them in.
    req.session.user_id       = user.id;
    req.session.first_seen_at = Date.now();
    req.session.last_seen_at  = Date.now();
    await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));

    res.json({ user });
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
    next(err);
  }
});

// POST /api/v1/auth/login — authenticate an existing user by username + password.
router.post('/login', async (req, res, next) => {
  try {
    const ip = req.ip || req.socket?.remoteAddress || 'unknown';
    const delay = ipBackoff.delayMs(ip);
    if (delay > 0) await new Promise(r => setTimeout(r, delay));
    
    const { username, password } = req.body || {};
    if (!username || !password) {
      ipBackoff.recordFailure(ip);
      return res.status(401).json({ error: 'invalid credentials' });
    }

    const { rows } = await pool.query(
      `SELECT id, username, display_name, password_hash, totp_enabled FROM users WHERE username = $1 AND id <> $2`,
      [username.trim(), DEV_USER_ID]
    );
    if (rows.length === 0) {
      // Pre-computed bcrypt hash of a value that no real password input will match.
      // Used to keep the user-not-found response time uniform with the wrong-password
      // path (~180ms at cost 12) so user enumeration via timing isn't possible.
      await comparePassword(password, DUMMY_PASSWORD_HASH);
      ipBackoff.recordFailure(ip);
      return res.status(401).json({ error: 'invalid credentials' });
    }
    const user = rows[0];
    if (!(await comparePassword(password, user.password_hash))) {
      ipBackoff.recordFailure(ip);
      return res.status(401).json({ error: 'invalid credentials' });
    }

    // Second factor: if TOTP is enabled, don't create a session yet. Hand back
    // a short-lived ticket the client redeems via /login/totp with a code.
    // Crucially: do NOT clear the per-IP failure counter here. If we did, each
    // /login retry would reset the backoff and let an attacker brute the 6-digit
    // TOTP space (10^6) with no per-attempt delay. The counter is cleared
    // inside establishSession() once MFA has actually passed.
    if (user.totp_enabled) {
      return res.json({
        mfa_required: true,
        ticket: issueTicket(user.id, { ip, userAgent: req.get('user-agent') }),
      });
    }

    await establishSession(req, user, ip);
    res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
  } catch (err) { next(err); }
});

// Write the session and wait for it to persist before responding. Extracted so
// both the password-only and the MFA-completion paths share one implementation.
// Clears the per-IP failure counter only here — after every required factor has
// actually been proven (password [+ TOTP if enabled, or OAuth + TOTP]).
async function establishSession(req, user, ip) {
  req.session.user_id       = user.id;
  req.session.first_seen_at = Date.now();
  req.session.last_seen_at  = Date.now();
  // The critical line — wait for the row to land in `sessions` before responding.
  // Without this, the SPA's next request races the store write, hits 401, and
  // the prior bounce-to-login logic produced an infinite loop.
  await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
  if (ip) ipBackoff.recordSuccess(ip);
  pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
    .catch(err => console.error('[auth] last_login_at update failed:', err.message));
}

// POST /api/v1/auth/login/totp { ticket?, code } — second login step. `code` is
// either a 6-digit TOTP or a one-time recovery code. The ticket comes from the
// request body (password-login path) or req.session.mfa_ticket (Google path).
router.post('/login/totp', async (req, res, next) => {
  try {
    const ip = req.ip || req.socket?.remoteAddress || 'unknown';
    // Rate-limit the second factor with the same per-IP backoff as /login so
    // the 6-digit code space can't be hammered.
    const delay = ipBackoff.delayMs(ip);
    if (delay > 0) await new Promise(r => setTimeout(r, delay));

    const { ticket: bodyTicket, code } = req.body || {};
    const ticket = bodyTicket || req.session?.mfa_ticket;
    if (req.session?.mfa_ticket) delete req.session.mfa_ticket;
    // Bound to the issuing request's IP + UA — replays from a different origin
    // redeem to null. See mfa-tickets.js for the binding model.
    const userId = redeemTicket(ticket, { ip, userAgent: req.get('user-agent') });
    if (!userId) {
      ipBackoff.recordFailure(ip);
      return res.status(401).json({ error: 'invalid or expired ticket' });
    }
    if (!code) return res.status(400).json({ error: 'code required' });

    const { rows } = await pool.query(
      `SELECT id, username, display_name, totp_secret, totp_enabled, totp_last_counter
         FROM users WHERE id = $1`, [userId]);
    const user = rows[0];
    if (!user || !user.totp_enabled || !user.totp_secret) {
      return res.status(401).json({ error: 'invalid credentials' });
    }

    // verifyToken returns the matched counter on success. Reject codes at
    // counters ≤ totp_last_counter to prevent replay within the same step.
    // The CAS-style UPDATE makes this race-free under concurrent submissions.
    const matchedCounter = verifyToken(user.totp_secret, code);
    let ok = false;
    if (matchedCounter !== null) {
      const lastCounter = BigInt(user.totp_last_counter || 0);
      if (BigInt(matchedCounter) > lastCounter) {
        const upd = await pool.query(
          `UPDATE users SET totp_last_counter = $1
             WHERE id = $2 AND totp_last_counter < $1`,
          [String(matchedCounter), user.id]
        );
        ok = upd.rowCount === 1;
      }
      // matchedCounter ≤ last → silent replay; falls through to recovery-code
      // path which also fails → 401. Same UX as a wrong code, no info leak.
    }
    if (!ok) ok = await consumeRecoveryCode(user.id, code);
    if (!ok) {
      ipBackoff.recordFailure(ip);
      // The ticket was single-use; the client must restart from /login.
      return res.status(401).json({ error: 'invalid code' });
    }

    // recordSuccess is called by establishSession once the session lands —
    // that's the first moment we know every required factor has passed.
    await establishSession(req, user, ip);
    res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
  } catch (err) { next(err); }
});

// Check a recovery code against the user's unused codes; mark it spent on match.
// The marking is atomic (UPDATE ... WHERE used_at IS NULL with a rowCount check)
// so two concurrent redemptions of the same code can't both succeed.
async function consumeRecoveryCode(userId, code) {
  const cleaned = String(code).trim().toLowerCase();
  if (!/^[0-9a-f]{5}-[0-9a-f]{5}$/.test(cleaned)) return false;
  const { rows } = await pool.query(
    `SELECT id, code_hash FROM user_recovery_codes WHERE user_id = $1 AND used_at IS NULL`, [userId]);
  for (const row of rows) {
    if (await comparePassword(cleaned, row.code_hash)) {
      const upd = await pool.query(
        `UPDATE user_recovery_codes SET used_at = NOW() WHERE id = $1 AND used_at IS NULL`, [row.id]);
      // Lost the race if another request already consumed it.
      return upd.rowCount === 1;
    }
  }
  return false;
}

// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
  if (!req.session) return res.status(204).end();
  req.session.destroy(err => {
    if (err) console.error('[auth] session destroy failed:', err.message);
    res.clearCookie('dragonflight.sid', { path: '/' });
    res.status(204).end();
  });
});

// GET /api/v1/auth/me
router.get('/me', requireAuth, (req, res) => {
  res.json({
    id:           req.user.id,
    username:     req.user.username,
    display_name: req.user.display_name,
    role:         req.user.role,
    totp_enabled: !!req.user.totp_enabled,
  });
});

// POST /api/v1/auth/password { current_password, new_password }
router.post('/password', requireAuth, async (req, res, next) => {
  try {
    const { current_password, new_password } = req.body || {};
    if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
    if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');

    const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
    if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
    if (!(await comparePassword(current_password, rows[0].password_hash))) {
      return badRequest(res, 'current password is incorrect');
    }
    const newHash = await hashPassword(new_password);
    await pool.query(
      `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
      [newHash, req.user.id]
    );
    res.status(204).end();
  } catch (err) { next(err); }
});

// ── TOTP enrollment (all require an active session) ─────────────────────────

// POST /api/v1/auth/totp/setup — begin enrollment. Generates a fresh secret,
// stores it (but leaves totp_enabled=false), and returns the otpauth URI + the
// base32 secret for manual entry. Enrollment isn't active until /enable
// confirms a code, so a started-but-abandoned setup never locks the user out.
router.post('/totp/setup', requireAuth, async (req, res, next) => {
  try {
    const { rows } = await pool.query(`SELECT totp_enabled FROM users WHERE id = $1`, [req.user.id]);
    if (rows[0]?.totp_enabled) return res.status(409).json({ error: 'TOTP already enabled' });

    const secret = generateSecret();
    await pool.query(`UPDATE users SET totp_secret = $1 WHERE id = $2`, [secret, req.user.id]);
    const uri = otpauthURI(secret, req.user.username || 'user');

    // QR rendering is optional — the otpauth URI + manual secret are sufficient
    // to enroll. Render a data-URL QR only if the optional `qrcode` dep is
    // present, so a missing dependency degrades instead of 500-ing.
    let qr = null;
    try {
      const QRCode = (await import('qrcode')).default;
      qr = await QRCode.toDataURL(uri);
    } catch { /* qrcode not installed — client falls back to manual entry */ }

    res.json({ secret, otpauth_uri: uri, qr });
  } catch (err) { next(err); }
});

// POST /api/v1/auth/totp/enable { code } — confirm enrollment with a code from
// the authenticator. On success, flips totp_enabled and returns one-time
// recovery codes (shown exactly once).
router.post('/totp/enable', requireAuth, async (req, res, next) => {
  try {
    const { code } = req.body || {};
    if (!code) return badRequest(res, 'code required');

    const { rows } = await pool.query(
      `SELECT totp_secret, totp_enabled FROM users WHERE id = $1`, [req.user.id]);
    const row = rows[0];
    if (!row?.totp_secret) return badRequest(res, 'start setup first');
    if (row.totp_enabled)  return res.status(409).json({ error: 'TOTP already enabled' });
    const enrollCounter = verifyToken(row.totp_secret, code);
    if (enrollCounter === null) return badRequest(res, 'incorrect code');

    const recovery = generateRecoveryCodes(10);
    const hashes = await Promise.all(recovery.map(c => hashPassword(c)));
    // Enable + seed totp_last_counter to the enrollment code's counter so the
    // same code can't be reused on first login. Replace any stale recovery
    // codes atomically.
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(
        `UPDATE users SET totp_enabled = TRUE, totp_last_counter = $2 WHERE id = $1`,
        [req.user.id, String(enrollCounter)]
      );
      await client.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
      for (const h of hashes) {
        await client.query(
          `INSERT INTO user_recovery_codes (user_id, code_hash) VALUES ($1, $2)`, [req.user.id, h]);
      }
      await client.query('COMMIT');
    } catch (e) {
      await client.query('ROLLBACK').catch(() => {});
      throw e;
    } finally { client.release(); }

    res.json({ enabled: true, recovery_codes: recovery });
  } catch (err) { next(err); }
});

// POST /api/v1/auth/totp/disable { password } — turn off 2FA. Requires the
// account password as a confirmation so a hijacked live session can't silently
// strip the second factor.
router.post('/totp/disable', requireAuth, async (req, res, next) => {
  try {
    const { password } = req.body || {};
    if (!password) return badRequest(res, 'password required');
    const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
    if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
    if (!(await comparePassword(password, rows[0].password_hash))) {
      return badRequest(res, 'incorrect password');
    }
    await pool.query(
      `UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0 WHERE id = $1`,
      [req.user.id]
    );
    await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.user.id]);
    res.status(204).end();
  } catch (err) { next(err); }
});

// ── Google OAuth (OIDC) sign-in ─────────────────────────────────────────────
// All Google routes are config-gated: without GOOGLE_CLIENT_ID/SECRET and
// OAUTH_REDIRECT_URL they 404, so a deployment without SSO is unaffected.

// GET /api/v1/auth/google/enabled — cheap, no auth. Lets the login screen decide
// whether to render the "Sign in with Google" button.
router.get('/google/enabled', (_req, res) => {
  res.json({ enabled: googleConfigured() });
});

// GET /api/v1/auth/google — kick off the OAuth dance. Stores an anti-CSRF state
// in the session and redirects to Google's consent screen.
router.get('/google', async (req, res, next) => {
  try {
    if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
    const state = randomBytes(16).toString('hex');
    req.session.oauth_state = state;
    await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
    res.redirect(await buildAuthUrl(state));
  } catch (err) { next(err); }
});

// GET /api/v1/auth/google/callback — Google redirects back here with ?code&state.
// Verifies the ID token, enforces the allowed domain, auto-provisions a viewer
// on first login, establishes the session, then redirects to the SPA.
router.get('/google/callback', async (req, res, next) => {
  try {
    if (!googleConfigured()) return res.status(404).json({ error: 'google sign-in not configured' });
    const { code, state } = req.query;
    const expected = req.session.oauth_state;
    delete req.session.oauth_state;
    if (!code || !state || !expected || state !== expected) {
      return res.status(400).json({ error: 'invalid oauth state' });
    }

    const profile = await exchangeAndVerify(code);
    const user = await resolveGoogleUser(profile);

    // If this account has TOTP enabled, Google is only the FIRST factor — route
    // through the same second-factor step as password login. The ticket lives in
    // the session (not the URL) and the SPA prompts for the code.
    if (user.totp_enabled) {
      const ip = req.ip || req.socket?.remoteAddress || 'unknown';
      req.session.mfa_ticket = issueTicket(user.id, {
        ip,
        userAgent: req.get('user-agent'),
      });
      await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
      return res.redirect('/?mfa=1');
    }

    const ip = req.ip || req.socket?.remoteAddress || 'unknown';
    await establishSession(req, user, ip);

    // Redirect to the SPA root; AuthGate will re-check /auth/me and render the app.
    res.redirect('/');
  } catch (err) {
    // Surface a friendly message on the login screen rather than a raw 500.
    if (err.status === 403) return res.redirect('/?auth_error=domain');
    if (err.status === 401) return res.redirect('/?auth_error=google');
    next(err);
  }
});

// Map a verified Google profile to a Dragonflight user row.
//
// Resolution order:
//   1. Existing link by google_sub → that user.
//   2. Otherwise auto-provision a fresh 'viewer'.
//
// We deliberately do NOT auto-link to an existing account by matching email:
// that would let anyone who controls a Google address with the same email sign
// in as a pre-existing local (possibly admin) account, bypassing its password
// and TOTP. Linking an existing account to Google is an explicit, authenticated
// action (a future "connect Google" under Settings), not something a login does.
async function resolveGoogleUser(profile) {
  const found = await pool.query(
    `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
  if (found.rows.length) return found.rows[0];

  const base = (profile.email.split('@')[0] || 'user').replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user';
  let username = base, n = 1;
  while ((await pool.query(`SELECT 1 FROM users WHERE username = $1`, [username])).rows.length) {
    username = base + (++n);
  }

  try {
    const ins = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role, email, google_sub)
       VALUES ($1, NULL, $2, 'viewer', $3, $4)
       RETURNING id, username, display_name, totp_enabled`,
      [username, profile.name, profile.email, profile.sub]);
    return ins.rows[0];
  } catch (err) {
    // Concurrent first-login race: the unique google_sub index rejected our
    // INSERT because a sibling request just created the row. Re-resolve.
    if (err.code === '23505') {
      const retry = await pool.query(
        `SELECT id, username, display_name, totp_enabled FROM users WHERE google_sub = $1`, [profile.sub]);
      if (retry.rows.length) return retry.rows[0];
    }
    throw err;
  }
}

export default router;
export { realUserCount, resolveGoogleUser, consumeRecoveryCode };
