<?php
/**
 * class/auth.php — autenticación por cookies + sesiones en BD.
 *
 * La cookie `auth` lleva un token aleatorio en claro; en BD se guarda SOLO su
 * hash (sha256) en users_sessions. Así una fuga de BD no permite suplantar.
 */
if (!defined('IN_APP')) { die('Acceso denegado'); }

class Auth extends App
{
    const COOKIE      = 'auth';
    const TTL_DAYS    = 30;

    /** Carga el usuario de la sesión actual (o null). Actualiza last_seen. */
    public function resolve()
    {
        $this->ensureSchema('users');
        $this->ensureSchema('users_sessions');

        $token = $_COOKIE[self::COOKIE] ?? '';
        if ($token === '' || !preg_match('/^[a-f0-9]{64}$/', $token)) return null;

        $hash = hash('sha256', $token);
        $sql  = 'SELECT s.id_session, s.id_user, s.expires_at, s.revoked,
                        u.id_user AS uid, u.name, u.email, u.locale, u.is_admin, u.is_active, u.is_org, u.org_id
                 FROM users_sessions s
                 JOIN users u ON u.id_user = s.id_user
                 WHERE s.token_hash = ? LIMIT 1';
        $stmt = $this->db->prepare($sql);
        if (!$stmt) return null;
        $stmt->bind_param('s', $hash);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();

        if (!$row) return null;
        if ((int)$row['revoked'] === 1) return null;
        if ((int)$row['is_active'] !== 1) return null;
        if (strtotime($row['expires_at']) < time()) return null;

        // last_seen
        $up = $this->db->prepare('UPDATE users_sessions SET last_seen = NOW() WHERE id_session = ?');
        $up->bind_param('i', $row['id_session']);
        $up->execute();
        $up->close();

        return array(
            'id_user'    => (int)$row['uid'],
            'name'       => $row['name'],
            'email'      => $row['email'],
            'locale'     => $row['locale'],
            'is_admin'   => (int)$row['is_admin'] === 1,
            'is_org'     => (int)$row['is_org'] === 1,
            'org_id'     => $row['org_id'] !== null ? (int)$row['org_id'] : null,
            'id_session' => (int)$row['id_session'],
        );
    }

    /** Valida credenciales y, si son correctas, crea la sesión y pone la cookie. */
    public function login($email, $password)
    {
        $this->ensureSchema('users');
        $this->ensureSchema('users_sessions');

        $email = trim((string)$email);
        $stmt  = $this->db->prepare('SELECT id_user, pass_hash, is_active FROM users WHERE email = ? LIMIT 1');
        $stmt->bind_param('s', $email);
        $stmt->execute();
        $u = $stmt->get_result()->fetch_assoc();
        $stmt->close();

        if (!$u || (int)$u['is_active'] !== 1 || !password_verify((string)$password, $u['pass_hash'])) {
            $this->_error = App::t('auth.invalid_login');
            return false;
        }

        $this->createSession((int)$u['id_user']);
        $this->db->query('UPDATE users SET last_login_at = NOW() WHERE id_user = ' . (int)$u['id_user']);
        return true;
    }

    /** Crea una fila de sesión y fija la cookie con el token en claro. */
    public function createSession($id_user)
    {
        $token = bin2hex(random_bytes(32));      // 64 hex
        $hash  = hash('sha256', $token);
        $ip    = $_SERVER['REMOTE_ADDR'] ?? '';
        $ua    = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
        $exp   = date('Y-m-d H:i:s', time() + self::TTL_DAYS * 86400);

        $stmt = $this->db->prepare(
            'INSERT INTO users_sessions (id_user, token_hash, ip, user_agent, created_at, last_seen, expires_at, revoked)
             VALUES (?, ?, ?, ?, NOW(), NOW(), ?, 0)'
        );
        $stmt->bind_param('issss', $id_user, $hash, $ip, $ua, $exp);
        $stmt->execute();
        $stmt->close();

        $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
        setcookie(self::COOKIE, $token, array(
            'expires'  => time() + self::TTL_DAYS * 86400,
            'path'     => '/',
            'secure'   => $secure,
            'httponly' => true,
            'samesite' => 'Lax',
        ));
    }

