-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
--   • check_rate_limit         — consulta + decide allowed/captcha/bloqueio
--   • record_submission_attempt — log + atualiza contadores e bloqueios
--   • generate_math_challenge  — cria pergunta math, retorna {id, question}
--   • verify_math_challenge    — valida {id, answer}, marca used
-- =============================================================================

-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
    p_ip_hash  text,
    p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
    cfg                       saas_security_config%ROWTYPE;
    rl                        submission_rate_limits%ROWTYPE;
    v_now                     timestamptz := now();
    v_window_start            timestamptz;
    v_in_window               boolean;
    v_requires_captcha        boolean := false;
    v_blocked_until           timestamptz;
    v_retry_after_seconds     integer := 0;
BEGIN
    SELECT * INTO cfg FROM saas_security_config WHERE id = true;
    IF NOT FOUND THEN
        -- Sem config: fail-open (libera). Logado.
        RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
    END IF;

    -- Modo paranoid global: sempre captcha
    IF cfg.captcha_required_globally THEN
        v_requires_captcha := true;
    END IF;

    -- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
    IF NOT cfg.rate_limit_enabled THEN
        RETURN jsonb_build_object(
            'allowed',          true,
            'requires_captcha', v_requires_captcha,
            'reason',           CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
        );
    END IF;

    -- Sem ip_hash: libera (não dá pra rastrear)
    IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
        RETURN jsonb_build_object(
            'allowed',          true,
            'requires_captcha', v_requires_captcha,
            'reason',           'no_ip'
        );
    END IF;

    SELECT * INTO rl
      FROM submission_rate_limits
     WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;

    -- Bloqueio temporário ativo?
    IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
        v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
        RETURN jsonb_build_object(
            'allowed',             false,
            'requires_captcha',    false,
            'retry_after_seconds', v_retry_after_seconds,
            'reason',              'blocked'
        );
    END IF;

    -- Captcha condicional ativo?
    IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
        v_requires_captcha := true;
    END IF;

    -- Janela atual ainda válida?
    v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
    v_in_window := FOUND AND rl.window_start >= v_window_start;

    IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
        -- Excedeu — bloqueia
        v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
        UPDATE submission_rate_limits
           SET blocked_until = v_blocked_until,
               last_attempt_at = v_now
         WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;

        v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
        RETURN jsonb_build_object(
            'allowed',             false,
            'requires_captcha',    false,
            'retry_after_seconds', v_retry_after_seconds,
            'reason',              'rate_limit_exceeded'
        );
    END IF;

    RETURN jsonb_build_object(
        'allowed',          true,
        'requires_captcha', v_requires_captcha,
        'reason',           CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
    );
END;
$function$;

REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT  EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;

-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
    p_endpoint   text,
    p_ip_hash    text,
    p_success    boolean,
    p_blocked_by text DEFAULT NULL,
    p_error_code text DEFAULT NULL,
    p_error_msg  text DEFAULT NULL,
    p_user_agent text DEFAULT NULL,
    p_metadata   jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
    cfg            saas_security_config%ROWTYPE;
    v_now          timestamptz := now();
    v_window_start timestamptz;
    rl             submission_rate_limits%ROWTYPE;
BEGIN
    -- Log sempre (mesmo sem ip)
    INSERT INTO public_submission_attempts
        (endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
    VALUES
        (p_endpoint, p_ip_hash, p_success, p_blocked_by,
         left(coalesce(p_error_code, ''), 80),
         left(coalesce(p_error_msg, ''), 500),
         left(coalesce(p_user_agent, ''), 500),
         p_metadata);

    -- Sem ip ou rate limit desligado: não atualiza contador
    IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;

    SELECT * INTO cfg FROM saas_security_config WHERE id = true;
    IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;

    v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;

    SELECT * INTO rl
      FROM submission_rate_limits
     WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;

    IF NOT FOUND THEN
        INSERT INTO submission_rate_limits
            (ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
        VALUES
            (p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
    ELSE
        IF rl.window_start < v_window_start THEN
            -- Reset janela
            UPDATE submission_rate_limits
               SET attempt_count = 1,
                   fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
                   window_start = v_now,
                   last_attempt_at = v_now,
                   blocked_until = NULL
             WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
        ELSE
            UPDATE submission_rate_limits
               SET attempt_count = attempt_count + 1,
                   fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
                   last_attempt_at = v_now
             WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
        END IF;

        -- Se atingiu threshold de captcha condicional, marca
        IF NOT p_success THEN
            SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
            IF rl.fail_count >= cfg.captcha_after_failures
               AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
                UPDATE submission_rate_limits
                   SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
                 WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
            END IF;
        END IF;
    END IF;
END;
$function$;

REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT  EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;

-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
    v_a    integer;
    v_b    integer;
    v_op   text;
    v_ans  integer;
    v_q    text;
    v_id   uuid;
BEGIN
    v_a := 1 + floor(random() * 9)::int;
    v_b := 1 + floor(random() * 9)::int;
    v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];

    -- garantir resultado positivo na subtração
    IF v_op = '-' AND v_b > v_a THEN
        v_a := v_a + v_b;
    END IF;

    v_ans := CASE v_op
        WHEN '+' THEN v_a + v_b
        WHEN '-' THEN v_a - v_b
        WHEN '*' THEN v_a * v_b
    END;

    v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);

    INSERT INTO math_challenges (question, answer)
    VALUES (v_q, v_ans)
    RETURNING id INTO v_id;

    RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;

REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT  EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;

-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
    p_id     uuid,
    p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
    mc math_challenges%ROWTYPE;
BEGIN
    IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;

    SELECT * INTO mc FROM math_challenges WHERE id = p_id;
    IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
        RETURN false;
    END IF;

    UPDATE math_challenges SET used = true WHERE id = p_id;

    RETURN mc.answer = p_answer;
END;
$function$;

REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT  EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;

-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
    WITH d AS (
        DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
    )
    SELECT COUNT(*)::int FROM d;
$function$;

REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT  EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;
