<?php
// app/services/AuthService.php

declare(strict_types=1);

namespace ScMedia\Services;

use ScMedia\Db\Db;
use ScMedia\Http\Response;
use Throwable;

final class AuthService {
  private Db $db;
  private array $config;
  private string $jwtSecret;
  private int $accessTtl;
  private int $refreshTtlWebDays;
  private int $refreshTtlMobileDays;
  private int $challengeTtl;
  private int $passwordResetTtlMinutes;
  private int $maxDevices;
  private bool $returnResetToken;
  private int $loginMax;
  private int $loginWindow;
  private int $loginBlock;
  private int $mfaMax;
  private int $mfaWindow;
  private int $mfaBlock;
  private int $sseKeyTtl;
  private int $sseSessionTtl;

  public function __construct(Db $db, array $config) {
    $this->db = $db;
    $this->config = $config;

    $auth = $config['auth'] ?? [];
    $this->jwtSecret = (string)($auth['jwt_secret'] ?? 'CHANGE_ME_JWT_SECRET');
    $this->accessTtl = (int)($auth['access_ttl_seconds'] ?? 900);
    $this->refreshTtlWebDays = (int)($auth['refresh_ttl_web_days'] ?? 14);
    $this->refreshTtlMobileDays = (int)($auth['refresh_ttl_mobile_days'] ?? 30);
    $this->challengeTtl = (int)($auth['challenge_ttl_seconds'] ?? 300);
    $this->passwordResetTtlMinutes = (int)($auth['password_reset_ttl_minutes'] ?? 30);
    $this->maxDevices = (int)($auth['max_devices'] ?? 5);
    $this->returnResetToken = (bool)($auth['return_reset_token'] ?? false);
    $this->loginMax = (int)($auth['rate_limit_login_max'] ?? 10);
    $this->loginWindow = (int)($auth['rate_limit_login_window'] ?? 900);
    $this->loginBlock = (int)($auth['rate_limit_login_block'] ?? 900);
    $this->mfaMax = (int)($auth['rate_limit_mfa_max'] ?? 6);
    $this->mfaWindow = (int)($auth['rate_limit_mfa_window'] ?? 900);
    $this->mfaBlock = (int)($auth['rate_limit_mfa_block'] ?? 900);
    $this->sseKeyTtl = (int)($auth['sse_key_ttl_seconds'] ?? 90);
    $this->sseSessionTtl = (int)($auth['sse_session_ttl_seconds'] ?? 20);
  }
  
  public function setSseSessionTtl(int $seconds): void {
    $this->sseSessionTtl = max(1, $seconds);
  }