    /** Cierra la sesión actual (revoca en BD + borra cookie). */
    public function logout()
    {
        $token = $_COOKIE[self::COOKIE] ?? '';
        if ($token !== '') {
            $hash = hash('sha256', $token);
            $stmt = $this->db->prepare('UPDATE users_sessions SET revoked = 1 WHERE token_hash = ?');
            $stmt->bind_param('s', $hash);
            $stmt->execute();
            $stmt->close();
        }
        setcookie(self::COOKIE, '', array('expires' => time() - 3600, 'path' => '/'));
    }

    /* --- gestión de sesiones del usuario (perfil) --- */

    /** Lista las sesiones activas (no revocadas, no caducadas) de un usuario. */
    public function sessions($id_user)
    {
        $stmt = $this->db->prepare(
            'SELECT id_session, ip, user_agent, created_at, last_seen, expires_at
             FROM users_sessions
             WHERE id_user = ? AND revoked = 0 AND expires_at > NOW()
             ORDER BY last_seen DESC'
        );
        $stmt->bind_param('i', $id_user);
        $stmt->execute();
        $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
        $stmt->close();
        return $rows;
    }

    /** Revoca una sesión concreta del usuario. */
    public function revoke($id_user, $id_session)
    {
        $stmt = $this->db->prepare('UPDATE users_sessions SET revoked = 1 WHERE id_session = ? AND id_user = ?');
        $stmt->bind_param('ii', $id_session, $id_user);
        $stmt->execute();
        $stmt->close();
    }

    /** Revoca todas las sesiones del usuario salvo la actual. */
    public function revokeOthers($id_user, $keep_session)
    {
        $stmt = $this->db->prepare('UPDATE users_sessions SET revoked = 1 WHERE id_user = ? AND id_session <> ?');
        $stmt->bind_param('ii', $id_user, $keep_session);
        $stmt->execute();
        $stmt->close();
    }

    /* --- invitaciones (registro cerrado) --- */

    const INVITE_TTL_DAYS = 7;

    /** Crea una invitación y devuelve el token (para construir el enlace). */
    public function createInvite($id_user_inviter, $email = null)
    {
        $this->ensureSchema('users_invitations');
        $token = bin2hex(random_bytes(32));
        $email = ($email !== null && trim($email) !== '') ? trim($email) : null;
        $exp   = date('Y-m-d H:i:s', time() + self::INVITE_TTL_DAYS * 86400);
        $stmt  = $this->db->prepare(
            'INSERT INTO users_invitations (token, email, id_user_inviter, created_at, expires_at)
             VALUES (?, ?, ?, NOW(), ?)'
        );
        $stmt->bind_param('ssis', $token, $email, $id_user_inviter, $exp);
        $stmt->execute();
        $stmt->close();
        return $token;
    }

    /** Invitaciones existentes con su estado (pending/used/expired). */
    public function listInvites()
    {
        $this->ensureSchema('users_invitations');
        $sql  = 'SELECT i.id_invitation, i.token, i.email, i.created_at, i.expires_at, i.used_at,
                        u.email AS created_email
                 FROM users_invitations i
                 LEFT JOIN users u ON u.id_user = i.id_user_created
                 ORDER BY i.id_invitation DESC LIMIT 100';
        $rows = $this->db->query($sql)->fetch_all(MYSQLI_ASSOC);
        foreach ($rows as &$r) {
            if ($r['used_at'])                           $r['status'] = 'used';
            elseif (strtotime($r['expires_at']) < time()) $r['status'] = 'expired';
            else                                          $r['status'] = 'pending';
        }
        return $rows;
    }

    /** Devuelve la invitación si el token es válido (no usado, no caducado), o null. */
    public function validateInvite($token)
    {
        $this->ensureSchema('users_invitations');
        if (!is_string($token) || !preg_match('/^[a-f0-9]{64}$/', $token)) return null;
        $stmt = $this->db->prepare('SELECT id_invitation, email, expires_at, used_at FROM users_invitations WHERE token = ? LIMIT 1');
        $stmt->bind_param('s', $token);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        if (!$row || $row['used_at'] || strtotime($row['expires_at']) < time()) return null;
        return $row;
    }

