import type { Db } from '../types/db';
import type { Req } from '../types/http';

const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database.cts');
const { buildDeviceFingerprint } = require('./loginFingerprint.cts');
const { encryptSecret, decryptSecret } = require('./encryptionService.cts');

// Store SHA-256(token) in the DB; the raw token stays only in the cookie.
function hashSession(token: string): string {
  return crypto.createHash('sha256').update(token).digest('hex');
}

const COOKIE_NAME = 'bt_session';
const SINGLE_COOKIE_NAME = 'bt_single_session';
const SESSION_DAYS = 7;

// Pre-computed hash used to equalize login timing when the account is unknown,
// inactive, or OIDC-only. Cost factor 12 matches real password hashes.
const TIMING_EQUALIZATION_HASH = bcrypt.hashSync(crypto.randomBytes(32).toString('hex'), 12);

function envFlag(name: string): boolean | null {
  const value = process.env[name];
  if (value === undefined) return null;
  return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}

function requestLooksHttps(req?: Req): boolean {
  if (!req) return false;
  if (req.secure) return true;
  const proto = req.get?.('x-forwarded-proto') || req.headers?.['x-forwarded-proto'];
  return String(proto || '')
    .split(',')
    .map((s) => s.trim())
    .includes('https');
}

/**
 * Build session-cookie options.
 * COOKIE_SECURE=true/false is explicit. HTTPS=true keeps the old deployment knob.
 * Otherwise, mark cookies Secure only when the current request appears to be HTTPS.
 */
function cookieOpts(req?: Req) {
  const cookieSecure = envFlag('COOKIE_SECURE');
  const httpsSecure = envFlag('HTTPS');
  const secure =
    cookieSecure !== null
      ? cookieSecure
      : httpsSecure !== null
        ? httpsSecure
        : requestLooksHttps(req);

  return {
    httpOnly: true,
    sameSite: 'strict',
    secure,
    maxAge: SESSION_DAYS * 86400 * 1000,
    path: '/',
  };
}

async function login(username: any, password: any) {
  const db: Db = getDb();
  const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);

  // Unknown, inactive, or OIDC-only account: still burn a bcrypt comparison so
  // the response time is indistinguishable from a wrong password.
  if (!user || user.active === 0 || (user.auth_provider && user.auth_provider !== 'local')) {
    await bcrypt.compare(password, TIMING_EQUALIZATION_HASH);
    return null;
  }

  const valid = await bcrypt.compare(password, user.password_hash);
  // Return userId so the route can log the failed attempt against the known account.
  if (!valid) return { error: 'bad_password', userId: user.id };

  // TOTP is enabled — issue a short-lived challenge instead of a session
  if (user.totp_enabled) {
    const { createChallenge } = require('./totpService.cts');
    const challengeToken = createChallenge(getDb(), user.id);
    return { requires_totp: true, challenge_token: challengeToken };
  }

  // WebAuthn is enabled — issue a WebAuthn authentication challenge instead
  if (user.webauthn_enabled) {
    const {
      createAuthenticationChallenge,
      createLoginChallenge,
    } = require('./webauthnService.cts');
    const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
    const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
    return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
  }

  // Clean up expired sessions for this user before creating new session
  try {
    db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(
      user.id,
    );
  } catch (err: any) {
    console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message);
  }

  const sessionId = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
    .toISOString()
    .slice(0, 19)
    .replace('T', ' ');

  db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
    hashSession(sessionId),
    user.id,
    expiresAt,
  );

  // Update last_login_at if column exists (added in v0.17 migration)
  try {
    db.prepare("UPDATE users SET last_login_at = datetime('now') WHERE id = ?").run(user.id);
  } catch {
    /* column may not exist on older schemas */
  }

  return { sessionId, user: publicUser(user) };
}

async function createSession(userId: any) {
  const db: Db = getDb();
  const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
  if (!user) return null;
  if (user.active === 0) return null;

  // Clean up expired sessions for this user before creating new session
  try {
    db.prepare("DELETE FROM sessions WHERE user_id = ? AND expires_at < datetime('now')").run(
      userId,
    );
  } catch (err: any) {
    console.error('[cleanup-error] Failed to cleanup user expired sessions:', err.message);
  }

  const sessionId = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
    .toISOString()
    .slice(0, 19)
    .replace('T', ' ');

  db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
    hashSession(sessionId),
    user.id,
    expiresAt,
  );

  return { sessionId, user: publicUser(user) };
}