  public function requireAuth(?string $role = null): array {
    $token = $this->readBearerToken();
    if ($token === '') {
      Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Missing token']], 401);
      exit;
    }

    $claims = $this->verifyJwt($token);
    if ($claims === null) {
      Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Invalid token']], 401);
      exit;
    }

    $userId = (int)($claims['sub'] ?? 0);
    if ($userId <= 0) {
      Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Invalid token subject']], 401);
      exit;
    }

    $user = $this->db->fetchOne("SELECT id, email, status, token_version FROM users WHERE id = :id", [':id' => $userId]);
    if (!$user || (string)($user['status'] ?? '') !== 'active') {
      Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'User disabled']], 401);
      exit;
    }

    $tokenVersion = (int)($claims['ver'] ?? 0);
    if ($tokenVersion !== (int)($user['token_version'] ?? 0)) {
      Response::json(['ok' => false, 'error' => ['code' => 'UNAUTHORIZED', 'message' => 'Token expired']], 401);
      exit;
    }

    $roles = $this->getUserRoles($userId);
    if ($role !== null && !in_array($role, $roles, true)) {
      Response::json(['ok' => false, 'error' => ['code' => 'FORBIDDEN', 'message' => 'Insufficient role']], 403);
      exit;
    }

    return [
      'id' => $userId,
      'email' => (string)($user['email'] ?? ''),
      'roles' => $roles,
    ];
  }

  public function requirePageAuth(?string $role = null): array {
    $token = $this->readBearerToken();
    if ($token === '') {
      $this->redirectToLogin();
    }

    $claims = $this->verifyJwt($token);
    if ($claims === null) {
      $this->redirectToLogin();
    }

    $userId = (int)($claims['sub'] ?? 0);
    if ($userId <= 0) {
      $this->redirectToLogin();
    }

    $user = $this->db->fetchOne("SELECT id, email, status, token_version FROM users WHERE id = :id", [':id' => $userId]);
    if (!$user || (string)($user['status'] ?? '') !== 'active') {
      $this->redirectToLogin();
    }

    $tokenVersion = (int)($claims['ver'] ?? 0);
    if ($tokenVersion !== (int)($user['token_version'] ?? 0)) {
      $this->redirectToLogin();
    }

    $roles = $this->getUserRoles($userId);
    if ($role !== null && !in_array($role, $roles, true)) {
      $this->redirectToLogin();
    }

    return [
      'id' => (int)$user['id'],
      'email' => (string)$user['email'],
      'roles' => $roles,
    ];
  }

  public function register(string $email, string $password): array {
    $email = strtolower(trim($email));
    if ($email === '' || $password === '') {
      return ['ok' => false, 'error' => 'Email and password required'];
    }

    $exists = $this->db->fetchOne("SELECT id FROM users WHERE email = :e", [':e' => $email]);
    if ($exists) {
      return ['ok' => false, 'error' => 'User already exists'];
    }

    $hash = password_hash($password, PASSWORD_DEFAULT);
    $this->db->begin();
    try {
      $this->db->exec("
        INSERT INTO users (email, password_hash, status, token_version, created_at, updated_at)
        VALUES (:e, :h, 'active', 1, NOW(), NOW())
      ", [':e' => $email, ':h' => $hash]);
      $userId = (int)$this->db->lastInsertId();

      $role = $this->isFirstUser($userId) ? 'admin' : 'user';
      $this->assignRolesInternal($userId, [$role]);

      $this->audit('register', $userId, 'user', $userId, ['role' => $role]);
      $this->db->commit();
      return ['ok' => true, 'user_id' => $userId, 'role' => $role];
    } catch (Throwable $e) {
      $this->db->rollBack();
      return ['ok' => false, 'error' => $e->getMessage()];
    }
  }

  public function login(string $email, string $password, string $clientType, string $deviceId = ''): array {
    $blocked = $this->isRateLimited('login');
    if ($blocked > 0) {
      return ['ok' => false, 'error' => 'Too many attempts', 'retry_after' => $blocked];
    }
    $email = strtolower(trim($email));
    $user = $this->db->fetchOne("SELECT id, email, password_hash, status FROM users WHERE email = :e", [':e' => $email]);
    if (!$user || (string)($user['status'] ?? '') !== 'active') {
      $this->registerFailure('login');
      $this->audit('login_failed', null, 'user', null, ['email' => $email]);
      return ['ok' => false, 'error' => 'Invalid credentials'];
    }

    if (!password_verify($password, (string)($user['password_hash'] ?? ''))) {
      $this->registerFailure('login');
      $this->audit('login_failed', (int)$user['id'], 'user', (int)$user['id'], ['reason' => 'bad_password']);
      return ['ok' => false, 'error' => 'Invalid credentials'];
    }

    $userId = (int)$user['id'];
    $this->resetRateLimit('login');
    if ($this->isMfaEnabled($userId)) {
      $challenge = $this->createLoginChallenge($userId);
      $this->audit('login_mfa_required', $userId, 'user', $userId);
      return ['ok' => true, 'mfa_required' => true, 'challenge_token' => $challenge];
    }

    $tokens = $this->issueTokens($userId, $clientType, $deviceId);
    $this->db->exec("UPDATE users SET last_login_at = NOW() WHERE id = :id", [':id' => $userId]);
    $this->audit('login', $userId, 'user', $userId);
    return ['ok' => true, 'tokens' => $tokens];
  }

  public function verifyMfa(string $challengeToken, string $code, string $clientType, string $deviceId = ''): array {
    $blocked = $this->isRateLimited('mfa');
    if ($blocked > 0) {
      return ['ok' => false, 'error' => 'Too many attempts', 'retry_after' => $blocked];
    }
    $challenge = $this->findChallenge($challengeToken);
    if (!$challenge) {
      $this->registerFailure('mfa');
      return ['ok' => false, 'error' => 'Invalid or expired challenge'];
    }

    $userId = (int)$challenge['user_id'];
    $valid = $this->verifyMfaCode($userId, $code);
    if (!$valid) {
      $this->registerFailure('mfa');
      $this->audit('mfa_failed', $userId, 'user', $userId);
      return ['ok' => false, 'error' => 'Invalid code'];
    }

    $this->resetRateLimit('mfa');
    $this->db->exec("UPDATE auth_challenges SET used_at = NOW() WHERE id = :id", [':id' => (int)$challenge['id']]);
    $tokens = $this->issueTokens($userId, $clientType, $deviceId);
    $this->db->exec("UPDATE users SET last_login_at = NOW() WHERE id = :id", [':id' => $userId]);
    $this->audit('login', $userId, 'user', $userId, ['mfa' => true]);
    return ['ok' => true, 'tokens' => $tokens];
  }

  public function refresh(string $refreshToken, string $clientType, string $deviceId = ''): array {
    $tokenRow = $this->findRefreshToken($refreshToken);
    if (!$tokenRow) {
      return ['ok' => false, 'error' => 'Invalid refresh token'];
    }
    if (!empty($tokenRow['revoked_at'])) {
      return ['ok' => false, 'error' => 'Refresh token revoked'];
    }
    $expiresAt = strtotime((string)($tokenRow['expires_at'] ?? ''));
    if ($expiresAt !== false && $expiresAt <= time()) {
      return ['ok' => false, 'error' => 'Refresh token expired'];
    }

    $userId = (int)$tokenRow['user_id'];
    $user = $this->db->fetchOne("SELECT id, status FROM users WHERE id = :id", [':id' => $userId]);
    if (!$user || (string)($user['status'] ?? '') !== 'active') {
      return ['ok' => false, 'error' => 'User disabled'];
    }

    $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = :id", [':id' => (int)$tokenRow['id']]);
    $tokens = $this->issueTokens($userId, $clientType, $deviceId, (int)$tokenRow['id']);
    $this->audit('refresh', $userId, 'user', $userId);
    return ['ok' => true, 'tokens' => $tokens];
  }

  public function logout(string $refreshToken): array {
    $tokenRow = $this->findRefreshToken($refreshToken);
    if (!$tokenRow) {
      return ['ok' => true];
    }

    $userId = (int)$tokenRow['user_id'];
    $this->revokeUserSessions($userId);
    $this->audit('logout', $userId, 'user', $userId);
    return ['ok' => true];
  }

  public function logoutByAccessToken(string $accessToken): void {
    $claims = $this->verifyJwt($accessToken);
    if ($claims === null) {
      return;
    }
    $userId = (int)($claims['sub'] ?? 0);
    if ($userId <= 0) {
      return;
    }
    $this->revokeUserSessions($userId);
    $this->audit('logout', $userId, 'user', $userId);
  }

  public function resolveUserLanguageFromToken(string $accessToken): ?string {
    if ($accessToken === '') {
      return null;
    }
    $claims = $this->verifyJwt($accessToken);
    if ($claims === null) {
      return null;
    }
    $userId = (int)($claims['sub'] ?? 0);
    if ($userId <= 0) {
      return null;
    }
    $user = $this->db->fetchOne("SELECT id, status, token_version FROM users WHERE id = :id", [':id' => $userId]);
    if (!$user || (string)($user['status'] ?? '') !== 'active') {
      return null;
    }
    $tokenVersion = (int)($claims['ver'] ?? 0);
    if ($tokenVersion !== (int)($user['token_version'] ?? 0)) {
      return null;
    }
    $row = $this->db->fetchOne("SELECT ui_prefs_json FROM user_profiles WHERE user_id = :id", [':id' => $userId]) ?? [];
    $ui = json_decode((string)($row['ui_prefs_json'] ?? '{}'), true);
    if (!is_array($ui)) {
      return null;
    }
    $lang = (string)($ui['language'] ?? '');
    return preg_match('/^[a-z]{2}$/', $lang) ? $lang : null;
  }

  public function setupMfa(int $userId): array {
    $secret = $this->generateTotpSecret();
    $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :id AND enabled_at IS NULL", [':id' => $userId]);
    $this->db->exec("
      INSERT INTO mfa_methods (user_id, type, secret_enc, enabled_at, created_at)
      VALUES (:uid, 'totp', :s, NULL, NOW())
    ", [':uid' => $userId, ':s' => $secret]);

    $issuer = (string)($this->config['app']['app_id'] ?? 'scmedia');
    $label = rawurlencode($issuer . ':' . $this->getUserEmail($userId));
    $otpauth = "otpauth://totp/{$label}?secret={$secret}&issuer=" . rawurlencode($issuer);

    $this->audit('mfa_setup', $userId, 'user', $userId);
    return ['ok' => true, 'secret' => $secret, 'otpauth_url' => $otpauth];
  }

  public function confirmMfa(int $userId, string $code): array {
    $method = $this->db->fetchOne("
      SELECT id, secret_enc
      FROM mfa_methods
      WHERE user_id = :uid AND enabled_at IS NULL AND type = 'totp'
      ORDER BY id DESC
      LIMIT 1
    ", [':uid' => $userId]);
    if (!$method) {
      return ['ok' => false, 'error' => 'No pending MFA setup'];
    }

    $secret = (string)($method['secret_enc'] ?? '');
    if (!$this->verifyTotp($secret, $code)) {
      return ['ok' => false, 'error' => 'Invalid code'];
    }

    $this->db->exec("UPDATE mfa_methods SET enabled_at = NOW() WHERE id = :id", [':id' => (int)$method['id']]);
    $codes = $this->generateBackupCodes($userId);
    $this->audit('mfa_enabled', $userId, 'user', $userId);
    return ['ok' => true, 'backup_codes' => $codes];
  }

  public function disableMfa(int $userId): array {
    $this->db->begin();
    try {
      $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :uid", [':uid' => $userId]);
      $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]);
      $this->db->commit();
    } catch (Throwable $e) {
      $this->db->rollBack();
      return ['ok' => false, 'error' => $e->getMessage()];
    }
    $this->invalidateUserSessions($userId, 'mfa_disabled');
    $this->audit('mfa_disabled', $userId, 'user', $userId);
    return ['ok' => true];
  }

  public function passwordForgot(string $email): array {
    $email = strtolower(trim($email));
    $user = $this->db->fetchOne("SELECT id FROM users WHERE email = :e", [':e' => $email]);
    if (!$user) {
      return ['ok' => true];
    }

    $token = $this->randomToken(32);
    $hash = $this->hashToken($token);
    $this->db->exec("
      INSERT INTO password_resets (user_id, token_hash, expires_at, created_at)
      VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :mins MINUTE), NOW())
    ", [':uid' => (int)$user['id'], ':h' => $hash, ':mins' => $this->passwordResetTtlMinutes]);

    $payload = ['ok' => true];
    if ($this->returnResetToken) {
      $payload['reset_token'] = $token;
    }
    $this->audit('password_reset_requested', (int)$user['id'], 'user', (int)$user['id']);
    return $payload;
  }

  public function passwordReset(string $token, string $newPassword): array {
    if ($token === '' || $newPassword === '') {
      return ['ok' => false, 'error' => 'Token and password required'];
    }

    $row = $this->findPasswordReset($token);
    if (!$row) {
      return ['ok' => false, 'error' => 'Invalid token'];
    }

    $hash = password_hash($newPassword, PASSWORD_DEFAULT);
    $userId = (int)$row['user_id'];
    $this->db->begin();
    try {
      $this->db->exec("UPDATE users SET password_hash = :h, password_changed_at = NOW(), token_version = token_version + 1 WHERE id = :id", [
        ':h' => $hash,
        ':id' => $userId,
      ]);
      $this->db->exec("UPDATE password_resets SET used_at = NOW() WHERE id = :id", [':id' => (int)$row['id']]);
      $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]);
      $this->db->commit();
    } catch (Throwable $e) {
      $this->db->rollBack();
      return ['ok' => false, 'error' => $e->getMessage()];
    }

    $this->audit('password_reset', $userId, 'user', $userId);
    return ['ok' => true];
  }

  public function changePassword(int $userId, string $currentPassword, string $newPassword): array {
    if ($currentPassword === '' || $newPassword === '') {
      return ['ok' => false, 'error' => 'Password required'];
    }
    $row = $this->db->fetchOne("SELECT password_hash FROM users WHERE id = :id", [':id' => $userId]);
    if (!$row) {
      return ['ok' => false, 'error' => 'User not found'];
    }
    if (!password_verify($currentPassword, (string)($row['password_hash'] ?? ''))) {
      $this->audit('password_change_failed', $userId, 'user', $userId);
      return ['ok' => false, 'error' => 'Invalid password'];
    }
    $hash = password_hash($newPassword, PASSWORD_DEFAULT);
    $this->db->exec("UPDATE users SET password_hash = :h, password_changed_at = NOW(), token_version = token_version + 1 WHERE id = :id", [
      ':h' => $hash,
      ':id' => $userId,
    ]);
    $this->audit('password_change', $userId, 'user', $userId);
    return ['ok' => true];
  }

  public function changeEmail(int $userId, string $email): array {
    $email = strtolower(trim($email));
    if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
      return ['ok' => false, 'error' => 'Invalid email'];
    }
    $exists = $this->db->fetchOne("SELECT id FROM users WHERE email = :e AND id <> :id", [
      ':e' => $email,
      ':id' => $userId,
    ]);
    if ($exists) {
      return ['ok' => false, 'error' => 'Email already in use'];
    }
    $this->db->exec("UPDATE users SET email = :e, updated_at = NOW() WHERE id = :id", [
      ':e' => $email,
      ':id' => $userId,
    ]);
    $this->audit('email_change', $userId, 'user', $userId, ['email' => $email]);
    return ['ok' => true];
  }

  public function listUsers(): array {
    return $this->db->fetchAll("
      SELECT u.id, u.email, u.status, u.last_login_at, u.created_at,
        GROUP_CONCAT(r.name ORDER BY r.name SEPARATOR ',') AS roles
      FROM users u
      LEFT JOIN user_roles ur ON ur.user_id = u.id
      LEFT JOIN roles r ON r.id = ur.role_id
      GROUP BY u.id
      ORDER BY u.id ASC
    ");
  }

  public function listRoles(): array {
    return $this->db->fetchAll("SELECT id, name, description FROM roles ORDER BY id ASC");
  }

  public function listAudit(int $page, int $perPage): array {
    if ($page < 1) $page = 1;
    if ($perPage < 1) $perPage = 50;
    if ($perPage > 500) $perPage = 500;
    $offset = ($page - 1) * $perPage;
    $items = $this->db->fetchAll("
      SELECT a.id, a.actor_user_id, u.email AS actor_email, a.action, a.target_type,
             a.target_id, a.ip_addr, a.user_agent, a.meta_json, a.created_at
      FROM audit_log a
      LEFT JOIN users u ON u.id = a.actor_user_id
      ORDER BY a.id DESC
      LIMIT {$perPage} OFFSET {$offset}
    ");
    $totalRow = $this->db->fetchOne("SELECT COUNT(*) AS c FROM audit_log");
    return [
      'items' => $items,
      'total' => (int)($totalRow['c'] ?? 0),
    ];
  }

  public function listAuditFiltered(int $page, int $perPage, string $from = '', string $to = '', string $action = ''): array {
    if ($page < 1) $page = 1;
    if ($perPage < 1) $perPage = 50;
    if ($perPage > 500) $perPage = 500;
    $offset = ($page - 1) * $perPage;

    $where = [];
    $params = [];
    if ($from !== '' && $to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) {
      $where[] = "a.created_at >= :from AND a.created_at < DATE_ADD(:to, INTERVAL 1 DAY)";
      $params[':from'] = $from . ' 00:00:00';
      $params[':to'] = $to . ' 00:00:00';
    }
    if ($action !== '') {
      $where[] = "a.action = :action";
      $params[':action'] = $action;
    }
    $whereSql = count($where) > 0 ? ('WHERE ' . implode(' AND ', $where)) : '';

    $items = $this->db->fetchAll("
      SELECT a.id, a.actor_user_id, u.email AS actor_email, a.action, a.target_type,
             a.target_id, a.ip_addr, a.user_agent, a.meta_json, a.created_at
      FROM audit_log a
      LEFT JOIN users u ON u.id = a.actor_user_id
      {$whereSql}
      ORDER BY a.id DESC
      LIMIT {$perPage} OFFSET {$offset}
    ", $params);
    $totalRow = $this->db->fetchOne("SELECT COUNT(*) AS c FROM audit_log {$whereSql}", $params);
    return [
      'items' => $items,
      'total' => (int)($totalRow['c'] ?? 0),
    ];
  }

  public function setUserRoles(int $actorId, int $userId, array $roles): array {
    $roles = array_values(array_filter(array_map('trim', $roles)));
    if (count($roles) === 0) {
      return ['ok' => false, 'error' => 'Roles required'];
    }

    $this->db->begin();
    try {
      $this->db->exec("DELETE FROM user_roles WHERE user_id = :uid", [':uid' => $userId]);
      $this->assignRolesInternal($userId, $roles);
      $this->db->exec("UPDATE users SET token_version = token_version + 1 WHERE id = :id", [':id' => $userId]);
      $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]);
      $this->db->commit();
    } catch (Throwable $e) {
      $this->db->rollBack();
      return ['ok' => false, 'error' => $e->getMessage()];
    }

    $this->audit('roles_changed', $actorId, 'user', $userId, ['roles' => $roles]);
    return ['ok' => true];
  }

  public function disableUser(int $actorId, int $userId, bool $disabled): array {
    if ($disabled) {
      $isAdmin = $this->db->fetchOne("
        SELECT 1
        FROM user_roles ur
        JOIN roles r ON r.id = ur.role_id
        WHERE ur.user_id = :uid AND r.name = 'admin'
        LIMIT 1
      ", [':uid' => $userId]);
      if ($isAdmin) {
        $row = $this->db->fetchOne("
          SELECT COUNT(*) AS c
          FROM users u
          JOIN user_roles ur ON ur.user_id = u.id
          JOIN roles r ON r.id = ur.role_id
          WHERE r.name = 'admin' AND u.status = 'active'
        ");
        $admins = (int)($row['c'] ?? 0);
        if ($admins <= 1) {
          return ['ok' => false, 'error' => 'Cannot disable last admin'];
        }
      }
    }
    $status = $disabled ? 'disabled' : 'active';
    $this->db->exec("UPDATE users SET status = :s WHERE id = :id", [':s' => $status, ':id' => $userId]);
    $this->invalidateUserSessions($userId, 'user_disabled');
    $this->audit($disabled ? 'user_disabled' : 'user_enabled', $actorId, 'user', $userId);
    return ['ok' => true];
  }

  public function resetUserMfa(int $actorId, int $userId): array {
    $this->db->begin();
    try {
      $this->db->exec("DELETE FROM mfa_methods WHERE user_id = :uid", [':uid' => $userId]);
      $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]);
      $this->db->commit();
    } catch (Throwable $e) {
      $this->db->rollBack();
      return ['ok' => false, 'error' => $e->getMessage()];
    }
    $this->invalidateUserSessions($userId, 'mfa_reset');
    $this->audit('mfa_reset', $actorId, 'user', $userId);
    return ['ok' => true];
  }

  public function issueSseKey(int $userId): array {
    $key = $this->randomToken(24);
    $hash = $this->hashToken($key);
    $this->db->exec("
      INSERT INTO auth_sse_keys (user_id, key_hash, expires_at, created_at)
      VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :ttl SECOND), NOW())
    ", [
      ':uid' => $userId,
      ':h' => $hash,
      ':ttl' => $this->sseKeyTtl,
    ]);
    return ['key' => $key, 'expires_in' => $this->sseKeyTtl];
  }

  public function validateSseKey(string $key): ?int {
    if ($key === '') {
      return null;
    }
    $hash = $this->hashToken($key);
    $row = $this->db->fetchOne("
      SELECT user_id
      FROM auth_sse_keys
      WHERE key_hash = :h AND expires_at > NOW()
      LIMIT 1
    ", [':h' => $hash]);
    if (!$row) {
      return null;
    }
    return (int)($row['user_id'] ?? 0);
  }

  public function startSseSession(int $userId): string {
    $existing = $this->db->fetchOne("
      SELECT session_token, updated_at
      FROM auth_sse_sessions
      WHERE user_id = :uid
      LIMIT 1
    ", [':uid' => $userId]);
    if ($existing && $this->isRecentSseUpdate($existing['updated_at'] ?? null)) {
      return (string)($existing['session_token'] ?? '');
    }
    $token = $this->hashToken($this->randomToken(24));
    $this->db->exec("
      INSERT INTO auth_sse_sessions (user_id, session_token, updated_at)
      VALUES (:uid, :token, NOW())
      ON DUPLICATE KEY UPDATE session_token = VALUES(session_token), updated_at = NOW()
    ", [
      ':uid' => $userId,
      ':token' => $token,
    ]);
    return $token;
  }

  public function isSseSessionActive(int $userId, string $token): bool {
    $row = $this->db->fetchOne("
      SELECT session_token, updated_at
      FROM auth_sse_sessions
      WHERE user_id = :uid
      LIMIT 1
    ", [':uid' => $userId]);
    if (!$row) return false;
    if (!hash_equals((string)$row['session_token'], $token)) {
      return false;
    }
    return $this->isRecentSseUpdate($row['updated_at'] ?? null);
  }

  public function touchSseSession(int $userId, string $token): void {
    $this->db->exec("
      UPDATE auth_sse_sessions
      SET updated_at = NOW()
      WHERE user_id = :uid AND session_token = :token
    ", [
      ':uid' => $userId,
      ':token' => $token,
    ]);
  }

  private function isRecentSseUpdate(?string $updatedAt): bool {
    if (!$updatedAt) return false;
    $ts = strtotime($updatedAt);
    if ($ts === false) return false;
    return (time() - $ts) <= $this->sseSessionTtl;
  }

  private function issueTokens(int $userId, string $clientType, string $deviceId = '', int $rotatedFromId = 0): array {
    $roles = $this->getUserRoles($userId);
    $user = $this->db->fetchOne("SELECT token_version FROM users WHERE id = :id", [':id' => $userId]);
    $tokenVersion = (int)($user['token_version'] ?? 1);
    $now = time();
    $payload = [
      'sub' => $userId,
      'roles' => $roles,
      'ver' => $tokenVersion,
      'iat' => $now,
      'exp' => $now + $this->accessTtl,
      'jti' => $this->randomToken(16),
    ];

    $access = $this->signJwt($payload);
    $refresh = $this->randomToken(32);
    $refreshHash = $this->hashToken($refresh);
    $deviceId = $deviceId !== '' ? $deviceId : $this->randomToken(12);
    $ttlDays = ($clientType === 'mobile') ? $this->refreshTtlMobileDays : $this->refreshTtlWebDays;

    $this->db->exec("
      INSERT INTO refresh_tokens (user_id, device_id, token_hash, user_agent, ip_addr, expires_at, rotated_from_id, created_at)
      VALUES (:uid, :did, :h, :ua, :ip, DATE_ADD(NOW(), INTERVAL :days DAY), :rf, NOW())
    ", [
      ':uid' => $userId,
      ':did' => $deviceId,
      ':h' => $refreshHash,
      ':ua' => $this->getUserAgent(),
      ':ip' => $this->getIpAddress(),
      ':days' => $ttlDays,
      ':rf' => $rotatedFromId > 0 ? $rotatedFromId : null,
    ]);

    $this->enforceDeviceLimit($userId);

    return [
      'access_token' => $access,
      'refresh_token' => $refresh,
      'expires_in' => $this->accessTtl,
      'token_type' => 'Bearer',
    ];
  }

  private function enforceDeviceLimit(int $userId): void {
    $rows = $this->db->fetchAll("
      SELECT id
      FROM refresh_tokens
      WHERE user_id = :uid AND revoked_at IS NULL AND expires_at > NOW()
      ORDER BY created_at ASC
    ", [':uid' => $userId]);

    $extra = count($rows) - $this->maxDevices;
    if ($extra <= 0) {
      return;
    }

    $ids = array_slice(array_map(fn($r) => (int)$r['id'], $rows), 0, $extra);
    if (count($ids) > 0) {
      $placeholders = implode(',', array_fill(0, count($ids), '?'));
      $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE id IN ({$placeholders})", $ids);
    }
  }

  private function createLoginChallenge(int $userId): string {
    $token = $this->randomToken(24);
    $hash = $this->hashToken($token);
    $this->db->exec("
      INSERT INTO auth_challenges (user_id, token_hash, expires_at, created_at)
      VALUES (:uid, :h, DATE_ADD(NOW(), INTERVAL :ttl SECOND), NOW())
    ", [':uid' => $userId, ':h' => $hash, ':ttl' => $this->challengeTtl]);
    return $token;
  }

  private function findChallenge(string $token): ?array {
    if ($token === '') {
      return null;
    }
    $hash = $this->hashToken($token);
    return $this->db->fetchOne("
      SELECT id, user_id
      FROM auth_challenges
      WHERE token_hash = :h AND used_at IS NULL AND expires_at > NOW()
      ORDER BY id DESC
      LIMIT 1
    ", [':h' => $hash]);
  }

  private function findRefreshToken(string $token): ?array {
    if ($token === '') {
      return null;
    }
    $hash = $this->hashToken($token);
    return $this->db->fetchOne("
      SELECT id, user_id, expires_at, revoked_at
      FROM refresh_tokens
      WHERE token_hash = :h
      LIMIT 1
    ", [':h' => $hash]);
  }

  private function findPasswordReset(string $token): ?array {
    $hash = $this->hashToken($token);
    return $this->db->fetchOne("
      SELECT id, user_id
      FROM password_resets
      WHERE token_hash = :h AND used_at IS NULL AND expires_at > NOW()
      LIMIT 1
    ", [':h' => $hash]);
  }

  private function getUserRoles(int $userId): array {
    $rows = $this->db->fetchAll("
      SELECT r.name
      FROM roles r
      INNER JOIN user_roles ur ON ur.role_id = r.id
      WHERE ur.user_id = :uid
    ", [':uid' => $userId]);
    return array_values(array_filter(array_map(fn($r) => (string)$r['name'], $rows)));
  }

  private function assignRolesInternal(int $userId, array $roles): void {
    foreach ($roles as $role) {
      $row = $this->db->fetchOne("SELECT id FROM roles WHERE name = :n", [':n' => $role]);
      if (!$row) {
        continue;
      }
      $this->db->exec("INSERT IGNORE INTO user_roles (user_id, role_id) VALUES (:u, :r)", [
        ':u' => $userId,
        ':r' => (int)$row['id'],
      ]);
    }
  }

  private function isMfaEnabled(int $userId): bool {
    $row = $this->db->fetchOne("SELECT id FROM mfa_methods WHERE user_id = :uid AND enabled_at IS NOT NULL LIMIT 1", [':uid' => $userId]);
    return (bool)$row;
  }

  private function verifyMfaCode(int $userId, string $code): bool {
    $code = trim($code);
    if ($code === '') {
      return false;
    }

    $method = $this->db->fetchOne("
      SELECT secret_enc
      FROM mfa_methods
      WHERE user_id = :uid AND enabled_at IS NOT NULL AND type = 'totp'
      ORDER BY id DESC LIMIT 1
    ", [':uid' => $userId]);
    if ($method) {
      $secret = (string)($method['secret_enc'] ?? '');
      if ($this->verifyTotp($secret, $code)) {
        return true;
      }
    }

    $hash = $this->hashToken($code);
    $row = $this->db->fetchOne("
      SELECT id
      FROM mfa_backup_codes
      WHERE user_id = :uid AND code_hash = :h AND used_at IS NULL
      LIMIT 1
    ", [':uid' => $userId, ':h' => $hash]);
    if ($row) {
      $this->db->exec("UPDATE mfa_backup_codes SET used_at = NOW() WHERE id = :id", [':id' => (int)$row['id']]);
      return true;
    }

    return false;
  }

  private function generateBackupCodes(int $userId, int $count = 10): array {
    $this->db->exec("DELETE FROM mfa_backup_codes WHERE user_id = :uid", [':uid' => $userId]);
    $codes = [];
    for ($i = 0; $i < $count; $i++) {
      $code = strtoupper(bin2hex(random_bytes(4)));
      $codes[] = $code;
      $this->db->exec("
        INSERT INTO mfa_backup_codes (user_id, code_hash, created_at)
        VALUES (:uid, :h, NOW())
      ", [':uid' => $userId, ':h' => $this->hashToken($code)]);
    }
    return $codes;
  }

  private function verifyTotp(string $secret, string $code, int $window = 1): bool {
    $secret = trim($secret);
    if ($secret === '' || !ctype_digit($code)) {
      return false;
    }
    $timestamp = time();
    $timeStep = 30;
    $t = (int)floor($timestamp / $timeStep);

    for ($i = -$window; $i <= $window; $i++) {
      $hash = $this->hotp($secret, $t + $i);
      if (hash_equals($hash, $code)) {
        return true;
      }
    }
    return false;
  }

  private function hotp(string $secret, int $counter, int $digits = 6): string {
    $key = $this->base32Decode($secret);
    $binCounter = pack('N*', 0) . pack('N*', $counter);
    $hash = hash_hmac('sha1', $binCounter, $key, true);
    $offset = ord(substr($hash, -1)) & 0x0F;
    $part = substr($hash, $offset, 4);
    $value = unpack('N', $part)[1] & 0x7fffffff;
    $mod = $value % (10 ** $digits);
    return str_pad((string)$mod, $digits, '0', STR_PAD_LEFT);
  }

  private function base32Decode(string $str): string {
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $str = strtoupper($str);
    $str = preg_replace('/[^A-Z2-7]/', '', $str);
    if ($str === null) {
      $str = '';
    }
    $bits = '';
    for ($i = 0; $i < strlen($str); $i++) {
      $val = strpos($alphabet, $str[$i]);
      if ($val === false) {
        continue;
      }
      $bits .= str_pad(decbin($val), 5, '0', STR_PAD_LEFT);
    }
    $bytes = '';
    for ($i = 0; $i + 8 <= strlen($bits); $i += 8) {
      $bytes .= chr(bindec(substr($bits, $i, 8)));
    }
    return $bytes;
  }

  private function generateTotpSecret(int $length = 16): string {
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $secret = '';
    for ($i = 0; $i < $length; $i++) {
      $secret .= $alphabet[random_int(0, 31)];
    }
    return $secret;
  }

  private function signJwt(array $payload): string {
    $header = ['alg' => 'HS256', 'typ' => 'JWT'];
    $segments = [
      $this->base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES)),
      $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_SLASHES)),
    ];
    $data = implode('.', $segments);
    $sig = hash_hmac('sha256', $data, $this->jwtSecret, true);
    $segments[] = $this->base64UrlEncode($sig);
    return implode('.', $segments);
  }

  private function verifyJwt(string $token): ?array {
    $parts = explode('.', $token);
    if (count($parts) !== 3) {
      return null;
    }
    [$h, $p, $s] = $parts;
    $data = $h . '.' . $p;
    $expected = $this->base64UrlEncode(hash_hmac('sha256', $data, $this->jwtSecret, true));
    if (!hash_equals($expected, $s)) {
      return null;
    }
    $payload = json_decode($this->base64UrlDecode($p), true);
    if (!is_array($payload)) {
      return null;
    }
    $exp = (int)($payload['exp'] ?? 0);
    if ($exp > 0 && time() > $exp) {
      return null;
    }
    return $payload;
  }

  private function base64UrlEncode(string $data): string {
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
  }

  private function base64UrlDecode(string $data): string {
    $pad = strlen($data) % 4;
    if ($pad > 0) {
      $data .= str_repeat('=', 4 - $pad);
    }
    return base64_decode(strtr($data, '-_', '+/')) ?: '';
  }

  private function randomToken(int $bytes): string {
    return rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
  }

  private function hashToken(string $token): string {
    return hash('sha256', $token);
  }

  private function readBearerToken(): string {
    $header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
    if ($header === '' && function_exists('apache_request_headers')) {
      $headers = apache_request_headers();
      $header = $headers['Authorization'] ?? '';
    }
    if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
      return trim($m[1]);
    }
    $cookie = $_COOKIE['scmedia_access_token'] ?? '';
    if (is_string($cookie) && $cookie !== '') {
      return $cookie;
    }
    return '';
  }

  private function redirectToLogin(): void {
    header('Location: /login', true, 302);
    exit;
  }

  private function getUserAgent(): string {
    return substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
  }

  private function getIpAddress(): string {
    return substr((string)($_SERVER['REMOTE_ADDR'] ?? ''), 0, 64);
  }

  private function getUserEmail(int $userId): string {
    $row = $this->db->fetchOne("SELECT email FROM users WHERE id = :id", [':id' => $userId]);
    return (string)($row['email'] ?? '');
  }

  private function invalidateUserSessions(int $userId, string $reason): void {
    $this->revokeUserSessions($userId);
    $this->audit('forced_logout', $userId, 'user', $userId, ['reason' => $reason]);
  }

  private function revokeUserSessions(int $userId): void {
    $this->db->exec("UPDATE users SET token_version = token_version + 1 WHERE id = :id", [':id' => $userId]);
    $this->db->exec("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = :uid AND revoked_at IS NULL", [':uid' => $userId]);
  }

  private function isFirstUser(int $userId): bool {
    $row = $this->db->fetchOne("SELECT COUNT(*) AS c FROM users");
    return (int)($row['c'] ?? 0) === 1;
  }

  private function isRateLimited(string $action): int {
    $ip = $this->getIpAddress();
    $row = $this->db->fetchOne("
      SELECT id, failed_count, window_started_at, blocked_until
      FROM auth_rate_limits
      WHERE ip_addr = :ip AND action = :action
      LIMIT 1
    ", [':ip' => $ip, ':action' => $action]);

    if (!$row) {
      return 0;
    }

    $blockedUntil = $row['blocked_until'] ? strtotime((string)$row['blocked_until']) : null;
    if ($blockedUntil !== null && $blockedUntil > time()) {
      return $blockedUntil - time();
    }

    $windowStart = strtotime((string)$row['window_started_at']);
    $window = $this->getRateWindow($action);
    if ($windowStart !== false && (time() - $windowStart) > $window) {
      $this->db->exec("
        UPDATE auth_rate_limits
        SET failed_count = 0, window_started_at = NOW(), blocked_until = NULL
        WHERE id = :id
      ", [':id' => (int)$row['id']]);
    }

    return 0;
  }

  private function registerFailure(string $action): void {
    $ip = $this->getIpAddress();
    $row = $this->db->fetchOne("
      SELECT id, failed_count, window_started_at
      FROM auth_rate_limits
      WHERE ip_addr = :ip AND action = :action
      LIMIT 1
    ", [':ip' => $ip, ':action' => $action]);

    $window = $this->getRateWindow($action);
    $max = $this->getRateMax($action);
    $block = $this->getRateBlock($action);
    $now = time();

    if (!$row) {
      $blockedUntil = $max <= 1 ? date('Y-m-d H:i:s', $now + $block) : null;
      $this->db->exec("
        INSERT INTO auth_rate_limits (ip_addr, action, failed_count, window_started_at, blocked_until)
        VALUES (:ip, :action, :cnt, NOW(), :blocked)
      ", [
        ':ip' => $ip,
        ':action' => $action,
        ':cnt' => 1,
        ':blocked' => $blockedUntil,
      ]);
      return;
    }

    $windowStart = strtotime((string)$row['window_started_at']);
    $count = (int)$row['failed_count'];
    if ($windowStart === false || ($now - $windowStart) > $window) {
      $count = 0;
      $windowStart = $now;
    }
    $count++;
    $blockedUntil = $count >= $max ? date('Y-m-d H:i:s', $now + $block) : null;

    $this->db->exec("
      UPDATE auth_rate_limits
      SET failed_count = :cnt, window_started_at = :ws, blocked_until = :blocked
      WHERE id = :id
    ", [
      ':cnt' => $count,
      ':ws' => date('Y-m-d H:i:s', $windowStart),
      ':blocked' => $blockedUntil,
      ':id' => (int)$row['id'],
    ]);
  }

  private function resetRateLimit(string $action): void {
    $ip = $this->getIpAddress();
    $this->db->exec("DELETE FROM auth_rate_limits WHERE ip_addr = :ip AND action = :action", [
      ':ip' => $ip,
      ':action' => $action,
    ]);
  }

  private function getRateMax(string $action): int {
    return $action === 'mfa' ? $this->mfaMax : $this->loginMax;
  }

  private function getRateWindow(string $action): int {
    return $action === 'mfa' ? $this->mfaWindow : $this->loginWindow;
  }

  private function getRateBlock(string $action): int {
    return $action === 'mfa' ? $this->mfaBlock : $this->loginBlock;
  }

  private function audit(string $action, ?int $actorId, ?string $targetType, ?int $targetId, array $meta = []): void {
    $this->db->exec("
      INSERT INTO audit_log (actor_user_id, action, target_type, target_id, ip_addr, user_agent, meta_json, created_at)
      VALUES (:actor, :action, :tt, :tid, :ip, :ua, :meta, NOW())
    ", [
      ':actor' => $actorId,
      ':action' => $action,
      ':tt' => $targetType,
      ':tid' => $targetId,
      ':ip' => $this->getIpAddress(),
      ':ua' => $this->getUserAgent(),
      ':meta' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
    ]);
  }
}