    /** Marca una invitación como usada por un usuario recién creado. */
    public function consumeInvite($id_invitation, $id_user_created)
    {
        $stmt = $this->db->prepare('UPDATE users_invitations SET used_at = NOW(), id_user_created = ? WHERE id_invitation = ? AND used_at IS NULL');
        $stmt->bind_param('ii', $id_user_created, $id_invitation);
        $stmt->execute();
        $ok = $stmt->affected_rows === 1;
        $stmt->close();
        return $ok;
    }

    /** Borra una invitación (solo tiene sentido si está pendiente). */
    public function revokeInvite($id_invitation)
    {
        $stmt = $this->db->prepare('DELETE FROM users_invitations WHERE id_invitation = ? AND used_at IS NULL');
        $stmt->bind_param('i', $id_invitation);
        $stmt->execute();
        $stmt->close();
    }

    /** Cambia la contraseña verificando la actual. true, o false con $this->_error. */
    public function changePassword($id_user, $current, $new)
    {
        $id_user = (int)$id_user;
        if (strlen((string)$new) < 8) { $this->_error = App::t('profile.pw_short'); return false; }
        $stmt = $this->db->prepare('SELECT pass_hash FROM users WHERE id_user = ? LIMIT 1');
        $stmt->bind_param('i', $id_user);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        if (!$row || !password_verify((string)$current, $row['pass_hash'])) {
            $this->_error = App::t('profile.pw_wrong'); return false;
        }
        $hash = password_hash((string)$new, PASSWORD_DEFAULT);
        $st = $this->db->prepare('UPDATE users SET pass_hash = ? WHERE id_user = ?');
        $st->bind_param('si', $hash, $id_user);
        $st->execute();
        $st->close();
        return true;
    }

    /* --- API key del usuario (una activa por usuario) --- */

    /** Devuelve la API key activa del usuario (token, created_at, last_used_at) o null. */
    public function getApiKey($id_user)
    {
        $this->ensureSchema('users_api_keys');
        $stmt = $this->db->prepare('SELECT token, created_at, last_used_at FROM users_api_keys WHERE id_user = ? ORDER BY id_api_key DESC LIMIT 1');
        $stmt->bind_param('i', $id_user);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        return $row ?: null;
    }

    /** (Re)genera la API key MANUAL del usuario (label NULL): borra la manual y crea una nueva.
     *  No toca los tokens de dispositivos OAuth (que tienen label). Devuelve el token. */
    public function regenerateApiKey($id_user)
    {
        $this->ensureSchema('users_api_keys');
        $token = 'kq_' . bin2hex(random_bytes(32));

        $del = $this->db->prepare('DELETE FROM users_api_keys WHERE id_user = ? AND label IS NULL');
        $del->bind_param('i', $id_user);
        $del->execute();
        $del->close();

        $ins = $this->db->prepare('INSERT INTO users_api_keys (id_user, token, label, created_at) VALUES (?, ?, NULL, NOW())');
        $ins->bind_param('is', $id_user, $token);
        $ins->execute();
        $ins->close();

        return $token;
    }

    /* ===================== Tokens / OAuth por dispositivo ===================== */

    /** Lista todos los tokens del usuario (con label = app/dispositivo). */
    public function listApiKeys($id_user)
    {
        $this->ensureSchema('users_api_keys');
        $this->ensureColumn('users_api_keys', 'label', 'VARCHAR(190) NULL');
        $stmt = $this->db->prepare('SELECT id_api_key, token, label, created_at, last_used_at FROM users_api_keys WHERE id_user = ? ORDER BY id_api_key DESC');
        $stmt->bind_param('i', $id_user);
        $stmt->execute();
        $rows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
        $stmt->close();
        return $rows;
    }