function logout(sessionId: any): void {
  if (!sessionId) return;
  getDb().prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(sessionId));
}

/**
 * Regenerate session ID for security (e.g., on privilege escalation).
 */
function rotateSessionId(oldSessionId: any, userId: any): string | null {
  if (!oldSessionId || !userId) return null;

  const db: Db = getDb();

  // Verify the old session belongs to the user and is valid
  const existingSession = db
    .prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now')")
    .get(hashSession(oldSessionId));
  if (!existingSession || existingSession.user_id !== userId) {
    return null;
  }

  // Generate new session ID
  const newSessionId = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
    .toISOString()
    .slice(0, 19)
    .replace('T', ' ');

  // Delete old session and create new one atomically
  try {
    db.transaction(() => {
      db.prepare('DELETE FROM sessions WHERE id = ?').run(hashSession(oldSessionId));
      db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').run(
        hashSession(newSessionId),
        userId,
        expiresAt,
      );
    })();
  } catch {
    return null;
  }

  return newSessionId;
}

function getSessionUser(sessionId: any): any {
  if (!sessionId) return null;
  const row = getDb()
    .prepare(
      `
    SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
           u.active, u.is_default_admin, u.last_seen_version
    FROM sessions s
    JOIN users u ON u.id = s.user_id
    WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1
  `,
    )
    .get(hashSession(sessionId));
  return row || null;
}

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12);
}

function publicUser(u: any) {
  const displayName = u.display_name || null;
  return {
    id: u.id,
    username: u.username,
    display_name: displayName,
    displayName,
    name: displayName || u.username,
    role: u.role,
    active: u.active !== 0,
    is_default_admin: !!u.is_default_admin,
    must_change_password: !!u.must_change_password,
    first_login: !!u.first_login,
    last_seen_version: u.last_seen_version || null,
    webauthn_enabled: !!u.webauthn_enabled,
  };
}

/**
 * Records a successful login with encrypted IP/UA and prunes older entries
 * so each user keeps at most 10 login history rows.
 */