    /** Crea un token nuevo (no borra otros) con una etiqueta de dispositivo. Devuelve el token. */
    public function createApiKey($id_user, $label = null)
    {
        $this->ensureSchema('users_api_keys');
        $this->ensureColumn('users_api_keys', 'label', 'VARCHAR(190) NULL');
        $token = 'kq_' . bin2hex(random_bytes(32));
        $label = $label !== null ? mb_substr(trim((string)$label), 0, 190) : null;
        $ins = $this->db->prepare('INSERT INTO users_api_keys (id_user, token, label, created_at) VALUES (?, ?, ?, NOW())');
        $ins->bind_param('iss', $id_user, $token, $label);
        $ins->execute();
        $ins->close();
        return $token;
    }

    /** Revoca (borra) un token del usuario. Borrar basta: el gateway deja de aceptarlo. */
    public function revokeApiKey($id_user, $id_api_key)
    {
        $this->ensureSchema('users_api_keys');
        $stmt = $this->db->prepare('DELETE FROM users_api_keys WHERE id_api_key = ? AND id_user = ?');
        $stmt->bind_param('ii', $id_api_key, $id_user);
        $stmt->execute();
        $stmt->close();
    }

    /** Genera un código OAuth de un solo uso (5 min) para canjear por un token. */
    public function createOAuthCode($id_user, $redirect_uri, $device = null)
    {
        $this->ensureSchema('oauth_codes');
        $code = bin2hex(random_bytes(32));
        $device = $device !== null ? mb_substr(trim((string)$device), 0, 190) : null;
        $stmt = $this->db->prepare('INSERT INTO oauth_codes (code, id_user, redirect_uri, device, created_at, expires_at) VALUES (?,?,?,?,NOW(), DATE_ADD(NOW(), INTERVAL 5 MINUTE))');
        $stmt->bind_param('siss', $code, $id_user, $redirect_uri, $device);
        $stmt->execute();
        $stmt->close();
        return $code;
    }

    /** Canjea un código OAuth válido: devuelve ['id_user','device'] y lo marca usado, o null. */
    public function consumeOAuthCode($code, $redirect_uri)
    {
        $this->ensureSchema('oauth_codes');
        if (!preg_match('/^[a-f0-9]{64}$/', (string)$code)) return null;
        $stmt = $this->db->prepare('SELECT id_code, id_user, device FROM oauth_codes WHERE code = ? AND redirect_uri = ? AND used_at IS NULL AND expires_at > NOW() LIMIT 1');
        $stmt->bind_param('ss', $code, $redirect_uri);
        $stmt->execute();
        $row = $stmt->get_result()->fetch_assoc();
        $stmt->close();
        if (!$row) return null;
        $up = $this->db->prepare('UPDATE oauth_codes SET used_at = NOW() WHERE id_code = ?');
        $up->bind_param('i', $row['id_code']);
        $up->execute();
        $up->close();
        return array('id_user' => (int)$row['id_user'], 'device' => $row['device']);
    }

    /**
     * Registra un usuario a partir de una invitación válida. Devuelve true y
     * abre sesión, o false con $this->_error.
     */
    public function registerWithInvite($token, $name, $email, $password)
    {
        $inv = $this->validateInvite($token);
        if (!$inv) { $this->_error = App::t('invite.invalid'); return false; }

        $name  = trim((string)$name);
        $email = trim((string)$email);
        // Si la invitación fija un email, debe coincidir.
        if ($inv['email'] && strcasecmp($inv['email'], $email) !== 0) {
            $this->_error = App::t('invite.email_mismatch');
            return false;
        }
        if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen((string)$password) < 8) {
            $this->_error = App::t('auth.setup_invalid');
            return false;
        }

        $hash = password_hash((string)$password, PASSWORD_DEFAULT);
        $loc  = App::locale();
        $stmt = $this->db->prepare('INSERT INTO users (name, email, pass_hash, locale, is_admin, is_active, created_at) VALUES (?,?,?,?,0,1,NOW())');
        $stmt->bind_param('ssss', $name, $email, $hash, $loc);
        if (!$stmt->execute()) { $this->_error = App::t('auth.email_taken'); $stmt->close(); return false; }
        $id_user = $this->db->insert_id;
        $stmt->close();

        $this->consumeInvite($inv['id_invitation'], $id_user);
        $this->createSession($id_user);
        return true;
    }
}