function recordLogin(userId: any, ipAddress: any, userAgent: any, sessionId: any): void {
  const db: Db = getDb();
  const device = buildDeviceFingerprint({ userAgent, ipAddress });

  const encIp = ipAddress ? encryptSecret(ipAddress) : null;
  const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null;
  const sessionFingerprint = sessionId
    ? crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32)
    : null;

  // Collect prior fingerprints before insert to detect new devices
  let priorFingerprints: any[];
  try {
    priorFingerprints = db
      .prepare(
        `
      SELECT device_fingerprint FROM user_login_history
      WHERE user_id = ? AND success = 1
      ORDER BY logged_in_at DESC, id DESC LIMIT 10
    `,
      )
      .all(userId)
      .map((r: any) => r.device_fingerprint);
  } catch {
    priorFingerprints = [];
  }

  let insertedId: any;
  db.transaction(() => {
    const result = db
      .prepare(
        `
      INSERT INTO user_login_history (
        user_id, logged_in_at, ip_address, user_agent,
        browser, os, device_type, device_fingerprint, session_fingerprint, success
      )
      VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, 1)
    `,
      )
      .run(
        userId,
        encIp,
        encUa,
        device.browser,
        device.os,
        device.device_type,
        device.device_fingerprint,
        sessionFingerprint,
      );
    insertedId = result.lastInsertRowid;

    // Keep only the 10 most recent rows for this user
    db.prepare(
      `
      DELETE FROM user_login_history
      WHERE user_id = ? AND id NOT IN (
        SELECT id FROM user_login_history
        WHERE user_id = ?
        ORDER BY logged_in_at DESC, id DESC
        LIMIT 10
      )
    `,
    ).run(userId, userId);
  })();

  // Background tasks: geolocation + new device push alert
  if (insertedId) {
    setImmediate(() => {
      // Geolocation — only when the user has opted in, and skip private/loopback IPs
      const loginUser = getDb()
        .prepare('SELECT geolocation_enabled FROM users WHERE id = ?')
        .get(userId);
      if (ipAddress && loginUser?.geolocation_enabled) {
        const isPrivate = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1|localhost)/i.test(
          ipAddress,
        );
        if (!isPrivate) {
          fetch(`http://ip-api.com/json/${ipAddress}?fields=status,city,country,regionName,isp`, {
            signal: AbortSignal.timeout(5000),
          })
            .then((r: Response) => r.json())
            .then((data: any) => {
              if (data.status !== 'success') return;
              const updDb = getDb();
              updDb
                .prepare(
                  `
                UPDATE user_login_history
                SET location_city=?, location_country=?, location_region=?, location_isp=?
                WHERE id=?
              `,
                )
                .run(
                  encryptSecret(data.city || ''),
                  encryptSecret(data.country || ''),
                  encryptSecret(data.regionName || ''),
                  encryptSecret(data.isp || ''),
                  insertedId,
                );
            })
            .catch(() => {});
        }
      }

      // New device alert via push notification
      const isNewDevice =
        device.device_fingerprint && !priorFingerprints.includes(device.device_fingerprint);
      if (isNewDevice) {
        try {
          const { sendPushToUser } = require('./notificationService.cts')._push;
          const alertUser = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId);
          if (alertUser?.notify_push_enabled && alertUser?.push_channel) {
            const deviceDesc = [
              device.browser,
              device.os,
              device.device_type !== 'desktop' ? device.device_type : null,
            ]
              .filter(Boolean)
              .join(' / ');
            const ipDesc = ipAddress || 'unknown IP';
            sendPushToUser(
              alertUser,
              'New device sign-in — Bill Tracker',
              `A sign-in was detected from a new device: ${deviceDesc} (${ipDesc}).`,
              'today',
            ).catch(() => {});
          }
        } catch {
          /* push is best-effort */
        }
      }
    });
  }
}

/**
 * Records a failed login attempt (wrong password for a known account).
 */
function recordFailedLogin(userId: any, ipAddress: any, userAgent: any): void {
  if (!userId) return;
  try {
    const db: Db = getDb();
    const device = buildDeviceFingerprint({ userAgent, ipAddress });
    const encIp = ipAddress ? encryptSecret(ipAddress) : null;
    const encUa = userAgent ? encryptSecret(userAgent.slice(0, 500)) : null;

    db.transaction(() => {
      db.prepare(
        `
        INSERT INTO user_login_history (
          user_id, logged_in_at, ip_address, user_agent,
          browser, os, device_type, device_fingerprint, success
        )
        VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, 0)
      `,
      ).run(
        userId,
        encIp,
        encUa,
        device.browser,
        device.os,
        device.device_type,
        device.device_fingerprint,
      );

      // Prune: keep 10 most recent (success or fail) per user
      db.prepare(
        `
        DELETE FROM user_login_history
        WHERE user_id = ? AND id NOT IN (
          SELECT id FROM user_login_history
          WHERE user_id = ?
          ORDER BY logged_in_at DESC, id DESC
          LIMIT 10
        )
      `,
      ).run(userId, userId);
    })();
  } catch {
    /* never fail a request because of history logging */
  }
}

// Prune expired sessions — called by daily worker
function pruneExpiredSessions() {
  const result = getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
  console.log(`[cleanup] Purged ${result.changes} expired sessions`);
  return result;
}

/**
 * Invalidate all sessions for a user except for a specific session ID.
 */
function invalidateOtherSessions(userId: any, keepSessionId: any) {
  if (!userId) return { changes: 0 };

  const db: Db = getDb();
  let result: any;

  if (keepSessionId) {
    result = db
      .prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?')
      .run(userId, hashSession(keepSessionId));
  } else {
    result = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
  }

  return result;
}

module.exports = {
  login,
  logout,
  createSession,
  getSessionUser,
  hashPassword,
  publicUser,
  pruneExpiredSessions,
  cookieOpts,
  COOKIE_NAME,
  SINGLE_COOKIE_NAME,
  SESSION_DAYS,
  rotateSessionId,
  invalidateOtherSessions,
  recordLogin,
  recordFailedLogin,
};
