-- All Functions
-- Gerado automaticamente em 2026-05-11T16:53:50.921Z
-- Total: 211

CREATE FUNCTION auth.email() RETURNS text
    LANGUAGE sql STABLE
    AS $$
  select 
  coalesce(
    nullif(current_setting('request.jwt.claim.email', true), ''),
    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email')
  )::text
$$;

CREATE FUNCTION auth.jwt() RETURNS jsonb
    LANGUAGE sql STABLE
    AS $$
  select 
    coalesce(
        nullif(current_setting('request.jwt.claim', true), ''),
        nullif(current_setting('request.jwt.claims', true), '')
    )::jsonb
$$;

CREATE FUNCTION auth.role() RETURNS text
    LANGUAGE sql STABLE
    AS $$
  select 
  coalesce(
    nullif(current_setting('request.jwt.claim.role', true), ''),
    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role')
  )::text
$$;

CREATE FUNCTION auth.uid() RETURNS uuid
    LANGUAGE sql STABLE
    AS $$
  select 
  coalesce(
    nullif(current_setting('request.jwt.claim.sub', true), ''),
    (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
  )::uuid
$$;

CREATE FUNCTION extensions.grant_pg_cron_access() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF EXISTS (
    SELECT
    FROM pg_event_trigger_ddl_commands() AS ev
    JOIN pg_extension AS ext
    ON ev.objid = ext.oid
    WHERE ext.extname = 'pg_cron'
  )
  THEN
    grant usage on schema cron to postgres with grant option;

    alter default privileges in schema cron grant all on tables to postgres with grant option;
    alter default privileges in schema cron grant all on functions to postgres with grant option;
    alter default privileges in schema cron grant all on sequences to postgres with grant option;

    alter default privileges for user supabase_admin in schema cron grant all
        on sequences to postgres with grant option;
    alter default privileges for user supabase_admin in schema cron grant all
        on tables to postgres with grant option;
    alter default privileges for user supabase_admin in schema cron grant all
        on functions to postgres with grant option;

    grant all privileges on all tables in schema cron to postgres with grant option;
    revoke all on table cron.job from postgres;
    grant select on table cron.job to postgres with grant option;
  END IF;
END;
$$;

CREATE FUNCTION extensions.grant_pg_graphql_access() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $_$
DECLARE
    func_is_graphql_resolve bool;
BEGIN
    func_is_graphql_resolve = (
        SELECT n.proname = 'resolve'
        FROM pg_event_trigger_ddl_commands() AS ev
        LEFT JOIN pg_catalog.pg_proc AS n
        ON ev.objid = n.oid
    );

    IF func_is_graphql_resolve
    THEN
        -- Update public wrapper to pass all arguments through to the pg_graphql resolve func
        DROP FUNCTION IF EXISTS graphql_public.graphql;
        create or replace function graphql_public.graphql(
            "operationName" text default null,
            query text default null,
            variables jsonb default null,
            extensions jsonb default null
        )
            returns jsonb
            language sql
        as $$
            select graphql.resolve(
                query := query,
                variables := coalesce(variables, '{}'),
                "operationName" := "operationName",
                extensions := extensions
            );
        $$;

        -- This hook executes when `graphql.resolve` is created. That is not necessarily the last
        -- function in the extension so we need to grant permissions on existing entities AND
        -- update default permissions to any others that are created after `graphql.resolve`
        grant usage on schema graphql to postgres, anon, authenticated, service_role;
        grant select on all tables in schema graphql to postgres, anon, authenticated, service_role;
        grant execute on all functions in schema graphql to postgres, anon, authenticated, service_role;
        grant all on all sequences in schema graphql to postgres, anon, authenticated, service_role;
        alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role;
        alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role;
        alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role;

        -- Allow postgres role to allow granting usage on graphql and graphql_public schemas to custom roles
        grant usage on schema graphql_public to postgres with grant option;
        grant usage on schema graphql to postgres with grant option;
    END IF;

END;
$_$;

CREATE FUNCTION extensions.grant_pg_net_access() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF EXISTS (
    SELECT 1
    FROM pg_event_trigger_ddl_commands() AS ev
    JOIN pg_extension AS ext
    ON ev.objid = ext.oid
    WHERE ext.extname = 'pg_net'
  )
  THEN
    GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;

    ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
    ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;

    ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
    ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;

    REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
    REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;

    GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
    GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
  END IF;
END;
$$;

CREATE FUNCTION extensions.pgrst_ddl_watch() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  cmd record;
BEGIN
  FOR cmd IN SELECT * FROM pg_event_trigger_ddl_commands()
  LOOP
    IF cmd.command_tag IN (
      'CREATE SCHEMA', 'ALTER SCHEMA'
    , 'CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE'
    , 'CREATE FOREIGN TABLE', 'ALTER FOREIGN TABLE'
    , 'CREATE VIEW', 'ALTER VIEW'
    , 'CREATE MATERIALIZED VIEW', 'ALTER MATERIALIZED VIEW'
    , 'CREATE FUNCTION', 'ALTER FUNCTION'
    , 'CREATE TRIGGER'
    , 'CREATE TYPE', 'ALTER TYPE'
    , 'CREATE RULE'
    , 'COMMENT'
    )
    -- don't notify in case of CREATE TEMP table or other objects created on pg_temp
    AND cmd.schema_name is distinct from 'pg_temp'
    THEN
      NOTIFY pgrst, 'reload schema';
    END IF;
  END LOOP;
END; $$;

CREATE FUNCTION extensions.pgrst_drop_watch() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  obj record;
BEGIN
  FOR obj IN SELECT * FROM pg_event_trigger_dropped_objects()
  LOOP
    IF obj.object_type IN (
      'schema'
    , 'table'
    , 'foreign table'
    , 'view'
    , 'materialized view'
    , 'function'
    , 'trigger'
    , 'type'
    , 'rule'
    )
    AND obj.is_temporary IS false -- no pg_temp objects
    THEN
      NOTIFY pgrst, 'reload schema';
    END IF;
  END LOOP;
END; $$;

CREATE FUNCTION extensions.set_graphql_placeholder() RETURNS event_trigger
    LANGUAGE plpgsql
    AS $_$
    DECLARE
    graphql_is_dropped bool;
    BEGIN
    graphql_is_dropped = (
        SELECT ev.schema_name = 'graphql_public'
        FROM pg_event_trigger_dropped_objects() AS ev
        WHERE ev.schema_name = 'graphql_public'
    );

    IF graphql_is_dropped
    THEN
        create or replace function graphql_public.graphql(
            "operationName" text default null,
            query text default null,
            variables jsonb default null,
            extensions jsonb default null
        )
            returns jsonb
            language plpgsql
        as $$
            DECLARE
                server_version float;
            BEGIN
                server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float);

                IF server_version >= 14 THEN
                    RETURN jsonb_build_object(
                        'errors', jsonb_build_array(
                            jsonb_build_object(
                                'message', 'pg_graphql extension is not enabled.'
                            )
                        )
                    );
                ELSE
                    RETURN jsonb_build_object(
                        'errors', jsonb_build_array(
                            jsonb_build_object(
                                'message', 'pg_graphql is only available on projects running Postgres 14 onwards.'
                            )
                        )
                    );
                END IF;
            END;
        $$;
    END IF;

    END;
$_$;

CREATE FUNCTION pgbouncer.get_auth(p_usename text) RETURNS TABLE(username text, password text)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO ''
    AS $_$
begin
    raise debug 'PgBouncer auth request: %', p_usename;

    return query
    select 
        rolname::text, 
        case when rolvaliduntil < now() 
            then null 
            else rolpassword::text 
        end 
    from pg_authid 
    where rolname=$1 and rolcanlogin;
end;
$_$;

CREATE FUNCTION public.__rls_ping() RETURNS text
    LANGUAGE sql STABLE
    AS $$
  select 'ok'::text;
$$;

CREATE FUNCTION public._first_response_runs(p_tenant_id uuid, p_from timestamp with time zone, p_to timestamp with time zone) RETURNS TABLE(thread_key text, inbound_started_at timestamp with time zone, responded_at timestamp with time zone, response_seconds integer, responder_id uuid)
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
    WITH msgs AS (
        SELECT
            m.id,
            m.tenant_id,
            m.direction,
            m.created_at,
            m.patient_id,
            m.from_number,
            m.to_number,
            -- mesma logica da view conversation_threads
            COALESCE(
                m.patient_id::text,
                'anon:' || COALESCE(
                    CASE WHEN m.direction = 'inbound' THEN m.from_number ELSE m.to_number END,
                    'unknown'
                )
            ) AS tk
        FROM public.conversation_messages m
        WHERE m.tenant_id = p_tenant_id
          AND m.direction IN ('inbound', 'outbound')
          AND m.created_at >= p_from
          AND m.created_at <= p_to
    ),
    with_prev AS (
        SELECT *,
               LAG(direction) OVER (PARTITION BY tenant_id, tk ORDER BY created_at, id) AS prev_direction
        FROM msgs
    ),
    run_starts AS (
        -- Primeira mensagem de cada "run inbound"
        SELECT tk, tenant_id, created_at AS inbound_started_at
        FROM with_prev
        WHERE direction = 'inbound'
          AND (prev_direction IS NULL OR prev_direction = 'outbound')
    )
    SELECT
        r.tk AS thread_key,
        r.inbound_started_at,
        o.created_at AS responded_at,
        EXTRACT(EPOCH FROM (o.created_at - r.inbound_started_at))::INT AS response_seconds,
        -- Quem respondeu: pega o assigned_to atual da thread (snapshot aproximado)
        a.assigned_to AS responder_id
    FROM run_starts r
    LEFT JOIN LATERAL (
        SELECT created_at
        FROM public.conversation_messages m2
        WHERE m2.tenant_id = r.tenant_id
          AND COALESCE(m2.patient_id::text, 'anon:' || COALESCE(m2.to_number, m2.from_number, 'unknown')) = r.tk
          AND m2.direction = 'outbound'
          AND m2.created_at > r.inbound_started_at
        ORDER BY m2.created_at
        LIMIT 1
    ) o ON true
    LEFT JOIN public.conversation_assignments a
        ON a.tenant_id = r.tenant_id AND a.thread_key = r.tk
    WHERE o.created_at IS NOT NULL;   -- so runs que foram respondidos
$$;

CREATE FUNCTION public.activate_subscription_from_intent(p_intent_id uuid) RETURNS public.subscriptions
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_intent record;
  v_sub public.subscriptions;
  v_days int;
  v_user_id uuid;
  v_plan_id uuid;
  v_target text;
begin
  -- l?? pela VIEW unificada
  select * into v_intent
  from public.subscription_intents
  where id = p_intent_id;

  if not found then
    raise exception 'Intent n??o encontrado: %', p_intent_id;
  end if;

  if v_intent.status <> 'paid' then
    raise exception 'Intent precisa estar paid para ativar assinatura';
  end if;

  -- resolve target e plan_id via plans.key
  select p.id, p.target
    into v_plan_id, v_target
  from public.plans p
  where p.key = v_intent.plan_key
  limit 1;

  if v_plan_id is null then
    raise exception 'Plano n??o encontrado em plans.key = %', v_intent.plan_key;
  end if;

  v_target := lower(coalesce(v_target, ''));

  -- ??? supervisor adicionado
  if v_target not in ('clinic', 'therapist', 'supervisor') then
    raise exception 'Target inv??lido em plans.target: %', v_target;
  end if;

  -- regra por target
  if v_target = 'clinic' then
    if v_intent.tenant_id is null then
      raise exception 'Intent sem tenant_id';
    end if;
  else
    -- therapist ou supervisor: vinculado ao user
    v_user_id := v_intent.user_id;
    if v_user_id is null then
      v_user_id := v_intent.created_by_user_id;
    end if;
  end if;

  if v_target in ('therapist', 'supervisor') and v_user_id is null then
    raise exception 'N??o foi poss??vel determinar user_id para assinatura %.', v_target;
  end if;

  -- cancela assinatura ativa anterior
  if v_target = 'clinic' then
    update public.subscriptions
    set status = 'cancelled',
        cancelled_at = now()
    where tenant_id = v_intent.tenant_id
      and plan_id = v_plan_id
      and status = 'active';
  else
    -- therapist ou supervisor
    update public.subscriptions
    set status = 'cancelled',
        cancelled_at = now()
    where user_id = v_user_id
      and plan_id = v_plan_id
      and status = 'active'
      and tenant_id is null;
  end if;

  -- dura????o do plano (30 dias para mensal)
  v_days := case
    when lower(coalesce(v_intent.interval, 'month')) = 'year' then 365
    else 30
  end;

  -- cria nova assinatura
  insert into public.subscriptions (
    user_id,
    plan_id,
    status,
    started_at,
    expires_at,
    cancelled_at,
    activated_at,
    tenant_id,
    plan_key,
    interval,
    source,
    created_at,
    updated_at
  )
  values (
    case when v_target = 'clinic' then null else v_user_id end,
    v_plan_id,
    'active',
    now(),
    now() + make_interval(days => v_days),
    null,
    now(),
    case when v_target = 'clinic' then v_intent.tenant_id else null end,
    v_intent.plan_key,
    v_intent.interval,
    'manual',
    now(),
    now()
  )
  returning * into v_sub;

  -- grava v??nculo intent ??? subscription
  if v_target = 'clinic' then
    update public.subscription_intents_tenant
    set subscription_id = v_sub.id
    where id = p_intent_id;
  else
    update public.subscription_intents_personal
    set subscription_id = v_sub.id
    where id = p_intent_id;
  end if;

  return v_sub;
end;
$$;

CREATE FUNCTION public.add_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_kind text, p_purchase_id uuid DEFAULT NULL::uuid, p_admin_id uuid DEFAULT NULL::uuid, p_note text DEFAULT NULL::text) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_new_balance INT;
BEGIN
    IF p_amount <= 0 THEN
        RAISE EXCEPTION 'amount must be positive';
    END IF;
    IF p_kind NOT IN ('purchase', 'topup_manual', 'refund', 'adjustment') THEN
        RAISE EXCEPTION 'invalid kind for credit: %', p_kind;
    END IF;

    INSERT INTO public.whatsapp_credits_balance (tenant_id, balance, lifetime_purchased)
    VALUES (p_tenant_id, p_amount, CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END)
    ON CONFLICT (tenant_id) DO UPDATE SET
        balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
        lifetime_purchased = whatsapp_credits_balance.lifetime_purchased
            + CASE WHEN p_kind IN ('purchase', 'topup_manual') THEN p_amount ELSE 0 END,
        low_balance_alerted_at = NULL  -- reset alerta quando recebe creditos
    RETURNING balance INTO v_new_balance;

    INSERT INTO public.whatsapp_credits_transactions
        (tenant_id, kind, amount, balance_after, purchase_id, admin_id, note)
    VALUES
        (p_tenant_id, p_kind, p_amount, v_new_balance, p_purchase_id, p_admin_id, p_note);

    RETURN v_new_balance;
END;
$$;

CREATE FUNCTION public.admin_adjust_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_admin_id uuid, p_note text DEFAULT NULL::text) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_new_balance INT;
    v_current_balance INT;
    v_topup_net INT;
    v_usage_total INT;
    v_removable INT;
    v_clean_note TEXT;
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    IF p_tenant_id IS NULL THEN
        RAISE EXCEPTION 'tenant_required';
    END IF;

    IF p_amount IS NULL OR p_amount = 0 THEN
        RAISE EXCEPTION 'amount_required';
    END IF;

    IF ABS(p_amount) > 1000 THEN
        RAISE EXCEPTION 'amount_exceeds_limit_1000';
    END IF;

    IF p_admin_id IS NULL THEN
        RAISE EXCEPTION 'admin_id_required';
    END IF;

    v_clean_note := NULLIF(TRIM(COALESCE(p_note, '')), '');
    IF v_clean_note IS NOT NULL THEN
        v_clean_note := LEFT(v_clean_note, 500);
    END IF;

    IF p_amount > 0 THEN
        -- ADICIONAR
        INSERT INTO public.whatsapp_credits_balance (tenant_id, balance)
        VALUES (p_tenant_id, p_amount)
        ON CONFLICT (tenant_id) DO UPDATE SET
            balance = whatsapp_credits_balance.balance + EXCLUDED.balance,
            low_balance_alerted_at = NULL
        RETURNING balance INTO v_new_balance;

    ELSE
        -- REMOVER (amount < 0)
        SELECT balance INTO v_current_balance
            FROM public.whatsapp_credits_balance
            WHERE tenant_id = p_tenant_id
            FOR UPDATE;

        IF NOT FOUND THEN
            RAISE EXCEPTION 'tenant_has_no_balance';
        END IF;

        SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
            FROM public.whatsapp_credits_transactions
            WHERE tenant_id = p_tenant_id
              AND kind IN ('topup_manual', 'adjustment', 'refund');

        SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
            FROM public.whatsapp_credits_transactions
            WHERE tenant_id = p_tenant_id
              AND kind = 'usage';

        v_removable := GREATEST(0, v_topup_net - v_usage_total);
        v_removable := LEAST(v_removable, v_current_balance);

        IF ABS(p_amount) > v_removable THEN
            RAISE EXCEPTION 'cannot_remove_beyond_removable: max=%', v_removable;
        END IF;

        UPDATE public.whatsapp_credits_balance
            SET balance = balance + p_amount    -- p_amount ja e negativo
            WHERE tenant_id = p_tenant_id
            RETURNING balance INTO v_new_balance;
    END IF;

    INSERT INTO public.whatsapp_credits_transactions
        (tenant_id, kind, amount, balance_after, admin_id, note)
    VALUES
        (p_tenant_id, 'adjustment', p_amount, v_new_balance, p_admin_id, v_clean_note);

    RETURN v_new_balance;
END;
$$;

CREATE FUNCTION public.admin_credit_addon(p_tenant_id uuid, p_addon_type text, p_amount integer, p_product_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Cr??dito manual'::text, p_payment_method text DEFAULT 'manual'::text, p_price_cents integer DEFAULT 0) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_credit   addon_credits%ROWTYPE;
  v_balance_before INTEGER;
  v_balance_after  INTEGER;
  v_tx_id    UUID;
BEGIN
  -- Upsert addon_credits
  INSERT INTO addon_credits (tenant_id, addon_type, balance, total_purchased)
  VALUES (p_tenant_id, p_addon_type, 0, 0)
  ON CONFLICT (tenant_id, addon_type) DO NOTHING;

  -- Lock e leitura
  SELECT * INTO v_credit
  FROM addon_credits
  WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type
  FOR UPDATE;

  v_balance_before := v_credit.balance;
  v_balance_after  := v_credit.balance + p_amount;

  -- Atualiza saldo
  UPDATE addon_credits
  SET balance         = v_balance_after,
      total_purchased = total_purchased + p_amount,
      low_balance_notified = CASE WHEN v_balance_after > COALESCE(low_balance_threshold, 10) THEN false ELSE low_balance_notified END,
      updated_at      = now()
  WHERE id = v_credit.id;

  -- Registra transa????o
  INSERT INTO addon_transactions (
    tenant_id, addon_type, type, amount,
    balance_before, balance_after,
    product_id, description,
    admin_user_id, payment_method, price_cents
  ) VALUES (
    p_tenant_id, p_addon_type, 'purchase', p_amount,
    v_balance_before, v_balance_after,
    p_product_id, p_description,
    auth.uid(), p_payment_method, p_price_cents
  )
  RETURNING id INTO v_tx_id;

  RETURN jsonb_build_object(
    'success', true,
    'transaction_id', v_tx_id,
    'balance_before', v_balance_before,
    'balance_after', v_balance_after
  );
END;
$$;

CREATE FUNCTION public.admin_delete_email_template_global(p_id uuid) RETURNS boolean
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
  DELETE FROM public.email_templates_global WHERE id = p_id;
  IF NOT FOUND THEN
    RAISE EXCEPTION 'Template com id % n??o encontrado', p_id;
  END IF;
  RETURN true;
END;
$$;

CREATE FUNCTION public.admin_fix_plan_target(p_plan_key text, p_new_target text) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_plan_id uuid;
begin
  -- (opcional) restringe targets v??lidos
  if p_new_target not in ('clinic','therapist') then
    raise exception 'Target inv??lido: %', p_new_target using errcode='P0001';
  end if;

  -- trava o plano
  select id into v_plan_id
  from public.plans
  where key = p_plan_key
  for update;

  if v_plan_id is null then
    raise exception 'Plano n??o encontrado: %', p_plan_key using errcode='P0001';
  end if;

  -- seguran??a: n??o mexer se existe subscription
  if exists (select 1 from public.subscriptions s where s.plan_id = v_plan_id) then
    raise exception 'Plano % possui subscriptions. Migra????o bloqueada.', p_plan_key using errcode='P0001';
  end if;

  -- liga bypass SOMENTE nesta transa????o
  perform set_config('app.plan_migration_bypass', '1', true);

  update public.plans
  set target = p_new_target
  where id = v_plan_id;

end
$$;

CREATE FUNCTION public.admin_upsert_email_template_global(p_id uuid DEFAULT NULL::uuid, p_key text DEFAULT NULL::text, p_domain text DEFAULT NULL::text, p_channel text DEFAULT 'email'::text, p_subject text DEFAULT NULL::text, p_body_html text DEFAULT NULL::text, p_body_text text DEFAULT NULL::text, p_is_active boolean DEFAULT true, p_variables jsonb DEFAULT '{}'::jsonb) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_result jsonb;
  v_id uuid;
BEGIN
  -- UPDATE existente
  IF p_id IS NOT NULL THEN
    UPDATE public.email_templates_global
    SET
      subject   = COALESCE(p_subject, subject),
      body_html = COALESCE(p_body_html, body_html),
      body_text = p_body_text,
      is_active = p_is_active,
      variables = COALESCE(p_variables, variables),
      version   = version + 1
    WHERE id = p_id
    RETURNING to_jsonb(email_templates_global.*) INTO v_result;

    IF v_result IS NULL THEN
      RAISE EXCEPTION 'Template com id % n??o encontrado', p_id;
    END IF;

    RETURN v_result;
  END IF;

  -- INSERT novo
  IF p_key IS NULL OR p_domain IS NULL OR p_subject IS NULL OR p_body_html IS NULL THEN
    RAISE EXCEPTION 'key, domain, subject e body_html s??o obrigat??rios para novo template';
  END IF;

  INSERT INTO public.email_templates_global (key, domain, channel, subject, body_html, body_text, is_active, variables)
  VALUES (p_key, p_domain, p_channel, p_subject, p_body_html, p_body_text, p_is_active, p_variables)
  RETURNING to_jsonb(email_templates_global.*) INTO v_result;

  RETURN v_result;
END;
$$;

CREATE FUNCTION public.agenda_cfg_sync() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if new.agenda_view_mode = 'custom' then
    new.usar_horario_admin_custom := true;
    new.admin_inicio_visualizacao := new.agenda_custom_start;
    new.admin_fim_visualizacao := new.agenda_custom_end;
  else
    new.usar_horario_admin_custom := false;
  end if;

  return new;
end;
$$;

CREATE FUNCTION public.agendador_dias_disponiveis(p_slug text, p_ano integer, p_mes integer) RETURNS TABLE(data date, tem_slots boolean)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_owner_id     uuid;
  v_antecedencia int;
  v_agora        timestamptz;
  v_data         date;
  v_data_inicio  date;
  v_data_fim     date;
  v_db_dow       int;
  v_tem_slot     boolean;
  v_bloqueado    boolean;
BEGIN
  SELECT c.owner_id, c.antecedencia_minima_horas
  INTO v_owner_id, v_antecedencia
  FROM public.agendador_configuracoes c
  WHERE c.link_slug = p_slug AND c.ativo = true
  LIMIT 1;

  IF v_owner_id IS NULL THEN RETURN; END IF;

  v_agora       := now();
  v_data_inicio := make_date(p_ano, p_mes, 1);
  v_data_fim    := (v_data_inicio + interval '1 month' - interval '1 day')::date;

  v_data := v_data_inicio;
  WHILE v_data <= v_data_fim LOOP
    v_db_dow := extract(dow from v_data::timestamp)::int;

    -- ?????? Dia inteiro bloqueado? (agenda_bloqueios) ???????????????????????????????????????????????????????????????????????????
    SELECT EXISTS (
      SELECT 1 FROM public.agenda_bloqueios b
      WHERE b.owner_id     = v_owner_id
        AND b.data_inicio <= v_data
        AND COALESCE(b.data_fim, v_data) >= v_data
        AND b.hora_inicio IS NULL   -- bloqueio de dia inteiro
        AND (
          (NOT b.recorrente)
          OR (b.recorrente AND b.dia_semana = v_db_dow)
        )
    ) INTO v_bloqueado;

    IF v_bloqueado THEN
      v_data := v_data + 1;
      CONTINUE;
    END IF;

    -- ?????? Tem slots dispon??veis no dia? ???????????????????????????????????????????????????????????????????????????????????????????????????????????????
    SELECT EXISTS (
      SELECT 1 FROM public.agenda_online_slots s
      WHERE s.owner_id = v_owner_id
        AND s.weekday  = v_db_dow
        AND s.enabled  = true
        AND (v_data::text || ' ' || s.time::text)::timestamp
              AT TIME ZONE 'America/Sao_Paulo'
            >= v_agora + (v_antecedencia || ' hours')::interval
    ) INTO v_tem_slot;

    IF v_tem_slot THEN
      data      := v_data;
      tem_slots := true;
      RETURN NEXT;
    END IF;

    v_data := v_data + 1;
  END LOOP;
END;
$$;

CREATE FUNCTION public.agendador_gerar_slug() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  v_slug text;
  v_exists boolean;
BEGIN
  -- s?? gera se ativou e n??o tem slug ainda
  IF NEW.ativo = true AND (NEW.link_slug IS NULL OR NEW.link_slug = '') THEN
    LOOP
      v_slug := lower(substring(replace(gen_random_uuid()::text, '-', ''), 1, 8));
      SELECT EXISTS (
        SELECT 1 FROM public.agendador_configuracoes
        WHERE link_slug = v_slug AND owner_id <> NEW.owner_id
      ) INTO v_exists;
      EXIT WHEN NOT v_exists;
    END LOOP;
    NEW.link_slug := v_slug;
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.agendador_slots_disponiveis(p_slug text, p_data date) RETURNS TABLE(hora time without time zone, disponivel boolean)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_owner_id       uuid;
  v_duracao        int;
  v_antecedencia   int;
  v_agora          timestamptz;
  v_db_dow         int;
  v_slot           time;
  v_slot_fim       time;
  v_slot_ts        timestamptz;
  v_ocupado        boolean;
  -- loop de recorr??ncias
  v_rule           RECORD;
  v_rule_start_dow int;
  v_first_occ      date;
  v_day_diff       int;
  v_ex_type        text;
BEGIN
  SELECT c.owner_id, c.duracao_sessao_min, c.antecedencia_minima_horas
  INTO v_owner_id, v_duracao, v_antecedencia
  FROM public.agendador_configuracoes c
  WHERE c.link_slug = p_slug AND c.ativo = true
  LIMIT 1;

  IF v_owner_id IS NULL THEN RETURN; END IF;

  v_agora  := now();
  v_db_dow := extract(dow from p_data::timestamp)::int;

  -- ?????? Dia inteiro bloqueado? (agenda_bloqueios sem hora) ?????????????????????????????????????????????????????????
  -- Se sim, n??o h?? nenhum slot dispon??vel ??? retorna vazio.
  IF EXISTS (
    SELECT 1 FROM public.agenda_bloqueios b
    WHERE b.owner_id     = v_owner_id
      AND b.data_inicio <= p_data
      AND COALESCE(b.data_fim, p_data) >= p_data
      AND b.hora_inicio IS NULL   -- bloqueio de dia inteiro
      AND (
        (NOT b.recorrente)
        OR (b.recorrente AND b.dia_semana = v_db_dow)
      )
  ) THEN
    RETURN;
  END IF;

  FOR v_slot IN
    SELECT s.time
    FROM public.agenda_online_slots s
    WHERE s.owner_id = v_owner_id
      AND s.weekday  = v_db_dow
      AND s.enabled  = true
    ORDER BY s.time
  LOOP
    v_slot_fim := v_slot + (v_duracao || ' minutes')::interval;
    v_ocupado  := false;

    -- ?????? Anteced??ncia m??nima ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
    v_slot_ts := (p_data::text || ' ' || v_slot::text)::timestamp
                  AT TIME ZONE 'America/Sao_Paulo';
    IF v_slot_ts < v_agora + (v_antecedencia || ' hours')::interval THEN
      v_ocupado := true;
    END IF;

    -- ?????? Bloqueio de hor??rio espec??fico (agenda_bloqueios com hora) ?????????????????????????????????
    IF NOT v_ocupado THEN
      SELECT EXISTS (
        SELECT 1 FROM public.agenda_bloqueios b
        WHERE b.owner_id     = v_owner_id
          AND b.data_inicio <= p_data
          AND COALESCE(b.data_fim, p_data) >= p_data
          AND b.hora_inicio IS NOT NULL
          AND b.hora_inicio  < v_slot_fim
          AND b.hora_fim     > v_slot
          AND (
            (NOT b.recorrente)
            OR (b.recorrente AND b.dia_semana = v_db_dow)
          )
      ) INTO v_ocupado;
    END IF;

    -- ?????? Eventos avulsos internos (agenda_eventos) ????????????????????????????????????????????????????????????????????????????????????
    IF NOT v_ocupado THEN
      SELECT EXISTS (
        SELECT 1 FROM public.agenda_eventos e
        WHERE e.owner_id = v_owner_id
          AND e.status::text NOT IN ('cancelado', 'faltou')
          AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::date = p_data
          AND (e.inicio_em AT TIME ZONE 'America/Sao_Paulo')::time < v_slot_fim
          AND (e.fim_em    AT TIME ZONE 'America/Sao_Paulo')::time > v_slot
      ) INTO v_ocupado;
    END IF;

    -- ?????? Recorr??ncias ativas (recurrence_rules) ?????????????????????????????????????????????????????????????????????????????????????????????
    IF NOT v_ocupado THEN
      FOR v_rule IN
        SELECT
          r.id,
          r.start_date::date                AS start_date,
          r.end_date::date                  AS end_date,
          r.start_time::time                AS start_time,
          r.end_time::time                  AS end_time,
          COALESCE(r.interval, 1)::int      AS interval
        FROM public.recurrence_rules r
        WHERE r.owner_id = v_owner_id
          AND r.status   = 'ativo'
          AND p_data    >= r.start_date::date
          AND (r.end_date IS NULL OR p_data <= r.end_date::date)
          AND v_db_dow = ANY(r.weekdays)
          AND r.start_time::time < v_slot_fim
          AND r.end_time::time   > v_slot
      LOOP
        v_rule_start_dow := extract(dow from v_rule.start_date)::int;
        v_first_occ      := v_rule.start_date
                              + (((v_db_dow - v_rule_start_dow + 7) % 7))::int;
        v_day_diff       := (p_data - v_first_occ)::int;

        IF v_day_diff >= 0 AND v_day_diff % (7 * v_rule.interval) = 0 THEN
          v_ex_type := NULL;
          SELECT ex.type INTO v_ex_type
          FROM public.recurrence_exceptions ex
          WHERE ex.recurrence_id = v_rule.id
            AND ex.original_date = p_data
          LIMIT 1;

          IF v_ex_type IS NULL OR v_ex_type NOT IN (
            'cancel_session', 'patient_missed',
            'therapist_canceled', 'holiday_block',
            'reschedule_session'
          ) THEN
            v_ocupado := true;
            EXIT;
          END IF;
        END IF;
      END LOOP;
    END IF;

    -- ?????? Recorr??ncias remarcadas para este dia ????????????????????????????????????????????????????????????????????????????????????????????????
    IF NOT v_ocupado THEN
      SELECT EXISTS (
        SELECT 1
        FROM public.recurrence_exceptions ex
        JOIN public.recurrence_rules r ON r.id = ex.recurrence_id
        WHERE r.owner_id  = v_owner_id
          AND r.status    = 'ativo'
          AND ex.type     = 'reschedule_session'
          AND ex.new_date = p_data
          AND COALESCE(ex.new_start_time, r.start_time)::time < v_slot_fim
          AND COALESCE(ex.new_end_time,   r.end_time)::time   > v_slot
      ) INTO v_ocupado;
    END IF;

    -- ?????? Solicita????es p??blicas pendentes ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????
    IF NOT v_ocupado THEN
      SELECT EXISTS (
        SELECT 1 FROM public.agendador_solicitacoes sol
        WHERE sol.owner_id        = v_owner_id
          AND sol.status          = 'pendente'
          AND sol.data_solicitada = p_data
          AND sol.hora_solicitada = v_slot
          AND (sol.reservado_ate IS NULL OR sol.reservado_ate > v_agora)
      ) INTO v_ocupado;
    END IF;

    hora       := v_slot;
    disponivel := NOT v_ocupado;
    RETURN NEXT;
  END LOOP;
END;
$$;

CREATE FUNCTION public.auto_create_financial_record_from_session() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_price          NUMERIC(10,2);
  v_services_total NUMERIC(10,2);
  v_already_billed BOOLEAN;
BEGIN
  -- ?????? Guards de sa??da r??pida ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

  -- S?? processa quando o status muda PARA 'realizado'
  IF NEW.status::TEXT <> 'realizado' THEN
    RETURN NEW;
  END IF;

  -- S?? processa quando houve mudan??a real de status
  IF OLD.status IS NOT DISTINCT FROM NEW.status THEN
    RETURN NEW;
  END IF;

  -- S?? sess??es (n??o bloqueios, feriados, etc.)
  IF NEW.tipo::TEXT <> 'sessao' THEN
    RETURN NEW;
  END IF;

  -- Paciente obrigat??rio para vincular a cobran??a
  IF NEW.patient_id IS NULL THEN
    RETURN NEW;
  END IF;

  -- Sess??es de pacote t??m cobran??a gerenciada por billing_contract
  IF NEW.billing_contract_id IS NOT NULL THEN
    RETURN NEW;
  END IF;

  -- Idempot??ncia: j?? existe financial_record para este evento?
  SELECT billed INTO v_already_billed
  FROM public.agenda_eventos
  WHERE id = NEW.id;

  IF v_already_billed = TRUE THEN
    -- Confirma no financial_records tamb??m (dupla verifica????o)
    IF EXISTS (
      SELECT 1 FROM public.financial_records
      WHERE agenda_evento_id = NEW.id AND deleted_at IS NULL
    ) THEN
      RETURN NEW;
    END IF;
  END IF;

  -- ?????? Busca do pre??o ??????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

  v_price := NULL;

  -- Prioridade 1: soma dos servi??os da regra de recorr??ncia
  IF NEW.recurrence_id IS NOT NULL THEN
    SELECT COALESCE(SUM(rrs.final_price), 0)
    INTO v_services_total
    FROM public.recurrence_rule_services rrs
    WHERE rrs.rule_id = NEW.recurrence_id;

    IF v_services_total > 0 THEN
      v_price := v_services_total;
    END IF;

    -- Prioridade 2: price direto da regra (fallback se sem servi??os)
    IF v_price IS NULL OR v_price = 0 THEN
      SELECT price INTO v_price
      FROM public.recurrence_rules
      WHERE id = NEW.recurrence_id;
    END IF;
  END IF;

  -- Prioridade 3: price do pr??prio evento de agenda
  IF v_price IS NULL OR v_price = 0 THEN
    v_price := NEW.price;
  END IF;

  -- Sem pre??o ??? n??o criar registro (n??o ?? erro, apenas skip silencioso)
  IF v_price IS NULL OR v_price <= 0 THEN
    RETURN NEW;
  END IF;

  -- ?????? Cria????o do financial_record ???????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????

  INSERT INTO public.financial_records (
    owner_id,
    tenant_id,
    patient_id,
    agenda_evento_id,
    type,
    amount,
    discount_amount,
    final_amount,
    clinic_fee_pct,
    clinic_fee_amount,
    status,
    due_date
    -- payment_method: NULL at?? o momento do pagamento (mark_as_paid preenche)
  ) VALUES (
    NEW.owner_id,
    NEW.tenant_id,
    NEW.patient_id,
    NEW.id,
    'receita',
    v_price,
    0,
    v_price,
    0,    -- clinic_fee_pct: sem campo de configura????o global no schema atual.
    0,    -- clinic_fee_amount: calculado manualmente ou via update posterior.
    'pending',
    (NEW.inicio_em::DATE + 7)  -- vencimento padr??o: 7 dias ap??s a sess??o
  );

  -- ?????? Marca sess??o como billed ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
  -- UPDATE em billed (n??o em status) ??? n??o re-dispara este trigger
  UPDATE public.agenda_eventos
  SET billed = TRUE
  WHERE id = NEW.id;

  RETURN NEW;

EXCEPTION
  WHEN OTHERS THEN
    -- Log silencioso: nunca bloquear a agenda por falha financeira
    RAISE WARNING '[auto_create_financial_record_from_session] evento=% erro=%',
      NEW.id, SQLERRM;
    RETURN NEW;
END;
$$;

CREATE FUNCTION public.can_delete_patient(p_patient_id uuid) RETURNS boolean
    LANGUAGE sql STABLE SECURITY DEFINER
    AS $$
  SELECT NOT EXISTS (
    SELECT 1 FROM public.agenda_eventos    WHERE patient_id = p_patient_id
    UNION ALL
    SELECT 1 FROM public.recurrence_rules  WHERE patient_id = p_patient_id
    UNION ALL
    SELECT 1 FROM public.billing_contracts WHERE patient_id = p_patient_id
  );
$$;

CREATE FUNCTION public.cancel_notifications_on_opt_out() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  -- WhatsApp opt-out
  IF OLD.whatsapp_opt_in = true AND NEW.whatsapp_opt_in = false THEN
    PERFORM public.cancel_patient_pending_notifications(
      NEW.patient_id, 'whatsapp'
    );
  END IF;
  -- Email opt-out
  IF OLD.email_opt_in = true AND NEW.email_opt_in = false THEN
    PERFORM public.cancel_patient_pending_notifications(
      NEW.patient_id, 'email'
    );
  END IF;
  -- SMS opt-out
  IF OLD.sms_opt_in = true AND NEW.sms_opt_in = false THEN
    PERFORM public.cancel_patient_pending_notifications(
      NEW.patient_id, 'sms'
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
    PERFORM public.cancel_patient_pending_notifications(
      NEW.patient_id, NULL, NEW.id
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.cancel_patient_pending_notifications(p_patient_id uuid, p_channel text DEFAULT NULL::text, p_evento_id uuid DEFAULT NULL::uuid) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_canceled integer;
BEGIN
  UPDATE public.notification_queue
  SET status = 'cancelado',
      updated_at = now()
  WHERE patient_id = p_patient_id
    AND status IN ('pendente', 'processando')
    AND (p_channel IS NULL OR channel = p_channel)
    AND (p_evento_id IS NULL OR agenda_evento_id = p_evento_id);

  GET DIAGNOSTICS v_canceled = ROW_COUNT;
  RETURN v_canceled;
END;
$$;

CREATE FUNCTION public.cancel_recurrence_from(p_recurrence_id uuid, p_from_date date) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
  UPDATE public.recurrence_rules
  SET
    end_date   = p_from_date - INTERVAL '1 day',
    open_ended = false,
    status     = CASE
                   WHEN p_from_date <= start_date THEN 'cancelado'
                   ELSE status
                 END,
    updated_at = now()
  WHERE id = p_recurrence_id;
END;
$$;

CREATE FUNCTION public.cancel_subscription(p_subscription_id uuid) RETURNS public.subscriptions
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_sub public.subscriptions;
  v_owner_type text;
  v_owner_ref uuid;
begin

  select *
  into v_sub
  from public.subscriptions
  where id = p_subscription_id
  for update;

  if not found then
    raise exception 'Subscription n??o encontrada';
  end if;

  if v_sub.status = 'canceled' then
    return v_sub;
  end if;

  if v_sub.tenant_id is not null then
    v_owner_type := 'clinic';
    v_owner_ref := v_sub.tenant_id;
  elsif v_sub.user_id is not null then
    v_owner_type := 'therapist';
    v_owner_ref := v_sub.user_id;
  else
    v_owner_type := null;
    v_owner_ref := null;
  end if;

  update public.subscriptions
  set status = 'canceled',
      cancel_at_period_end = false,
      updated_at = now()
  where id = p_subscription_id
  returning * into v_sub;

  insert into public.subscription_events(
    subscription_id,
    owner_id,
    owner_type,
    owner_ref,
    event_type,
    old_plan_id,
    new_plan_id,
    created_by,
    reason,
    source,
    metadata
  )
  values (
    v_sub.id,
    v_owner_ref,
    v_owner_type,
    v_owner_ref,
    'canceled',
    v_sub.plan_id,
    v_sub.plan_id,
    auth.uid(),
    'Cancelamento manual via admin',
    'admin_panel',
    jsonb_build_object('previous_status', 'active')
  );

  if v_owner_ref is not null then
    insert into public.entitlements_invalidation(owner_id, changed_at)
    values (v_owner_ref, now())
    on conflict (owner_id)
    do update set changed_at = excluded.changed_at;
  end if;

  return v_sub;

end;
$$;

CREATE FUNCTION public.cancelar_eventos_serie(p_serie_id uuid, p_a_partir_de timestamp with time zone DEFAULT now()) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_count integer;
BEGIN
  UPDATE public.agenda_eventos
     SET status     = 'cancelado',
         updated_at = now()
   WHERE serie_id  = p_serie_id
     AND inicio_em >= p_a_partir_de
     AND status NOT IN ('realizado', 'cancelado');

  GET DIAGNOSTICS v_count = ROW_COUNT;
  RETURN v_count;
END;
$$;

CREATE FUNCTION public.change_subscription_plan(p_subscription_id uuid, p_new_plan_id uuid) RETURNS public.subscriptions
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_sub public.subscriptions;
  v_old_plan uuid;
  v_new_key text;

  v_owner_type text;
  v_owner_ref uuid;

  v_new_target text;
  v_sub_target text;
begin
  select *
    into v_sub
  from public.subscriptions
  where id = p_subscription_id
  for update;

  if not found then
    raise exception 'Subscription n??o encontrada';
  end if;

  v_old_plan := v_sub.plan_id;

  if v_old_plan = p_new_plan_id then
    return v_sub;
  end if;

  select key, target
    into v_new_key, v_new_target
  from public.plans
  where id = p_new_plan_id;

  if v_new_key is null then
    raise exception 'Plano n??o encontrado';
  end if;

  v_new_target := lower(coalesce(v_new_target, ''));

  v_sub_target := case
    when v_sub.tenant_id is not null then 'clinic'
    else 'therapist'
  end;

  if v_new_target <> v_sub_target then
    raise exception 'Plano inv??lido para este tipo de assinatura. Assinatura ?? % e o plano ?? %.',
      v_sub_target, v_new_target
      using errcode = 'P0001';
  end if;

  if v_sub.tenant_id is not null then
    v_owner_type := 'clinic';
    v_owner_ref := v_sub.tenant_id;
  elsif v_sub.user_id is not null then
    v_owner_type := 'therapist';
    v_owner_ref := v_sub.user_id;
  else
    v_owner_type := null;
    v_owner_ref := null;
  end if;

  update public.subscriptions
  set plan_id = p_new_plan_id,
      plan_key = v_new_key,
      updated_at = now()
  where id = p_subscription_id
  returning * into v_sub;

  insert into public.subscription_events(
    subscription_id,
    owner_id,
    owner_type,
    owner_ref,
    event_type,
    old_plan_id,
    new_plan_id,
    created_by,
    reason,
    source,
    metadata
  )
  values (
    v_sub.id,
    v_owner_ref,
    v_owner_type,
    v_owner_ref,
    'plan_changed',
    v_old_plan,
    p_new_plan_id,
    auth.uid(),
    'Plan change via DEV menu',
    'dev_menu',
    jsonb_build_object(
      'previous_plan', v_old_plan,
      'new_plan', p_new_plan_id,
      'new_plan_key', v_new_key,
      'new_plan_target', v_new_target
    )
  );

  if v_owner_ref is not null then
    insert into public.entitlements_invalidation (owner_id, changed_at)
    values (v_owner_ref, now())
    on conflict (owner_id)
    do update set changed_at = excluded.changed_at;
  end if;

  return v_sub;
end;
$$;

CREATE FUNCTION public.check_rate_limit(p_ip_hash text, p_endpoint text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
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;
$$;

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

CREATE FUNCTION public.cleanup_notification_queue() RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_deleted integer;
BEGIN
  DELETE FROM public.notification_queue
  WHERE status IN ('enviado', 'cancelado', 'ignorado')
    AND created_at < now() - interval '90 days';

  GET DIAGNOSTICS v_deleted = ROW_COUNT;
  RETURN v_deleted;
END;
$$;

CREATE FUNCTION public.convert_abandoned_intake_to_lead(p_intake_id uuid) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_intake RECORD;
    v_tenant_id UUID;
    v_thread_key TEXT;
    v_phone TEXT;
    v_note_body TEXT;
    v_admin_id UUID;
    v_msg_id BIGINT;
BEGIN
    SELECT * INTO v_intake FROM public.patient_intake_requests WHERE id = p_intake_id;
    IF NOT FOUND THEN RAISE EXCEPTION 'intake_not_found'; END IF;
    IF v_intake.status = 'abandoned_lead' THEN RETURN v_intake.lead_thread_key::UUID; END IF;

    -- Tenant_id vem via owner_id (tenant_members)
    SELECT tenant_id INTO v_tenant_id
        FROM public.tenant_members
        WHERE user_id = v_intake.owner_id
        ORDER BY CASE role WHEN 'tenant_admin' THEN 1 WHEN 'clinic_admin' THEN 2 ELSE 3 END
        LIMIT 1;

    IF v_tenant_id IS NULL THEN RAISE EXCEPTION 'tenant_not_resolved'; END IF;

    -- Normaliza telefone pra thread_key
    v_phone := regexp_replace(COALESCE(v_intake.telefone, ''), '\D', '', 'g');
    IF length(v_phone) BETWEEN 10 AND 11 THEN v_phone := '55' || v_phone; END IF;
    IF v_phone = '' THEN v_phone := 'unknown'; END IF;
    v_thread_key := 'anon:' || v_phone;

    -- Nota com dados coletados
    v_note_body := format(
        '📋 Lead abandonado (cadastro externo):%s%sNome: %s%sTelefone: %s%sE-mail: %s%sMotivo/Observacoes: %s%s%sIniciou em: %s · Ultima atualizacao: %s',
        E'\n', E'\n',
        COALESCE(v_intake.nome_completo, '—'),
        E'\n',
        COALESCE(v_intake.telefone, '—'),
        E'\n',
        COALESCE(v_intake.email_principal, '—'),
        E'\n',
        COALESCE(v_intake.onde_nos_conheceu, '—'),
        E'\n', E'\n',
        to_char(v_intake.created_at AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI'),
        to_char(COALESCE(v_intake.last_progress_at, v_intake.updated_at) AT TIME ZONE 'America/Sao_Paulo', 'DD/MM HH24:MI')
    );

    -- Pega 1 admin do tenant pra preencher created_by da nota
    SELECT user_id INTO v_admin_id
        FROM public.tenant_members
        WHERE tenant_id = v_tenant_id
          AND role IN ('tenant_admin', 'clinic_admin')
          AND status = 'active'
        LIMIT 1;

    IF v_admin_id IS NULL THEN
        v_admin_id := v_intake.owner_id;
    END IF;

    -- Cria mensagem placeholder (outbound sistema — entra no thread do CRM)
    INSERT INTO public.conversation_messages
        (tenant_id, channel, direction, from_number, to_number, body, provider,
         provider_raw, kanban_status)
    VALUES (
        v_tenant_id, 'whatsapp', 'inbound',
        CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
        NULL,
        format('🧾 Cadastro externo iniciado e não finalizado. %s entrou em contato via link público mas abandonou o formulário — ver nota interna.',
               COALESCE(v_intake.nome_completo, 'Visitante')),
        'system',
        jsonb_build_object('lead_from_abandoned_intake', true, 'intake_id', v_intake.id),
        'awaiting_us'
    ) RETURNING id INTO v_msg_id;

    -- Cria nota interna
    INSERT INTO public.conversation_notes
        (tenant_id, thread_key, contact_number, body, created_by)
    VALUES (
        v_tenant_id, v_thread_key,
        CASE WHEN v_phone = 'unknown' THEN NULL ELSE v_phone END,
        v_note_body, v_admin_id
    );

    -- Atualiza intake
    UPDATE public.patient_intake_requests
        SET status = 'abandoned_lead',
            lead_thread_key = v_thread_key,
            updated_at = now()
        WHERE id = p_intake_id;

    RETURN p_intake_id;
END;
$$;

CREATE FUNCTION public.create_clinic_tenant(p_name text) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_uid uuid;
  v_tenant uuid;
  v_name text;
begin
  v_uid := auth.uid();
  if v_uid is null then
    raise exception 'Not authenticated';
  end if;

  v_name := nullif(trim(coalesce(p_name, '')), '');
  if v_name is null then
    v_name := 'Cl??nica';
  end if;

  insert into public.tenants (name, kind, created_at)
  values (v_name, 'clinic', now())
  returning id into v_tenant;

  insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
  values (v_tenant, v_uid, 'tenant_admin', 'active', now());

  return v_tenant;
end;
$$;

CREATE FUNCTION public.create_financial_record_for_session(p_tenant_id uuid, p_owner_id uuid, p_patient_id uuid, p_agenda_evento_id uuid, p_amount numeric, p_due_date date) RETURNS SETOF public.financial_records
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_existing public.financial_records%ROWTYPE;
  v_new      public.financial_records%ROWTYPE;
BEGIN
  -- Idempot??ncia: retorna o registro existente se j?? foi criado
  SELECT * INTO v_existing
  FROM public.financial_records
  WHERE agenda_evento_id = p_agenda_evento_id
    AND deleted_at IS NULL
  LIMIT 1;

  IF FOUND THEN
    RETURN NEXT v_existing;
    RETURN;
  END IF;

  -- Cria o novo registro
  INSERT INTO public.financial_records (
    tenant_id,
    owner_id,
    patient_id,
    agenda_evento_id,
    amount,
    discount_amount,
    final_amount,
    status,
    due_date
  ) VALUES (
    p_tenant_id,
    p_owner_id,
    p_patient_id,
    p_agenda_evento_id,
    p_amount,
    0,
    p_amount,
    'pending',
    p_due_date
  )
  RETURNING * INTO v_new;

  -- Marca o evento da agenda como billed = true
  UPDATE public.agenda_eventos
    SET billed = TRUE
  WHERE id = p_agenda_evento_id;

  RETURN NEXT v_new;
END;
$$;

CREATE FUNCTION public.create_patient_intake_request(p_token text, p_name text, p_email text DEFAULT NULL::text, p_phone text DEFAULT NULL::text, p_notes text DEFAULT NULL::text, p_consent boolean DEFAULT false) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
declare
  v_owner uuid;
  v_active boolean;
  v_expires timestamptz;
  v_max_uses int;
  v_uses int;
  v_id uuid;
begin
  select owner_id, active, expires_at, max_uses, uses
    into v_owner, v_active, v_expires, v_max_uses, v_uses
  from public.patient_invites
  where token = p_token
  limit 1;

  if v_owner is null then
    raise exception 'Token inv??lido';
  end if;

  if v_active is not true then
    raise exception 'Link desativado';
  end if;

  if v_expires is not null and now() > v_expires then
    raise exception 'Link expirado';
  end if;

  if v_max_uses is not null and v_uses >= v_max_uses then
    raise exception 'Limite de uso atingido';
  end if;

  if p_name is null or length(trim(p_name)) = 0 then
    raise exception 'Nome ?? obrigat??rio';
  end if;

  insert into public.patient_intake_requests
    (owner_id, token, name, email, phone, notes, consent, status)
  values
    (v_owner, p_token, trim(p_name),
     nullif(lower(trim(p_email)), ''),
     nullif(trim(p_phone), ''),
     nullif(trim(p_notes), ''),
     coalesce(p_consent, false),
     'new')
  returning id into v_id;

  update public.patient_invites
    set uses = uses + 1
  where token = p_token;

  return v_id;
end;
$$;

CREATE FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $_$
DECLARE
  v_owner_id   uuid;
  v_tenant_id  uuid;
  v_active     boolean;
  v_expires    timestamptz;
  v_max_uses   int;
  v_uses       int;
  v_intake_id  uuid;
  v_birth_raw  text;
  v_birth      date;
  v_email      text;
  v_email_alt  text;
  v_nome       text;
  v_consent    boolean;
  v_genero     text;
  v_estado_civil text;

  -- Whitelists para campos tipados
  c_generos        text[] := ARRAY['male','female','non_binary','other','na'];
  c_estados_civis  text[] := ARRAY['single','married','divorced','widowed','na'];
BEGIN
  -- ───────────────────────────────────────────────────────────────────────
  -- Carrega invite e valida TUDO (A#16)
  -- ───────────────────────────────────────────────────────────────────────
  SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
    INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
  FROM public.patient_invites
  WHERE token = p_token
  LIMIT 1;

  IF v_owner_id IS NULL THEN
    RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
  END IF;

  IF v_active IS NOT TRUE THEN
    RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
  END IF;

  IF v_expires IS NOT NULL AND now() > v_expires THEN
    RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
  END IF;

  IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
    RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- Resolver tenant_id (A#19)
  -- Se o invite não tem tenant_id, tenta achar a membership active do owner.
  -- ───────────────────────────────────────────────────────────────────────
  IF v_tenant_id IS NULL THEN
    SELECT tenant_id
      INTO v_tenant_id
    FROM public.tenant_members
    WHERE user_id = v_owner_id
      AND status = 'active'
    ORDER BY created_at ASC
    LIMIT 1;
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- Sanitização + validações de campos (A#27)
  -- ───────────────────────────────────────────────────────────────────────

  -- Nome obrigatório (max 200)
  v_nome := nullif(trim(p_payload->>'nome_completo'), '');
  IF v_nome IS NULL THEN
    RAISE EXCEPTION 'Nome é obrigatório';
  END IF;
  IF length(v_nome) > 200 THEN
    RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)';
  END IF;

  -- Email principal obrigatório + lower + max 120
  v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
  IF v_email IS NULL THEN
    RAISE EXCEPTION 'E-mail é obrigatório';
  END IF;
  IF length(v_email) > 120 THEN
    RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)';
  END IF;
  IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
    RAISE EXCEPTION 'E-mail inválido';
  END IF;

  -- Email alternativo opcional mas validado se presente
  v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
  IF v_email_alt IS NOT NULL THEN
    IF length(v_email_alt) > 120 THEN
      RAISE EXCEPTION 'E-mail alternativo muito longo';
    END IF;
    IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
      RAISE EXCEPTION 'E-mail alternativo inválido';
    END IF;
  END IF;

  -- Consent obrigatório
  v_consent := coalesce((p_payload->>'consent')::boolean, false);
  IF v_consent IS NOT TRUE THEN
    RAISE EXCEPTION 'Consentimento é obrigatório';
  END IF;

  -- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD
  v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
  v_birth := CASE
    WHEN v_birth_raw IS NULL THEN NULL
    WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
    WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
    ELSE NULL
  END;
  -- Sanidade: nascimento não pode ser no futuro nem antes de 1900
  IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
    v_birth := NULL;
  END IF;

  -- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string)
  v_genero := nullif(trim(p_payload->>'genero'), '');
  IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
    v_genero := NULL;
  END IF;

  v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
  IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
    v_estado_civil := NULL;
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- INSERT com sanitização inline
  -- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno
  -- do terapeuta, não deve vir do paciente.
  -- ───────────────────────────────────────────────────────────────────────
  INSERT INTO public.patient_intake_requests (
    owner_id,
    tenant_id,
    token,
    status,
    consent,

    nome_completo,
    email_principal,
    email_alternativo,
    telefone,
    telefone_alternativo,

    avatar_url,

    data_nascimento,
    cpf,
    rg,
    genero,
    estado_civil,
    profissao,
    escolaridade,
    nacionalidade,
    naturalidade,

    cep,
    pais,
    cidade,
    estado,
    endereco,
    numero,
    complemento,
    bairro,

    observacoes,

    encaminhado_por,
    onde_nos_conheceu
  )
  VALUES (
    v_owner_id,
    v_tenant_id,
    p_token,
    'new',
    v_consent,

    v_nome,
    v_email,
    v_email_alt,
    nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
    nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),

    left(nullif(trim(p_payload->>'avatar_url'), ''), 500),

    v_birth,
    nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
    left(nullif(trim(p_payload->>'rg'), ''), 20),
    v_genero,
    v_estado_civil,
    left(nullif(trim(p_payload->>'profissao'), ''), 120),
    left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
    left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
    left(nullif(trim(p_payload->>'naturalidade'), ''), 120),

    nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
    left(nullif(trim(p_payload->>'pais'), ''), 60),
    left(nullif(trim(p_payload->>'cidade'), ''), 120),
    left(nullif(trim(p_payload->>'estado'), ''), 2),
    left(nullif(trim(p_payload->>'endereco'), ''), 200),
    left(nullif(trim(p_payload->>'numero'), ''), 20),
    left(nullif(trim(p_payload->>'complemento'), ''), 120),
    left(nullif(trim(p_payload->>'bairro'), ''), 120),

    left(nullif(trim(p_payload->>'observacoes'), ''), 2000),

    left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
    left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
  )
  RETURNING id INTO v_intake_id;

  -- Incrementa contador de uso (A#16)
  UPDATE public.patient_invites
    SET uses = uses + 1
  WHERE token = p_token;

  RETURN v_intake_id;
END;
$_$;

CREATE FUNCTION public.create_patient_intake_request_v2(p_token text, p_payload jsonb, p_client_info text DEFAULT NULL::text) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $_$
DECLARE
  v_owner_id   uuid;
  v_tenant_id  uuid;
  v_active     boolean;
  v_expires    timestamptz;
  v_max_uses   int;
  v_uses       int;
  v_intake_id  uuid;
  v_birth_raw  text;
  v_birth      date;
  v_email      text;
  v_email_alt  text;
  v_nome       text;
  v_consent    boolean;
  v_genero     text;
  v_estado_civil text;
  v_err_msg    text;
  v_err_code   text;
  v_clean_info text;

  c_generos        text[] := ARRAY['male','female','non_binary','other','na'];
  c_estados_civis  text[] := ARRAY['single','married','divorced','widowed','na'];

  -- Helper para logar: escreve em patient_invite_attempts e não propaga erros.
  -- Implementado inline porque PL/pgSQL não permite sub-rotina local fácil.
BEGIN
  -- Sanitiza client_info recebido (cap + trim)
  v_clean_info := nullif(left(trim(coalesce(p_client_info, '')), 500), '');

  -- ───────────────────────────────────────────────────────────────────────
  -- Resolve invite + valida TUDO (A#16)
  -- ───────────────────────────────────────────────────────────────────────
  SELECT owner_id, tenant_id, active, expires_at, max_uses, uses
    INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses
  FROM public.patient_invites
  WHERE token = p_token
  LIMIT 1;

  IF v_owner_id IS NULL THEN
    v_err_code := 'TOKEN_INVALID';
    v_err_msg := 'Token inválido';
    -- Log + raise (owner_id NULL porque token não bateu)
    BEGIN
      INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info)
      VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info);
    EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
  END IF;

  IF v_active IS NOT TRUE THEN
    v_err_code := 'TOKEN_DISABLED';
    v_err_msg := 'Link desativado';
    BEGIN
      INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
      VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
    EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
  END IF;

  IF v_expires IS NOT NULL AND now() > v_expires THEN
    v_err_code := 'TOKEN_EXPIRED';
    v_err_msg := 'Link expirado';
    BEGIN
      INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
      VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
    EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
  END IF;

  IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN
    v_err_code := 'TOKEN_MAX_USES';
    v_err_msg := 'Limite de uso atingido';
    BEGIN
      INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id)
      VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id);
    EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg USING ERRCODE = '28000';
  END IF;

  -- Resolve tenant_id se invite não tiver (A#19)
  IF v_tenant_id IS NULL THEN
    SELECT tenant_id
      INTO v_tenant_id
    FROM public.tenant_members
    WHERE user_id = v_owner_id
      AND status = 'active'
    ORDER BY created_at ASC
    LIMIT 1;
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- Sanitização + validações de campos (A#27)
  -- ───────────────────────────────────────────────────────────────────────
  v_nome := nullif(trim(p_payload->>'nome_completo'), '');
  IF v_nome IS NULL THEN
    v_err_code := 'VALIDATION'; v_err_msg := 'Nome é obrigatório';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;
  IF length(v_nome) > 200 THEN
    v_err_code := 'VALIDATION'; v_err_msg := 'Nome muito longo';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;

  v_email := nullif(lower(trim(p_payload->>'email_principal')), '');
  IF v_email IS NULL THEN
    v_err_code := 'VALIDATION'; v_err_msg := 'E-mail é obrigatório';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;
  IF length(v_email) > 120 THEN
    v_err_code := 'VALIDATION'; v_err_msg := 'E-mail muito longo';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;
  IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
    v_err_code := 'VALIDATION'; v_err_msg := 'E-mail inválido';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;

  v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), '');
  IF v_email_alt IS NOT NULL THEN
    IF length(v_email_alt) > 120 THEN
      v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo muito longo';
      BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
      RAISE EXCEPTION '%', v_err_msg;
    END IF;
    IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN
      v_err_code := 'VALIDATION'; v_err_msg := 'E-mail alternativo inválido';
      BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
      RAISE EXCEPTION '%', v_err_msg;
    END IF;
  END IF;

  v_consent := coalesce((p_payload->>'consent')::boolean, false);
  IF v_consent IS NOT TRUE THEN
    v_err_code := 'CONSENT_REQUIRED'; v_err_msg := 'Consentimento é obrigatório';
    BEGIN INSERT INTO public.patient_invite_attempts (token, ok, error_code, error_msg, client_info, owner_id, tenant_id) VALUES (p_token, false, v_err_code, v_err_msg, v_clean_info, v_owner_id, v_tenant_id); EXCEPTION WHEN OTHERS THEN NULL; END;
    RAISE EXCEPTION '%', v_err_msg;
  END IF;

  v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), '');
  v_birth := CASE
    WHEN v_birth_raw IS NULL THEN NULL
    WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date
    WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY')
    ELSE NULL
  END;
  IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN
    v_birth := NULL;
  END IF;

  v_genero := nullif(trim(p_payload->>'genero'), '');
  IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN
    v_genero := NULL;
  END IF;

  v_estado_civil := nullif(trim(p_payload->>'estado_civil'), '');
  IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN
    v_estado_civil := NULL;
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- INSERT
  -- ───────────────────────────────────────────────────────────────────────
  INSERT INTO public.patient_intake_requests (
    owner_id, tenant_id, token, status, consent,
    nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
    avatar_url,
    data_nascimento, cpf, rg, genero, estado_civil,
    profissao, escolaridade, nacionalidade, naturalidade,
    cep, pais, cidade, estado, endereco, numero, complemento, bairro,
    observacoes, encaminhado_por, onde_nos_conheceu
  )
  VALUES (
    v_owner_id, v_tenant_id, p_token, 'new', v_consent,
    v_nome, v_email, v_email_alt,
    nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''),
    nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''),
    left(nullif(trim(p_payload->>'avatar_url'), ''), 500),
    v_birth,
    nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''),
    left(nullif(trim(p_payload->>'rg'), ''), 20),
    v_genero, v_estado_civil,
    left(nullif(trim(p_payload->>'profissao'), ''), 120),
    left(nullif(trim(p_payload->>'escolaridade'), ''), 120),
    left(nullif(trim(p_payload->>'nacionalidade'), ''), 80),
    left(nullif(trim(p_payload->>'naturalidade'), ''), 120),
    nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''),
    left(nullif(trim(p_payload->>'pais'), ''), 60),
    left(nullif(trim(p_payload->>'cidade'), ''), 120),
    left(nullif(trim(p_payload->>'estado'), ''), 2),
    left(nullif(trim(p_payload->>'endereco'), ''), 200),
    left(nullif(trim(p_payload->>'numero'), ''), 20),
    left(nullif(trim(p_payload->>'complemento'), ''), 120),
    left(nullif(trim(p_payload->>'bairro'), ''), 120),
    left(nullif(trim(p_payload->>'observacoes'), ''), 2000),
    left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120),
    left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80)
  )
  RETURNING id INTO v_intake_id;

  UPDATE public.patient_invites
    SET uses = uses + 1
  WHERE token = p_token;

  -- Log de sucesso (best-effort, não propaga erro)
  BEGIN
    INSERT INTO public.patient_invite_attempts (token, ok, client_info, owner_id, tenant_id)
    VALUES (p_token, true, v_clean_info, v_owner_id, v_tenant_id);
  EXCEPTION WHEN OTHERS THEN NULL; END;

  RETURN v_intake_id;
END;
$_$;

CREATE FUNCTION public.create_support_session(p_tenant_id uuid, p_ttl_minutes integer DEFAULT 60) RETURNS json
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_admin_id  uuid;
  v_role      text;
  v_token     text;
  v_expires   timestamp with time zone;
  v_session   support_sessions;
BEGIN
  -- Verifica autentica????o
  v_admin_id := auth.uid();
  IF v_admin_id IS NULL THEN
    RAISE EXCEPTION 'N??o autenticado.' USING ERRCODE = 'P0001';
  END IF;

  -- Verifica role saas_admin
  SELECT role INTO v_role
    FROM public.profiles
   WHERE id = v_admin_id;

  IF v_role <> 'saas_admin' THEN
    RAISE EXCEPTION 'Acesso negado. Somente saas_admin pode criar sess??es de suporte.'
      USING ERRCODE = 'P0002';
  END IF;

  -- Valida TTL (1 a 120 minutos)
  IF p_ttl_minutes < 1 OR p_ttl_minutes > 120 THEN
    RAISE EXCEPTION 'TTL inv??lido. Use entre 1 e 120 minutos.'
      USING ERRCODE = 'P0003';
  END IF;

  -- Valida tenant
  IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
    RAISE EXCEPTION 'Tenant n??o encontrado.'
      USING ERRCODE = 'P0004';
  END IF;

  -- Gera token ??nico (64 chars hex, sem pgcrypto)
  v_token   := replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '');
  v_expires := now() + (p_ttl_minutes || ' minutes')::interval;

  -- Insere sess??o
  INSERT INTO public.support_sessions (tenant_id, admin_id, token, expires_at)
  VALUES (p_tenant_id, v_admin_id, v_token, v_expires)
  RETURNING * INTO v_session;

  RETURN json_build_object(
    'token',      v_session.token,
    'expires_at', v_session.expires_at,
    'session_id', v_session.id
  );
END;
$$;

CREATE FUNCTION public.create_therapist_payout(p_tenant_id uuid, p_therapist_id uuid, p_period_start date, p_period_end date) RETURNS public.therapist_payouts
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_payout        public.therapist_payouts%ROWTYPE;
  v_total_sessions INTEGER;
  v_gross         NUMERIC(10,2);
  v_clinic_fee    NUMERIC(10,2);
  v_net           NUMERIC(10,2);
BEGIN
  -- ?????? Verifica????o de permiss??o ????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
  -- Apenas o pr??prio terapeuta ou o tenant_admin pode criar o repasse
  IF auth.uid() <> p_therapist_id AND NOT public.is_tenant_admin(p_tenant_id) THEN
    RAISE EXCEPTION 'Sem permiss??o para criar repasse para este terapeuta.';
  END IF;

  -- ?????? Verifica se j?? existe repasse para o mesmo per??odo ???????????????????????????????????????????????????
  IF EXISTS (
    SELECT 1 FROM public.therapist_payouts
    WHERE owner_id    = p_therapist_id
      AND tenant_id   = p_tenant_id
      AND period_start = p_period_start
      AND period_end   = p_period_end
      AND status <> 'cancelled'
  ) THEN
    RAISE EXCEPTION
      'J?? existe um repasse ativo para o per??odo % a % deste terapeuta.',
      p_period_start, p_period_end;
  END IF;

  -- ?????? Agrega os financial_records eleg??veis ??????????????????????????????????????????????????????????????????????????????????????????
  -- Eleg??veis: paid, receita, owner=terapeuta, tenant correto, paid_at no per??odo,
  --            n??o soft-deleted, ainda n??o vinculados a nenhum payout.
  SELECT
    COUNT(*)                AS total_sessions,
    COALESCE(SUM(amount),        0) AS gross_amount,
    COALESCE(SUM(clinic_fee_amount), 0) AS clinic_fee_total,
    COALESCE(SUM(net_amount),    0) AS net_amount
  INTO
    v_total_sessions, v_gross, v_clinic_fee, v_net
  FROM public.financial_records fr
  WHERE fr.owner_id    = p_therapist_id
    AND fr.tenant_id   = p_tenant_id
    AND fr.type        = 'receita'
    AND fr.status      = 'paid'
    AND fr.deleted_at  IS NULL
    AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
    AND NOT EXISTS (
      SELECT 1 FROM public.therapist_payout_records tpr
      WHERE tpr.financial_record_id = fr.id
    );

  -- Sem registros eleg??veis ??? n??o criar payout vazio
  IF v_total_sessions = 0 THEN
    RAISE EXCEPTION
      'Nenhum registro financeiro eleg??vel encontrado para o per??odo % a %.',
      p_period_start, p_period_end;
  END IF;

  -- ?????? Cria o repasse ???????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????????
  INSERT INTO public.therapist_payouts (
    owner_id,
    tenant_id,
    period_start,
    period_end,
    total_sessions,
    gross_amount,
    clinic_fee_total,
    net_amount,
    status
  ) VALUES (
    p_therapist_id,
    p_tenant_id,
    p_period_start,
    p_period_end,
    v_total_sessions,
    v_gross,
    v_clinic_fee,
    v_net,
    'pending'
  )
  RETURNING * INTO v_payout;

  -- ?????? Vincula os financial_records ao repasse ????????????????????????????????????????????????????????????????????????????????????
  INSERT INTO public.therapist_payout_records (payout_id, financial_record_id)
  SELECT v_payout.id, fr.id
  FROM public.financial_records fr
  WHERE fr.owner_id    = p_therapist_id
    AND fr.tenant_id   = p_tenant_id
    AND fr.type        = 'receita'
    AND fr.status      = 'paid'
    AND fr.deleted_at  IS NULL
    AND fr.paid_at::DATE BETWEEN p_period_start AND p_period_end
    AND NOT EXISTS (
      SELECT 1 FROM public.therapist_payout_records tpr
      WHERE tpr.financial_record_id = fr.id
    );

  RETURN v_payout;
END;
$$;

CREATE FUNCTION public.current_member_id(p_tenant_id uuid) RETURNS uuid
    LANGUAGE sql STABLE
    AS $$
  select tm.id
  from public.tenant_members tm
  where tm.tenant_id = p_tenant_id
    and tm.user_id = auth.uid()
  limit 1
$$;

CREATE FUNCTION public.current_member_role(p_tenant_id uuid) RETURNS text
    LANGUAGE sql STABLE
    AS $$
  select tm.role
  from public.tenant_members tm
  where tm.tenant_id = p_tenant_id
    and tm.user_id = auth.uid()
  limit 1
$$;

CREATE FUNCTION public.debit_addon_credit(p_tenant_id uuid, p_addon_type text, p_queue_id uuid DEFAULT NULL::uuid, p_description text DEFAULT 'Consumo'::text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_credit         addon_credits%ROWTYPE;
  v_balance_before INTEGER;
  v_balance_after  INTEGER;
BEGIN
  -- Lock e leitura
  SELECT * INTO v_credit
  FROM addon_credits
  WHERE tenant_id = p_tenant_id AND addon_type = p_addon_type AND is_active = true
  FOR UPDATE;

  IF NOT FOUND THEN
    RETURN jsonb_build_object('success', false, 'reason', 'no_credits', 'balance', 0);
  END IF;

  -- Verifica saldo
  IF v_credit.balance <= 0 THEN
    RETURN jsonb_build_object('success', false, 'reason', 'insufficient_balance', 'balance', 0);
  END IF;

  -- Verifica rate limit di??rio
  IF v_credit.daily_limit IS NOT NULL THEN
    -- Reset se passou do dia
    IF v_credit.daily_reset_at IS NULL OR v_credit.daily_reset_at < date_trunc('day', now()) THEN
      UPDATE addon_credits SET daily_used = 0, daily_reset_at = date_trunc('day', now()) + interval '1 day' WHERE id = v_credit.id;
      v_credit.daily_used := 0;
    END IF;

    IF v_credit.daily_used >= v_credit.daily_limit THEN
      RETURN jsonb_build_object('success', false, 'reason', 'daily_limit_reached', 'balance', v_credit.balance);
    END IF;
  END IF;

  -- Verifica rate limit hor??rio
  IF v_credit.hourly_limit IS NOT NULL THEN
    IF v_credit.hourly_reset_at IS NULL OR v_credit.hourly_reset_at < date_trunc('hour', now()) THEN
      UPDATE addon_credits SET hourly_used = 0, hourly_reset_at = date_trunc('hour', now()) + interval '1 hour' WHERE id = v_credit.id;
      v_credit.hourly_used := 0;
    END IF;

    IF v_credit.hourly_used >= v_credit.hourly_limit THEN
      RETURN jsonb_build_object('success', false, 'reason', 'hourly_limit_reached', 'balance', v_credit.balance);
    END IF;
  END IF;

  -- Verifica expira????o
  IF v_credit.expires_at IS NOT NULL AND v_credit.expires_at < now() THEN
    RETURN jsonb_build_object('success', false, 'reason', 'credits_expired', 'balance', v_credit.balance);
  END IF;

  v_balance_before := v_credit.balance;
  v_balance_after  := v_credit.balance - 1;

  -- Debita
  UPDATE addon_credits
  SET balance        = v_balance_after,
      total_consumed = total_consumed + 1,
      daily_used     = COALESCE(daily_used, 0) + 1,
      hourly_used    = COALESCE(hourly_used, 0) + 1,
      updated_at     = now()
  WHERE id = v_credit.id;

  -- Registra transa????o
  INSERT INTO addon_transactions (
    tenant_id, addon_type, type, amount,
    balance_before, balance_after,
    queue_id, description
  ) VALUES (
    p_tenant_id, p_addon_type, 'consume', -1,
    v_balance_before, v_balance_after,
    p_queue_id, p_description
  );

  RETURN jsonb_build_object(
    'success', true,
    'balance_before', v_balance_before,
    'balance_after', v_balance_after
  );
END;
$$;

CREATE FUNCTION public.deduct_whatsapp_credits(p_tenant_id uuid, p_amount integer, p_conversation_message_id bigint DEFAULT NULL::bigint, p_note text DEFAULT NULL::text) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_new_balance INT;
    v_row RECORD;
BEGIN
    IF p_amount <= 0 THEN
        RAISE EXCEPTION 'amount must be positive';
    END IF;

    -- Lock a linha e valida saldo
    SELECT balance, low_balance_threshold
        INTO v_row
        FROM public.whatsapp_credits_balance
        WHERE tenant_id = p_tenant_id
        FOR UPDATE;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'insufficient_credits';
    END IF;
    IF v_row.balance < p_amount THEN
        RAISE EXCEPTION 'insufficient_credits';
    END IF;

    UPDATE public.whatsapp_credits_balance
        SET balance = balance - p_amount,
            lifetime_used = lifetime_used + p_amount
        WHERE tenant_id = p_tenant_id
        RETURNING balance INTO v_new_balance;

    INSERT INTO public.whatsapp_credits_transactions
        (tenant_id, kind, amount, balance_after, conversation_message_id, note)
    VALUES
        (p_tenant_id, 'usage', -p_amount, v_new_balance, p_conversation_message_id, p_note);

    RETURN v_new_balance;
END;
$$;

CREATE FUNCTION public.delete_commitment_full(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
declare
  v_is_native boolean;
  v_fields int := 0;
  v_logs int := 0;
  v_parent int := 0;
begin
  if auth.uid() is null then
    raise exception 'Not authenticated';
  end if;

  if not exists (
    select 1
    from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
      and tm.status = 'active'
  ) then
    raise exception 'Not allowed';
  end if;

  select dc.is_native
    into v_is_native
  from public.determined_commitments dc
  where dc.tenant_id = p_tenant_id
    and dc.id = p_commitment_id;

  if v_is_native is null then
    raise exception 'Commitment not found';
  end if;

  if v_is_native = true then
    raise exception 'Cannot delete native commitment';
  end if;

  delete from public.determined_commitment_fields
   where tenant_id = p_tenant_id
     and commitment_id = p_commitment_id;
  get diagnostics v_fields = row_count;

  delete from public.commitment_time_logs
   where tenant_id = p_tenant_id
     and commitment_id = p_commitment_id;
  get diagnostics v_logs = row_count;

  delete from public.determined_commitments
   where tenant_id = p_tenant_id
     and id = p_commitment_id;
  get diagnostics v_parent = row_count;

  if v_parent <> 1 then
    raise exception 'Parent not deleted (RLS/owner issue).';
  end if;

  return jsonb_build_object(
    'ok', true,
    'deleted', jsonb_build_object(
      'fields', v_fields,
      'logs', v_logs,
      'commitment', v_parent
    )
  );
end;
$$;

CREATE FUNCTION public.delete_determined_commitment(p_tenant_id uuid, p_commitment_id uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
declare
  v_is_native boolean;
  v_fields_deleted int := 0;
  v_logs_deleted int := 0;
  v_commitment_deleted int := 0;
begin
  if auth.uid() is null then
    raise exception 'Not authenticated';
  end if;

  if not exists (
    select 1
    from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
      and tm.status = 'active'
  ) then
    raise exception 'Not allowed';
  end if;

  select dc.is_native
    into v_is_native
  from public.determined_commitments dc
  where dc.tenant_id = p_tenant_id
    and dc.id = p_commitment_id;

  if v_is_native is null then
    raise exception 'Commitment not found for tenant';
  end if;

  if v_is_native = true then
    raise exception 'Cannot delete native commitment';
  end if;

  delete from public.determined_commitment_fields f
   where f.tenant_id = p_tenant_id
     and f.commitment_id = p_commitment_id;
  get diagnostics v_fields_deleted = row_count;

  delete from public.commitment_time_logs l
   where l.tenant_id = p_tenant_id
     and l.commitment_id = p_commitment_id;
  get diagnostics v_logs_deleted = row_count;

  delete from public.determined_commitments dc
   where dc.tenant_id = p_tenant_id
     and dc.id = p_commitment_id;
  get diagnostics v_commitment_deleted = row_count;

  if v_commitment_deleted <> 1 then
    raise exception 'Delete did not remove the commitment (tenant mismatch?)';
  end if;

  return jsonb_build_object(
    'ok', true,
    'tenant_id', p_tenant_id,
    'commitment_id', p_commitment_id,
    'deleted', jsonb_build_object(
      'fields', v_fields_deleted,
      'logs', v_logs_deleted,
      'commitment', v_commitment_deleted
    )
  );
end;
$$;

CREATE FUNCTION public.delete_plan_safe(p_plan_id uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_active_count int;
  v_plan_key     text;
BEGIN
  IF auth.uid() IS NULL THEN
    RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
  END IF;

  IF NOT public.is_saas_admin() THEN
    RAISE EXCEPTION 'Apenas saas_admin pode deletar planos' USING ERRCODE = '42501';
  END IF;

  IF p_plan_id IS NULL THEN
    RAISE EXCEPTION 'plan_id obrigatório' USING ERRCODE = '22023';
  END IF;

  SELECT key INTO v_plan_key FROM public.plans WHERE id = p_plan_id;
  IF v_plan_key IS NULL THEN
    RAISE EXCEPTION 'plano não encontrado' USING ERRCODE = '22023';
  END IF;

  SELECT COUNT(*) INTO v_active_count
  FROM public.subscriptions
  WHERE plan_id = p_plan_id
    AND status = 'active';

  IF v_active_count > 0 THEN
    RAISE EXCEPTION 'Plano % tem % assinatura(s) ativa(s); migre os tenants antes de deletar.',
      v_plan_key, v_active_count
      USING ERRCODE = 'P0001';
  END IF;

  -- desativa preços ativos antes de deletar
  UPDATE public.plan_prices
     SET is_active = false,
         active_to = now()
   WHERE plan_id = p_plan_id
     AND is_active = true;

  DELETE FROM public.plans WHERE id = p_plan_id;

  RETURN jsonb_build_object(
    'deleted',  true,
    'plan_key', v_plan_key
  );
END;
$$;

CREATE FUNCTION public.dev_list_auth_users(p_limit integer DEFAULT 50) RETURNS TABLE(id uuid, email text, created_at timestamp with time zone)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'auth'
    AS $$
begin
  -- s?? saas_admin pode ver
  if not exists (
    select 1
    from public.profiles p
    where p.id = auth.uid()
      and p.role = 'saas_admin'
  ) then
    return;
  end if;

  return query
  select
    u.id,
    u.email,
    u.created_at
  from auth.users u
  order by u.created_at desc
  limit greatest(1, least(coalesce(p_limit, 50), 500));
end;
$$;

CREATE FUNCTION public.dev_list_custom_users() RETURNS TABLE(user_id uuid, email text, created_at timestamp with time zone, global_role text, tenant_role text, tenant_id uuid, password_dev text, kind text)
    LANGUAGE sql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
  with base as (
    select
      u.id as user_id,
      lower(u.email) as email,
      u.created_at
    from auth.users u
    where lower(u.email) not in (
      'clinic@agenciapsi.com.br',
      'therapist@agenciapsi.com.br',
      'patient@agenciapsi.com.br',
      'saas@agenciapsi.com.br'
    )
  ),
  prof as (
    select p.id, p.role as global_role
    from public.profiles p
  ),
  last_membership as (
    select distinct on (tm.user_id)
      tm.user_id,
      tm.tenant_id,
      tm.role as tenant_role,
      tm.created_at
    from public.tenant_members tm
    where tm.status = 'active'
    order by tm.user_id, tm.created_at desc
  )
  select
    b.user_id,
    b.email,
    b.created_at,
    pr.global_role,
    lm.tenant_role,
    lm.tenant_id,
    dc.password_dev,
    dc.kind
  from base b
  left join prof pr on pr.id = b.user_id
  left join last_membership lm on lm.user_id = b.user_id
  left join public.dev_user_credentials dc on lower(dc.email) = b.email
  order by b.created_at desc;
$$;

CREATE FUNCTION public.dev_list_intent_leads() RETURNS TABLE(email text, last_intent_at timestamp with time zone, plan_key text, billing_interval text, status text, tenant_id uuid)
    LANGUAGE sql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
  select
    lower(si.email) as email,
    max(si.created_at) as last_intent_at,
    (array_agg(si.plan_key order by si.created_at desc))[1] as plan_key,
    (array_agg(si.interval order by si.created_at desc))[1] as billing_interval,
    (array_agg(si.status order by si.created_at desc))[1] as status,
    (array_agg(si.tenant_id order by si.created_at desc))[1] as tenant_id
  from public.subscription_intents si
  where si.email is not null
    and not exists (
      select 1
      from auth.users au
      where lower(au.email) = lower(si.email)
    )
  group by lower(si.email)
  order by max(si.created_at) desc;
$$;

CREATE FUNCTION public.dev_public_debug_snapshot() RETURNS TABLE(users_total integer, tenants_total integer, intents_new_total integer, latest_intents jsonb)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $_$
declare
  v_latest jsonb;
begin
  select jsonb_agg(
    jsonb_build_object(
      'created_at', si.created_at,
      'email_masked',
        regexp_replace(lower(si.email), '(^.).*(@.*$)', '\1***\2'),
      'plan_key', si.plan_key,
      'status', si.status
    )
    order by si.created_at desc
  )
  into v_latest
  from (
    select si.*
    from public.subscription_intents si
    where si.email is not null
    order by si.created_at desc
    limit 5
  ) si;

  return query
  select
    (select count(*)::int from auth.users) as users_total,
    (select count(*)::int from public.tenants) as tenants_total,
    (select count(*)::int from public.subscription_intents where status = 'new') as intents_new_total,
    coalesce(v_latest, '[]'::jsonb) as latest_intents;
end;
$_$;

CREATE FUNCTION public.dev_set_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  NEW.updated_at := now();
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.ensure_personal_tenant() RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_uid      uuid;
  v_existing uuid;
BEGIN
  v_uid := auth.uid();
  IF v_uid IS NULL THEN
    RAISE EXCEPTION 'Not authenticated';
  END IF;

  SELECT tm.tenant_id INTO v_existing
  FROM public.tenant_members tm
  JOIN public.tenants t ON t.id = tm.tenant_id
  WHERE tm.user_id = v_uid
    AND tm.status = 'active'
    AND t.kind IN ('therapist', 'saas')
  ORDER BY tm.created_at DESC
  LIMIT 1;

  IF v_existing IS NOT NULL THEN
    RETURN v_existing;
  END IF;

  RETURN public.provision_account_tenant(v_uid, 'therapist');
END;
$$;

CREATE FUNCTION public.ensure_personal_tenant_for_user(p_user_id uuid) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_uid uuid;
  v_existing uuid;
  v_tenant uuid;
  v_email text;
  v_name text;
begin
  v_uid := p_user_id;
  if v_uid is null then
    raise exception 'Missing user id';
  end if;

  -- s?? considera tenant pessoal (kind='saas')
  select tm.tenant_id
    into v_existing
  from public.tenant_members tm
  join public.tenants t on t.id = tm.tenant_id
  where tm.user_id = v_uid
    and tm.status = 'active'
    and t.kind = 'saas'
  order by tm.created_at desc
  limit 1;

  if v_existing is not null then
    return v_existing;
  end if;

  select email into v_email
  from auth.users
  where id = v_uid;

  v_name := coalesce(split_part(v_email, '@', 1), 'Conta');

  insert into public.tenants (name, kind, created_at)
  values (v_name || ' (Pessoal)', 'saas', now())
  returning id into v_tenant;

  insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
  values (v_tenant, v_uid, 'tenant_admin', 'active', now());

  return v_tenant;
end;
$$;

CREATE FUNCTION public.export_patient_data(p_patient_id uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'pg_temp'
    AS $$
DECLARE
    v_patient RECORD;
    v_tenant_id UUID;
    v_caller UUID;
    v_is_member BOOLEAN;
    v_result JSONB;
BEGIN
    v_caller := auth.uid();
    IF v_caller IS NULL THEN
        RAISE EXCEPTION 'Autenticacao obrigatoria' USING ERRCODE = '28000';
    END IF;

    -- carrega paciente
    SELECT * INTO v_patient FROM public.patients WHERE id = p_patient_id;
    IF NOT FOUND THEN
        RAISE EXCEPTION 'Paciente nao encontrado' USING ERRCODE = 'P0002';
    END IF;

    v_tenant_id := v_patient.tenant_id;

    -- verifica se caller e membro ativo do tenant do paciente
    SELECT EXISTS (
        SELECT 1 FROM public.tenant_members tm
        WHERE tm.tenant_id = v_tenant_id
          AND tm.user_id = v_caller
          AND tm.status = 'active'
    ) OR public.is_saas_admin() INTO v_is_member;

    IF NOT v_is_member THEN
        RAISE EXCEPTION 'Sem permissao para exportar dados deste paciente' USING ERRCODE = '42501';
    END IF;

    -- monta o payload
    v_result := jsonb_build_object(
        'export_metadata', jsonb_build_object(
            'generated_at', now(),
            'generated_by', v_caller,
            'tenant_id', v_tenant_id,
            'patient_id', p_patient_id,
            'lgpd_basis', 'Art. 18, II - portabilidade dos dados do titular',
            'controller', 'AgenciaPSI - Clinica responsavel',
            'format_version', '1.0'
        ),
        'paciente', to_jsonb(v_patient),
        'contatos', COALESCE((
            SELECT jsonb_agg(to_jsonb(pc) ORDER BY pc.created_at)
            FROM public.patient_contacts pc
            WHERE pc.patient_id = p_patient_id
        ), '[]'::jsonb),
        'contatos_apoio', COALESCE((
            SELECT jsonb_agg(to_jsonb(psc) ORDER BY psc.created_at)
            FROM public.patient_support_contacts psc
            WHERE psc.patient_id = p_patient_id
        ), '[]'::jsonb),
        'historico_status', COALESCE((
            SELECT jsonb_agg(to_jsonb(psh) ORDER BY psh.alterado_em)
            FROM public.patient_status_history psh
            WHERE psh.patient_id = p_patient_id
        ), '[]'::jsonb),
        'timeline', COALESCE((
            SELECT jsonb_agg(to_jsonb(pt) ORDER BY pt.ocorrido_em)
            FROM public.patient_timeline pt
            WHERE pt.patient_id = p_patient_id
        ), '[]'::jsonb),
        'descontos', COALESCE((
            SELECT jsonb_agg(to_jsonb(pd) ORDER BY pd.created_at)
            FROM public.patient_discounts pd
            WHERE pd.patient_id = p_patient_id
        ), '[]'::jsonb),
        'eventos_agenda', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', ae.id,
                    'tipo', ae.tipo,
                    'inicio_em', ae.inicio_em,
                    'fim_em', ae.fim_em,
                    'status', ae.status,
                    'observacoes', ae.observacoes,
                    'created_at', ae.created_at
                ) ORDER BY ae.inicio_em
            )
            FROM public.agenda_eventos ae
            WHERE ae.patient_id = p_patient_id
        ), '[]'::jsonb),
        'registros_financeiros', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', fr.id,
                    'amount', fr.amount,
                    'discount_amount', fr.discount_amount,
                    'final_amount', fr.final_amount,
                    'status', fr.status,
                    'due_date', fr.due_date,
                    'paid_at', fr.paid_at,
                    'payment_method', fr.payment_method,
                    'notes', fr.notes,
                    'created_at', fr.created_at
                ) ORDER BY fr.created_at
            )
            FROM public.financial_records fr
            WHERE fr.patient_id = p_patient_id
        ), '[]'::jsonb),
        'documentos', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', d.id,
                    'nome_original', d.nome_original,
                    'tipo_documento', d.tipo_documento,
                    'categoria', d.categoria,
                    'descricao', d.descricao,
                    'mime_type', d.mime_type,
                    'tamanho_bytes', d.tamanho_bytes,
                    'status_revisao', d.status_revisao,
                    'visibilidade', d.visibilidade,
                    'uploaded_at', d.uploaded_at,
                    'created_at', d.created_at
                ) ORDER BY d.created_at
            )
            FROM public.documents d
            WHERE d.patient_id = p_patient_id AND d.deleted_at IS NULL
        ), '[]'::jsonb),
        'notificacoes_enviadas', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', nl.id,
                    'channel', nl.channel,
                    'template_key', nl.template_key,
                    'recipient_address', nl.recipient_address,
                    'status', nl.status,
                    'sent_at', nl.sent_at,
                    'delivered_at', nl.delivered_at,
                    'read_at', nl.read_at,
                    'failure_reason', nl.failure_reason,
                    'created_at', nl.created_at
                ) ORDER BY nl.created_at
            )
            FROM public.notification_logs nl
            WHERE nl.patient_id = p_patient_id
        ), '[]'::jsonb),
        'audit_trail', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', al.id,
                    'action', al.action,
                    'entity_type', al.entity_type,
                    'changed_fields', al.changed_fields,
                    'user_id', al.user_id,
                    'created_at', al.created_at
                ) ORDER BY al.created_at
            )
            FROM public.audit_logs al
            WHERE al.tenant_id = v_tenant_id
              AND al.entity_type = 'patients'
              AND al.entity_id = p_patient_id::text
        ), '[]'::jsonb),
        'acessos_a_documentos', COALESCE((
            SELECT jsonb_agg(
                jsonb_build_object(
                    'id', dal.id,
                    'documento_id', dal.documento_id,
                    'acao', dal.acao,
                    'user_id', dal.user_id,
                    'acessado_em', dal.acessado_em
                ) ORDER BY dal.acessado_em
            )
            FROM public.document_access_logs dal
            WHERE dal.documento_id IN (
                SELECT id FROM public.documents WHERE patient_id = p_patient_id
            )
        ), '[]'::jsonb),
        'grupos', COALESCE((
            SELECT jsonb_agg(jsonb_build_object('patient_group_id', pgp.patient_group_id))
            FROM public.patient_group_patient pgp
            WHERE pgp.patient_id = p_patient_id
        ), '[]'::jsonb),
        'tags', COALESCE((
            SELECT jsonb_agg(jsonb_build_object('tag_id', ppt.tag_id))
            FROM public.patient_patient_tag ppt
            WHERE ppt.patient_id = p_patient_id
        ), '[]'::jsonb)
    );

    -- registra o export como evento auditavel
    INSERT INTO public.audit_logs (
        tenant_id, user_id, entity_type, entity_id, action,
        old_values, new_values, changed_fields, metadata
    ) VALUES (
        v_tenant_id, v_caller, 'patients', p_patient_id::text, 'update',
        NULL, NULL, ARRAY['__lgpd_export__'],
        jsonb_build_object(
            'action_kind', 'lgpd_export',
            'lgpd_basis', 'Art. 18, II',
            'patient_name', v_patient.nome_completo
        )
    );

    RETURN v_result;
END;
$$;

CREATE FUNCTION public.fanout_inbound_message_to_notifications() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'pg_temp'
    AS $$
DECLARE
    v_target_user UUID;
    v_title TEXT;
    v_detail TEXT;
    v_initials TEXT;
    v_deeplink TEXT;
    v_patient_name TEXT;
    v_payload JSONB;
BEGIN
    -- so processa inbound
    IF NEW.direction <> 'inbound' THEN
        RETURN NEW;
    END IF;

    -- busca nome do paciente (se vinculado)
    IF NEW.patient_id IS NOT NULL THEN
        SELECT nome_completo INTO v_patient_name FROM public.patients WHERE id = NEW.patient_id;
    END IF;

    -- titulo e detalhes
    v_title := COALESCE(v_patient_name, NEW.from_number, 'Desconhecido');
    v_detail := COALESCE(left(NEW.body, 100), '[mensagem sem texto]');

    -- iniciais
    IF v_patient_name IS NOT NULL THEN
        v_initials := upper(left(v_patient_name, 1)) ||
                      COALESCE(upper(left(split_part(v_patient_name, ' ', 2), 1)), '');
    ELSE
        v_initials := '?';
    END IF;

    -- deeplink para a pagina de conversas (clinic padrao; therapist tambem funciona via mesma rota na area dele)
    v_deeplink := '/admin/conversas';

    v_payload := jsonb_build_object(
        'title', v_title,
        'detail', v_detail,
        'avatar_initials', v_initials,
        'deeplink', v_deeplink,
        'channel', NEW.channel,
        'conversation_message_id', NEW.id,
        'patient_id', NEW.patient_id,
        'from_number', NEW.from_number
    );

    -- ─── decide destinatarios ─────────────────────────────────────────────

    -- Caso 1: paciente vinculado e tem responsible_member_id
    IF NEW.patient_id IS NOT NULL THEN
        SELECT tm.user_id INTO v_target_user
        FROM public.patients p
        JOIN public.tenant_members tm ON tm.id = p.responsible_member_id
        WHERE p.id = NEW.patient_id
          AND tm.status = 'active'
        LIMIT 1;

        IF v_target_user IS NOT NULL THEN
            INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
            VALUES (v_target_user, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload);
            RETURN NEW;
        END IF;
    END IF;

    -- Caso 2: fallback — fan-out pra todos tenant_admin/clinic_admin/therapist ativos
    INSERT INTO public.notifications (owner_id, tenant_id, type, ref_id, ref_table, payload)
    SELECT tm.user_id, NEW.tenant_id, 'inbound_message', NULL, 'conversation_messages', v_payload
    FROM public.tenant_members tm
    WHERE tm.tenant_id = NEW.tenant_id
      AND tm.status = 'active'
      AND tm.role IN ('clinic_admin', 'tenant_admin', 'therapist');

    RETURN NEW;
END;
$$;

CREATE FUNCTION public.faq_votar(faq_id uuid) RETURNS void
    LANGUAGE sql SECURITY DEFINER
    AS $$
  update public.saas_faq
  set votos = votos + 1,
      updated_at = now()
  where id = faq_id
    and ativo = true;
$$;

CREATE FUNCTION public.financial_records_inject_tenant() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF NEW.tenant_id IS NULL AND NEW.owner_id IS NOT NULL THEN
    SELECT tm.tenant_id INTO NEW.tenant_id
    FROM public.tenant_members tm
    WHERE tm.user_id = NEW.owner_id AND tm.status = 'active'
    ORDER BY tm.created_at DESC
    LIMIT 1;
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.first_response_by_therapist(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(therapist_id uuid, runs_count integer, avg_seconds integer, median_seconds integer)
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
    SELECT
        r.responder_id AS therapist_id,
        COUNT(*)::INT AS runs_count,
        AVG(r.response_seconds)::INT AS avg_seconds,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY r.response_seconds)::INT AS median_seconds
    FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
    WHERE r.responder_id IS NOT NULL
    GROUP BY r.responder_id
    ORDER BY avg_seconds ASC;
$$;

CREATE FUNCTION public.first_response_evolution(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7, p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(bucket_start timestamp with time zone, runs_count integer, avg_seconds integer)
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
    WITH runs AS (
        SELECT r.inbound_started_at, r.response_seconds
        FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
        WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
    ),
    bucketed AS (
        SELECT
            -- Janela alinhada a p_from: bucket_index * N dias + p_from
            p_from + (
                FLOOR(EXTRACT(EPOCH FROM (inbound_started_at - p_from)) / (p_bucket_days * 86400))::INT
                * p_bucket_days * interval '1 day'
            ) AS bucket_start,
            response_seconds
        FROM runs
    )
    SELECT
        bucket_start,
        COUNT(*)::INT AS runs_count,
        AVG(response_seconds)::INT AS avg_seconds
    FROM bucketed
    GROUP BY bucket_start
    ORDER BY bucket_start;
$$;

CREATE FUNCTION public.first_response_stats(p_tenant_id uuid, p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_therapist_id uuid DEFAULT NULL::uuid) RETURNS TABLE(runs_count integer, avg_seconds integer, median_seconds integer, min_seconds integer, max_seconds integer, sla_threshold_seconds integer, sla_compliant_count integer, sla_compliance_rate numeric)
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_threshold_seconds INT;
BEGIN
    -- Pega threshold do SLA (se habilitado)
    SELECT CASE WHEN enabled THEN threshold_minutes * 60 ELSE NULL END
        INTO v_threshold_seconds
        FROM public.conversation_sla_rules
        WHERE tenant_id = p_tenant_id;

    RETURN QUERY
    WITH runs AS (
        SELECT r.response_seconds, r.responder_id
        FROM public._first_response_runs(p_tenant_id, p_from, p_to) r
        WHERE (p_therapist_id IS NULL OR r.responder_id = p_therapist_id)
    )
    SELECT
        COUNT(*)::INT AS runs_count,
        COALESCE(AVG(response_seconds)::INT, 0) AS avg_seconds,
        COALESCE(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_seconds)::INT, 0) AS median_seconds,
        COALESCE(MIN(response_seconds), 0) AS min_seconds,
        COALESCE(MAX(response_seconds), 0) AS max_seconds,
        v_threshold_seconds AS sla_threshold_seconds,
        COUNT(*) FILTER (WHERE v_threshold_seconds IS NOT NULL AND response_seconds <= v_threshold_seconds)::INT AS sla_compliant_count,
        CASE
            WHEN v_threshold_seconds IS NULL OR COUNT(*) = 0 THEN NULL
            ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE response_seconds <= v_threshold_seconds) / COUNT(*), 1)
        END AS sla_compliance_rate
    FROM runs;
END;
$$;

CREATE FUNCTION public.fix_all_subscription_mismatches() RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  r record;
begin
  for r in
    select distinct s.user_id as owner_id
    from public.subscriptions s
    where s.status = 'active'
      and s.user_id is not null
  loop
    perform public.rebuild_owner_entitlements(r.owner_id);
  end loop;
end;
$$;

CREATE FUNCTION public.fn_agenda_regras_semanais_no_overlap() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
declare
  v_count int;
begin
  if new.ativo is false then
    return new;
  end if;

  select count(*) into v_count
  from public.agenda_regras_semanais r
  where r.owner_id = new.owner_id
    and r.dia_semana = new.dia_semana
    and r.ativo is true
    and (tg_op = 'INSERT' or r.id <> new.id)
    and (new.hora_inicio < r.hora_fim and new.hora_fim > r.hora_inicio);

  if v_count > 0 then
    raise exception 'Janela sobreposta: j?? existe uma regra ativa nesse intervalo.';
  end if;

  return new;
end;
$$;

CREATE FUNCTION public.fn_document_signature_timeline() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
    v_patient_id uuid;
    v_tenant_id  uuid;
    v_doc_nome   text;
BEGIN
    IF NEW.status = 'assinado' AND (OLD.status IS NULL OR OLD.status <> 'assinado') THEN
        SELECT d.patient_id, d.tenant_id, d.nome_original
        INTO v_patient_id, v_tenant_id, v_doc_nome
        FROM public.documents d
        WHERE d.id = NEW.documento_id;

        IF v_patient_id IS NOT NULL THEN
            INSERT INTO public.patient_timeline (
                patient_id, tenant_id, evento_tipo,
                titulo, descricao, icone_cor,
                link_ref_tipo, link_ref_id,
                gerado_por, ocorrido_em
            ) VALUES (
                v_patient_id,
                v_tenant_id,
                'documento_assinado',
                'Documento assinado: ' || COALESCE(v_doc_nome, 'documento'),
                'Assinado por ' || COALESCE(NEW.signatario_nome, NEW.signatario_tipo),
                'green',
                'documento',
                NEW.documento_id,
                NEW.signatario_id,
                NEW.assinado_em
            );
        END IF;
    END IF;
    RETURN NEW;
END;
$$;

CREATE FUNCTION public.fn_documents_timeline_insert() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
    INSERT INTO public.patient_timeline (
        patient_id, tenant_id, evento_tipo,
        titulo, descricao, icone_cor,
        link_ref_tipo, link_ref_id,
        gerado_por, ocorrido_em
    ) VALUES (
        NEW.patient_id,
        NEW.tenant_id,
        'documento_adicionado',
        'Documento adicionado: ' || COALESCE(NEW.nome_original, 'arquivo'),
        'Tipo: ' || COALESCE(NEW.tipo_documento, 'outro'),
        'blue',
        'documento',
        NEW.id,
        NEW.uploaded_by,
        NEW.uploaded_at
    );
    RETURN NEW;
END;
$$;

CREATE FUNCTION public.fn_notify_agenda_status_change() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_url TEXT;
    v_key TEXT;
BEGIN
    -- So dispara se status realmente mudou
    IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
        RETURN NEW;
    END IF;

    -- So dispara pra status "interessantes". Outros sao silenciosamente ignorados
    -- (a edge tambem tem essa logica, mas economizamos chamada HTTP aqui)
    IF NEW.status NOT IN ('cancelado', 'remarcado', 'confirmado') THEN
        RETURN NEW;
    END IF;

    -- Precisa de paciente vinculado (senao nao tem telefone)
    IF NEW.patient_id IS NULL THEN
        RETURN NEW;
    END IF;

    -- Busca settings
    BEGIN
        v_url := current_setting('app.settings.supabase_url', true);
        v_key := current_setting('app.settings.service_role_key', true);
    EXCEPTION WHEN OTHERS THEN
        -- Settings nao configuradas — silencioso
        RETURN NEW;
    END;

    IF v_url IS NULL OR v_key IS NULL THEN
        RETURN NEW;
    END IF;

    -- Fire and forget (pg_net)
    PERFORM net.http_post(
        url := v_url || '/functions/v1/send-session-status-notification',
        headers := jsonb_build_object(
            'Authorization', 'Bearer ' || v_key,
            'Content-Type', 'application/json'
        ),
        body := jsonb_build_object(
            'event_id', NEW.id,
            'old_status', OLD.status,
            'new_status', NEW.status
        )
    );

    RETURN NEW;
END;
$$;

CREATE FUNCTION public.fn_sla_resolve_on_outbound() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_thread_key TEXT;
BEGIN
    -- So processa outbound
    IF NEW.direction <> 'outbound' THEN RETURN NEW; END IF;

    -- Calcula thread_key no mesmo padrao da view conversation_threads
    v_thread_key := COALESCE(
        NEW.patient_id::text,
        'anon:' || COALESCE(NEW.to_number, 'unknown')
    );

    UPDATE public.conversation_sla_breaches
        SET resolved_at = now(),
            resolved_by_message_id = NEW.id
        WHERE tenant_id = NEW.tenant_id
          AND thread_key = v_thread_key
          AND resolved_at IS NULL;

    RETURN NEW;
END;
$$;

CREATE FUNCTION public.fn_whatsapp_low_balance_notify() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_detail TEXT;
BEGIN
    -- So alerta na transicao (alerted_at NULL) E se esta abaixo do threshold
    IF NEW.balance < NEW.low_balance_threshold
       AND NEW.low_balance_alerted_at IS NULL THEN

        v_detail := format(
            'Saldo atual: %s credito(s). Alerta configurado em %s. '
            'Compre mais na loja para nao interromper envios via WhatsApp Oficial.',
            NEW.balance,
            NEW.low_balance_threshold
        );

        -- Stakeholders: owner do canal WhatsApp ativo + admins ativos do tenant
        INSERT INTO public.notifications
            (owner_id, tenant_id, type, ref_id, ref_table, payload)
        SELECT
            u.user_id,
            NEW.tenant_id,
            'system_alert',
            NEW.tenant_id,
            'whatsapp_credits_balance',
            jsonb_build_object(
                'title', 'Saldo de WhatsApp baixo',
                'detail', v_detail,
                'severity', 'warn',
                'deeplink', '/configuracoes/creditos-whatsapp'
            )
        FROM (
            SELECT owner_id AS user_id
                FROM public.notification_channels
                WHERE tenant_id = NEW.tenant_id
                  AND channel = 'whatsapp'
                  AND is_active = true
                  AND deleted_at IS NULL
            UNION
            SELECT user_id
                FROM public.tenant_members
                WHERE tenant_id = NEW.tenant_id
                  AND role IN ('clinic_admin', 'tenant_admin')
                  AND status = 'active'
        ) u
        WHERE u.user_id IS NOT NULL;

        -- Anti-spam: so alerta de novo depois que add_whatsapp_credits
        -- reseta alerted_at pra NULL (acontece em purchase/topup)
        NEW.low_balance_alerted_at := now();
    END IF;

    RETURN NEW;
END;
$$;

CREATE FUNCTION public.generate_math_challenge() RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
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;
$$;

CREATE FUNCTION public.get_entity_primary_phone(p_entity_type text, p_entity_id uuid) RETURNS text
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
    SELECT number FROM public.contact_phones
    WHERE entity_type = p_entity_type
      AND entity_id = p_entity_id
    ORDER BY is_primary DESC, position ASC, created_at ASC
    LIMIT 1;
$$;

CREATE FUNCTION public.get_financial_report(p_owner_id uuid, p_start_date date, p_end_date date, p_group_by text DEFAULT 'month'::text) RETURNS TABLE(group_key text, group_label text, total_receitas numeric, total_despesas numeric, saldo numeric, total_pendente numeric, total_overdue numeric, count_records bigint)
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$

  -- ?????? Valida p_group_by antes de executar ??????????????????????????????????????????????????????????????????????????????????????????????????????
  -- (lan??a erro se valor inv??lido; plpgsql seria necess??rio para isso em SQL puro,
  --  ent??o usamos um CTE de valida????o com CASE WHEN para retornar vazio em vez de erro)

  WITH base AS (
    SELECT
      fr.type,
      fr.amount,
      fr.final_amount,
      fr.status,
      fr.deleted_at,
      -- Chave de agrupamento calculada conforme p_group_by
      CASE p_group_by
        WHEN 'month'    THEN TO_CHAR(
                               COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
                               'YYYY-MM'
                             )
        WHEN 'week'     THEN TO_CHAR(
                               COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
                               'IYYY-"W"IW'
                             )
        WHEN 'category' THEN COALESCE(fr.category_id::TEXT, fr.category, 'sem_categoria')
        WHEN 'patient'  THEN COALESCE(fr.patient_id::TEXT, 'sem_paciente')
        ELSE NULL  -- group_by inv??lido ??? group_key NULL ??? retorno vazio
      END AS gkey,
      -- Label leg??vel (enriquecido via JOIN abaixo quando poss??vel)
      CASE p_group_by
        WHEN 'month'    THEN TO_CHAR(
                               COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
                               'YYYY-MM'
                             )
        WHEN 'week'     THEN TO_CHAR(
                               COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE),
                               'IYYY-"W"IW'
                             )
        WHEN 'category' THEN COALESCE(fc.name, fr.category, 'Sem categoria')
        WHEN 'patient'  THEN COALESCE(p.nome_completo, fr.patient_id::TEXT, 'Sem paciente')
        ELSE NULL
      END AS glabel
    FROM public.financial_records fr
    LEFT JOIN public.financial_categories fc
           ON fc.id = fr.category_id
    LEFT JOIN public.patients p
           ON p.id = fr.patient_id
    WHERE fr.owner_id    = p_owner_id
      AND fr.deleted_at  IS NULL
      AND COALESCE(fr.paid_at::DATE, fr.due_date, fr.created_at::DATE)
          BETWEEN p_start_date AND p_end_date
  )

  SELECT
    gkey                                                                             AS group_key,
    glabel                                                                           AS group_label,

    COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
                                                                                     AS total_receitas,

    COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
                                                                                     AS total_despesas,

    COALESCE(SUM(final_amount) FILTER (WHERE type = 'receita' AND status = 'paid'), 0)
    - COALESCE(SUM(final_amount) FILTER (WHERE type = 'despesa' AND status = 'paid'), 0)
                                                                                     AS saldo,

    COALESCE(SUM(final_amount) FILTER (WHERE status = 'pending'), 0)                AS total_pendente,

    COALESCE(SUM(final_amount) FILTER (WHERE status = 'overdue'), 0)                AS total_overdue,

    COUNT(*)                                                                         AS count_records

  FROM base
  WHERE gkey IS NOT NULL  -- descarta p_group_by inv??lido
  GROUP BY gkey, glabel
  ORDER BY gkey ASC;

$$;

CREATE FUNCTION public.get_financial_summary(p_owner_id uuid, p_year integer, p_month integer) RETURNS TABLE(total_receitas numeric, total_despesas numeric, total_pendente numeric, saldo_liquido numeric, total_repasse numeric, count_receitas bigint, count_despesas bigint)
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
  SELECT
    -- Receitas pagas no per??odo
    COALESCE(SUM(amount) FILTER (
      WHERE type = 'receita' AND status = 'paid'
    ), 0) AS total_receitas,

    -- Despesas pagas no per??odo
    COALESCE(SUM(amount) FILTER (
      WHERE type = 'despesa' AND status = 'paid'
    ), 0) AS total_despesas,

    -- Tudo pendente ou vencido (receitas + despesas)
    COALESCE(SUM(amount) FILTER (
      WHERE status IN ('pending', 'overdue')
    ), 0) AS total_pendente,

    -- Saldo l??quido (receitas pagas ??? despesas pagas)
    COALESCE(SUM(amount) FILTER (
      WHERE type = 'receita' AND status = 'paid'
    ), 0)
    - COALESCE(SUM(amount) FILTER (
      WHERE type = 'despesa' AND status = 'paid'
    ), 0) AS saldo_liquido,

    -- Total repassado ?? cl??nica (apenas receitas pagas)
    COALESCE(SUM(clinic_fee_amount) FILTER (
      WHERE type = 'receita' AND status = 'paid'
    ), 0) AS total_repasse,

    -- Contadores (excluindo soft-deleted)
    COUNT(*) FILTER (WHERE type = 'receita' AND deleted_at IS NULL) AS count_receitas,
    COUNT(*) FILTER (WHERE type = 'despesa' AND deleted_at IS NULL) AS count_despesas

  FROM public.financial_records
  WHERE owner_id   = p_owner_id
    AND deleted_at IS NULL
    AND EXTRACT(YEAR  FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year
    AND EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month;
$$;

CREATE FUNCTION public.get_my_email() RETURNS text
    LANGUAGE sql SECURITY DEFINER
    SET search_path TO 'public', 'auth'
    AS $$
  select lower(email)
  from auth.users
  where id = auth.uid();
$$;

CREATE FUNCTION public.get_patient_intake_invite_info(p_token text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'pg_temp'
    AS $$
DECLARE
    v_token_clean text;
    v_invite      RECORD;
    v_result      jsonb;
BEGIN
    v_token_clean := nullif(trim(coalesce(p_token, '')), '');
    IF v_token_clean IS NULL THEN
        RETURN jsonb_build_object('error', 'missing-token');
    END IF;

    SELECT pi.owner_id, pi.tenant_id, pi.active, pi.expires_at, pi.max_uses, pi.uses
      INTO v_invite
      FROM public.patient_invites pi
     WHERE pi.token = v_token_clean
     LIMIT 1;

    IF v_invite.owner_id IS NULL THEN
        RETURN jsonb_build_object('error', 'invalid-token');
    END IF;

    IF v_invite.active IS DISTINCT FROM true THEN
        RETURN jsonb_build_object('error', 'invalid-token');
    END IF;

    IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
        RETURN jsonb_build_object('error', 'invalid-token');
    END IF;

    IF v_invite.max_uses IS NOT NULL AND v_invite.uses >= v_invite.max_uses THEN
        RETURN jsonb_build_object('error', 'invalid-token');
    END IF;

    SELECT jsonb_build_object(
        'therapist', jsonb_build_object(
            'display_name',      coalesce(
                                     nullif(trim(p.full_name), ''),
                                     nullif(trim(p.nickname), ''),
                                     'Profissional'
                                 ),
            'avatar_url',        nullif(trim(coalesce(p.avatar_url, '')), ''),
            'work_description',  nullif(trim(coalesce(p.work_description, '')), ''),
            'bio',               nullif(trim(coalesce(p.bio, '')), ''),
            'phone',             nullif(trim(coalesce(p.phone, '')), ''),
            'site_url',          nullif(trim(coalesce(p.site_url, '')), ''),
            'instagram',         nullif(trim(coalesce(p.social_instagram, '')), '')
        ),
        'clinic', CASE
            WHEN cp.tenant_id IS NOT NULL THEN jsonb_build_object(
                'name',       nullif(trim(coalesce(cp.nome_fantasia, '')), ''),
                'logo_url',   nullif(trim(coalesce(cp.logo_url, '')), ''),
                'email',      nullif(trim(coalesce(cp.email, '')), ''),
                'phone',      nullif(trim(coalesce(cp.telefone, '')), ''),
                'site',       nullif(trim(coalesce(cp.site, '')), ''),
                'city',       nullif(trim(coalesce(cp.cidade, '')), ''),
                'state',      nullif(trim(coalesce(cp.estado, '')), ''),
                'neighborhood', nullif(trim(coalesce(cp.bairro, '')), ''),
                'street',     nullif(trim(coalesce(cp.logradouro, '')), ''),
                'number',     nullif(trim(coalesce(cp.numero, '')), ''),
                'social',     coalesce(cp.redes_sociais, '[]'::jsonb)
            )
            ELSE NULL
        END
    )
    INTO v_result
    FROM public.profiles p
    LEFT JOIN public.company_profiles cp ON cp.tenant_id = v_invite.tenant_id
    WHERE p.id = v_invite.owner_id
    LIMIT 1;

    IF v_result IS NULL THEN
        RETURN jsonb_build_object('error', 'invalid-token');
    END IF;

    RETURN jsonb_build_object('ok', true, 'info', v_result);
END;
$$;

CREATE FUNCTION public.get_patient_session_counts(p_patient_ids uuid[]) RETURNS TABLE(patient_id uuid, session_count integer, last_session_at timestamp with time zone)
    LANGUAGE sql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
    SELECT
        ae.patient_id,
        COUNT(*)::int                AS session_count,
        MAX(ae.inicio_em)            AS last_session_at
    FROM public.agenda_eventos ae
    WHERE ae.patient_id = ANY(p_patient_ids)
      AND ae.tenant_id IN (
          SELECT tm.tenant_id
          FROM public.tenant_members tm
          WHERE tm.user_id = auth.uid()
            AND tm.status = 'active'
      )
    GROUP BY ae.patient_id;
$$;

CREATE FUNCTION public.get_twilio_config() RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    cfg saas_twilio_config%ROWTYPE;
BEGIN
    -- Permite quem é saas_admin (UI) ou quando chamado via service_role (edge function)
    -- coalesce protege de NULL (auth.role() pode ser NULL fora de contexto JWT)
    IF NOT (public.is_saas_admin() OR coalesce(auth.role(), '') = 'service_role') THEN
        RAISE EXCEPTION 'Sem permissão' USING ERRCODE = '42501';
    END IF;

    SELECT * INTO cfg FROM saas_twilio_config WHERE id = true;
    IF NOT FOUND THEN
        RETURN jsonb_build_object(
            'account_sid',          NULL,
            'whatsapp_webhook_url', NULL,
            'usd_brl_rate',         5.5,
            'margin_multiplier',    1.4
        );
    END IF;

    RETURN jsonb_build_object(
        'account_sid',          cfg.account_sid,
        'whatsapp_webhook_url', cfg.whatsapp_webhook_url,
        'usd_brl_rate',         cfg.usd_brl_rate,
        'margin_multiplier',    cfg.margin_multiplier,
        'notes',                cfg.notes,
        'updated_at',           cfg.updated_at,
        'updated_by',           cfg.updated_by
    );
END;
$$;

CREATE FUNCTION public.get_whatsapp_removable_balance(p_tenant_id uuid) RETURNS TABLE(balance integer, removable integer, protected_amount integer, topup_net integer, usage_total integer)
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_balance INT := 0;
    v_topup_net INT := 0;
    v_usage_total INT := 0;
    v_removable INT := 0;
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    SELECT COALESCE(b.balance, 0) INTO v_balance
        FROM public.whatsapp_credits_balance b
        WHERE b.tenant_id = p_tenant_id;

    v_balance := COALESCE(v_balance, 0);

    SELECT COALESCE(SUM(amount), 0) INTO v_topup_net
        FROM public.whatsapp_credits_transactions
        WHERE tenant_id = p_tenant_id
          AND kind IN ('topup_manual', 'adjustment', 'refund');

    SELECT COALESCE(ABS(SUM(amount)), 0) INTO v_usage_total
        FROM public.whatsapp_credits_transactions
        WHERE tenant_id = p_tenant_id
          AND kind = 'usage';

    v_removable := GREATEST(0, v_topup_net - v_usage_total);
    v_removable := LEAST(v_removable, v_balance);

    RETURN QUERY SELECT
        v_balance,
        v_removable,
        GREATEST(0, v_balance - v_removable),
        v_topup_net,
        v_usage_total;
END;
$$;

CREATE FUNCTION public.guard_account_type_immutable() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF OLD.account_type <> 'free' AND NEW.account_type IS DISTINCT FROM OLD.account_type THEN
    RAISE EXCEPTION 'account_type ?? imut??vel ap??s escolha (atual: "%" para tentativa: "%"). Para mudar de perfil, crie uma nova conta.', OLD.account_type, NEW.account_type
      USING ERRCODE = 'P0001';
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.guard_locked_commitment() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if (old.is_locked = true) then
    if (tg_op = 'DELETE') then
      raise exception 'Compromisso bloqueado n??o pode ser exclu??do.';
    end if;

    if (tg_op = 'UPDATE') then
      if (new.active = false) then
        raise exception 'Compromisso bloqueado n??o pode ser desativado.';
      end if;

      -- trava renomear (mant??m o "Sess??o" sempre igual)
      if (new.name is distinct from old.name) then
        raise exception 'Compromisso bloqueado n??o pode ser renomeado.';
      end if;

      -- se quiser travar descri????o tamb??m, descomente:
      -- if (new.description is distinct from old.description) then
      --   raise exception 'Compromisso bloqueado n??o pode alterar descri????o.';
      -- end if;
    end if;
  end if;

  return new;
end;
$$;

CREATE FUNCTION public.guard_no_change_core_plan_key() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro')
     and new.key is distinct from old.key then
    raise exception 'N??o ?? permitido alterar a key do plano padr??o (%).', old.key
      using errcode = 'P0001';
  end if;

  return new;
end $$;

CREATE FUNCTION public.guard_no_change_plan_target() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
declare
  v_bypass text;
begin
  -- bypass controlado por sess??o/transa????o:
  -- s?? passa se app.plan_migration_bypass = '1'
  v_bypass := current_setting('app.plan_migration_bypass', true);

  if v_bypass = '1' then
    return new;
  end if;

  -- comportamento original (bloqueia qualquer mudan??a)
  if new.target is distinct from old.target then
    raise exception 'N??o ?? permitido alterar target do plano (%) de % para %.',
      old.key, old.target, new.target
      using errcode = 'P0001';
  end if;

  return new;
end
$$;

CREATE FUNCTION public.guard_no_delete_core_plans() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if old.key in ('clinic_free','clinic_pro','therapist_free','therapist_pro') then
    raise exception 'Plano padr??o (%) n??o pode ser removido.', old.key
      using errcode = 'P0001';
  end if;

  return old;
end $$;

CREATE FUNCTION public.guard_patient_cannot_own_tenant() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  v_account_type text;
BEGIN
  SELECT account_type INTO v_account_type
  FROM public.profiles
  WHERE id = NEW.user_id;

  IF v_account_type = 'patient' AND NEW.role IN ('tenant_admin', 'therapist') THEN
    RAISE EXCEPTION 'Usu??rio com perfil "patient" n??o pode ser propriet??rio ou terapeuta de um tenant. Se tornou profissional? Crie uma nova conta.'
      USING ERRCODE = 'P0001';
  END IF;

  RETURN NEW;
END;
$$;

CREATE FUNCTION public.guard_tenant_kind_immutable() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF NEW.kind IS DISTINCT FROM OLD.kind THEN
    RAISE EXCEPTION 'tenants.kind ?? imut??vel ap??s cria????o. Tentativa de alterar "%" para "%".', OLD.kind, NEW.kind
      USING ERRCODE = 'P0001';
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.handle_new_user() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
  INSERT INTO public.profiles (id, role, account_type)
  VALUES (NEW.id, 'portal_user', 'free')
  ON CONFLICT (id) DO NOTHING;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.handle_new_user_create_personal_tenant() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  -- Desabilitado. Tenant criado no onboarding via provision_account_tenant().
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.has_feature(p_owner_id uuid, p_feature_key text) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select exists (
    select 1
    from public.owner_feature_entitlements e
    where e.owner_id = p_owner_id
      and e.feature_key = p_feature_key
  );
$$;

CREATE FUNCTION public.is_clinic_tenant(_tenant_id uuid) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  SELECT EXISTS (
    SELECT 1 FROM public.tenants t
    WHERE t.id = _tenant_id
      AND t.kind IN ('clinic', 'clinic_coworking', 'clinic_reception', 'clinic_full')
  );
$$;

CREATE FUNCTION public.is_saas_admin() RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select exists (
    select 1 from public.saas_admins sa
    where sa.user_id = auth.uid()
  );
$$;

CREATE FUNCTION public.is_tenant_admin(p_tenant_id uuid) RETURNS boolean
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
  select exists (
    select 1
    from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
      and tm.role = 'tenant_admin'
      and tm.status = 'active'
  );
$$;

CREATE FUNCTION public.is_tenant_member(_tenant_id uuid) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select exists (
    select 1
    from public.tenant_members m
    where m.tenant_id = _tenant_id
      and m.user_id = auth.uid()
      and m.status = 'active'
  );
$$;

CREATE FUNCTION public.is_therapist_tenant(_tenant_id uuid) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  SELECT EXISTS (
    SELECT 1 FROM public.tenants t
    WHERE t.id = _tenant_id AND t.kind = 'therapist'
  );
$$;

CREATE FUNCTION public.issue_patient_invite() RETURNS text
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_uid        uuid;
  v_tenant_id  uuid;
  v_token      text;
  v_existing   text;
BEGIN
  v_uid := auth.uid();
  IF v_uid IS NULL THEN
    RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
  END IF;

  -- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create)
  SELECT token
    INTO v_existing
  FROM public.patient_invites
  WHERE owner_id = v_uid
    AND active = true
  ORDER BY created_at DESC
  LIMIT 1;

  IF v_existing IS NOT NULL THEN
    RETURN v_existing;
  END IF;

  SELECT tenant_id
    INTO v_tenant_id
  FROM public.tenant_members
  WHERE user_id = v_uid
    AND status = 'active'
  ORDER BY created_at ASC
  LIMIT 1;

  v_token := replace(gen_random_uuid()::text, '-', '');

  INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
  VALUES (v_uid, v_tenant_id, v_token, true);

  RETURN v_token;
END;
$$;

CREATE FUNCTION public.jwt_email() RETURNS text
    LANGUAGE sql STABLE
    AS $$
  select nullif(lower(current_setting('request.jwt.claim.email', true)), '');
$$;

CREATE FUNCTION public.list_financial_records(p_owner_id uuid, p_year integer DEFAULT NULL::integer, p_month integer DEFAULT NULL::integer, p_type text DEFAULT NULL::text, p_status text DEFAULT NULL::text, p_patient_id uuid DEFAULT NULL::uuid, p_limit integer DEFAULT 50, p_offset integer DEFAULT 0) RETURNS SETOF public.financial_records
    LANGUAGE sql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
  SELECT *
  FROM public.financial_records
  WHERE owner_id    = p_owner_id
    AND deleted_at  IS NULL
    AND (p_type       IS NULL OR type::TEXT  = p_type)
    AND (p_status     IS NULL OR status      = p_status)
    AND (p_patient_id IS NULL OR patient_id  = p_patient_id)
    AND (p_year  IS NULL OR EXTRACT(YEAR  FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_year)
    AND (p_month IS NULL OR EXTRACT(MONTH FROM COALESCE(paid_at::DATE, due_date, created_at::DATE)) = p_month)
  ORDER BY COALESCE(paid_at, due_date::TIMESTAMPTZ, created_at) DESC
  LIMIT  p_limit
  OFFSET p_offset;
$$;

CREATE FUNCTION public.log_audit_change() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'pg_temp'
    AS $$
DECLARE
    v_tenant_id UUID;
    v_entity_id TEXT;
    v_old JSONB;
    v_new JSONB;
    v_changed TEXT[];
    v_heavy_fields TEXT[] := ARRAY[
        'content', 'content_html', 'content_json', 'raw_data',
        'signature_data', 'pdf_blob', 'binary', 'body_html', 'body_text'
    ];
    v_noise_fields TEXT[] := ARRAY['updated_at', 'last_seen_at', 'last_activity_at'];
BEGIN
    IF TG_OP = 'DELETE' THEN
        v_tenant_id := OLD.tenant_id;
        v_entity_id := OLD.id::TEXT;
        v_old := to_jsonb(OLD) - v_heavy_fields;
        v_new := NULL;
    ELSIF TG_OP = 'INSERT' THEN
        v_tenant_id := NEW.tenant_id;
        v_entity_id := NEW.id::TEXT;
        v_old := NULL;
        v_new := to_jsonb(NEW) - v_heavy_fields;
    ELSE -- UPDATE
        v_tenant_id := NEW.tenant_id;
        v_entity_id := NEW.id::TEXT;
        v_old := to_jsonb(OLD) - v_heavy_fields;
        v_new := to_jsonb(NEW) - v_heavy_fields;

        -- calcular campos realmente alterados
        SELECT array_agg(key ORDER BY key) INTO v_changed
        FROM jsonb_each(to_jsonb(NEW)) AS kv(key, value)
        WHERE (to_jsonb(OLD))->kv.key IS DISTINCT FROM kv.value;

        -- se nada mudou, ignora
        IF v_changed IS NULL THEN
            RETURN NEW;
        END IF;

        -- se mudou apenas campos de ruido (ex: updated_at), ignora
        IF v_changed <@ v_noise_fields THEN
            RETURN NEW;
        END IF;
    END IF;

    INSERT INTO public.audit_logs (
        tenant_id, user_id, entity_type, entity_id, action,
        old_values, new_values, changed_fields
    ) VALUES (
        v_tenant_id,
        auth.uid(),
        TG_TABLE_NAME,
        v_entity_id,
        lower(TG_OP),
        v_old,
        v_new,
        v_changed
    );

    RETURN COALESCE(NEW, OLD);
END;
$$;

CREATE FUNCTION public.mark_as_paid(p_financial_record_id uuid, p_payment_method text) RETURNS SETOF public.financial_records
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_record public.financial_records%ROWTYPE;
BEGIN
  -- Garante que o registro pertence ao usu??rio autenticado (RLS n??o aplica em SECURITY DEFINER)
  SELECT * INTO v_record
  FROM public.financial_records
  WHERE id = p_financial_record_id
    AND owner_id = auth.uid()
    AND deleted_at IS NULL;

  IF NOT FOUND THEN
    RAISE EXCEPTION 'Registro financeiro n??o encontrado ou sem permiss??o.';
  END IF;

  IF v_record.status NOT IN ('pending', 'overdue') THEN
    RAISE EXCEPTION 'Apenas cobran??as pendentes ou vencidas podem ser marcadas como pagas.';
  END IF;

  UPDATE public.financial_records
    SET status         = 'paid',
        paid_at        = NOW(),
        payment_method = p_payment_method,
        updated_at     = NOW()
  WHERE id = p_financial_record_id
  RETURNING * INTO v_record;

  RETURN NEXT v_record;
END;
$$;

CREATE FUNCTION public.mark_payout_as_paid(p_payout_id uuid) RETURNS public.therapist_payouts
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_payout public.therapist_payouts%ROWTYPE;
BEGIN
  -- Busca o payout
  SELECT * INTO v_payout
  FROM public.therapist_payouts
  WHERE id = p_payout_id;

  IF NOT FOUND THEN
    RAISE EXCEPTION 'Repasse n??o encontrado: %', p_payout_id;
  END IF;

  -- Verifica permiss??o: apenas tenant_admin do tenant do repasse
  IF NOT public.is_tenant_admin(v_payout.tenant_id) THEN
    RAISE EXCEPTION 'Apenas o administrador da cl??nica pode marcar repasses como pagos.';
  END IF;

  -- Verifica status
  IF v_payout.status <> 'pending' THEN
    RAISE EXCEPTION
      'Repasse j?? est?? com status ''%''. Apenas repasses pendentes podem ser pagos.',
      v_payout.status;
  END IF;

  -- Atualiza
  UPDATE public.therapist_payouts
  SET
    status     = 'paid',
    paid_at    = NOW(),
    updated_at = NOW()
  WHERE id = p_payout_id
  RETURNING * INTO v_payout;

  RETURN v_payout;
END;
$$;

CREATE FUNCTION public.match_patient_by_phone(p_tenant_id uuid, p_phone text) RETURNS uuid
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public', 'pg_temp'
    AS $$
DECLARE
    v_normalized TEXT;
    v_patient_id UUID;
BEGIN
    v_normalized := public.normalize_phone_br(p_phone);
    IF v_normalized IS NULL OR length(v_normalized) < 10 THEN
        RETURN NULL;
    END IF;

    -- prioridade: telefone principal, depois alternativo, depois responsavel
    SELECT id INTO v_patient_id FROM public.patients
    WHERE tenant_id = p_tenant_id
      AND public.normalize_phone_br(telefone) = v_normalized
    LIMIT 1;
    IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;

    SELECT id INTO v_patient_id FROM public.patients
    WHERE tenant_id = p_tenant_id
      AND public.normalize_phone_br(telefone_alternativo) = v_normalized
    LIMIT 1;
    IF v_patient_id IS NOT NULL THEN RETURN v_patient_id; END IF;

    SELECT id INTO v_patient_id FROM public.patients
    WHERE tenant_id = p_tenant_id
      AND public.normalize_phone_br(telefone_responsavel) = v_normalized
    LIMIT 1;

    RETURN v_patient_id;
END;
$$;

CREATE FUNCTION public.my_tenants() RETURNS TABLE(tenant_id uuid, role text, status text, kind text)
    LANGUAGE sql STABLE
    AS $$
  select
    tm.tenant_id,
    tm.role,
    tm.status,
    t.kind
  from public.tenant_members tm
  join public.tenants t on t.id = tm.tenant_id
  where tm.user_id = auth.uid();
$$;

CREATE FUNCTION public.normalize_phone_br(p_phone text) RETURNS text
    LANGUAGE plpgsql IMMUTABLE
    AS $$
DECLARE
    v_digits TEXT;
BEGIN
    IF p_phone IS NULL THEN RETURN NULL; END IF;

    -- remove tudo que nao seja digito
    v_digits := regexp_replace(p_phone, '\D', '', 'g');

    -- remove DDI 55 se tem 12+ digitos (+55 + DDD + numero)
    IF length(v_digits) >= 12 AND left(v_digits, 2) = '55' THEN
        v_digits := substr(v_digits, 3);
    END IF;

    -- pega os ultimos 11 digitos (DDD + 9digito + 8numero) ou 10 (DDD + 8numero)
    IF length(v_digits) > 11 THEN
        v_digits := right(v_digits, 11);
    END IF;

    RETURN v_digits;
END;
$$;

CREATE FUNCTION public.notice_track_click(p_notice_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
begin
  update public.global_notices
  set clicks_count = clicks_count + 1
  where id = p_notice_id;
end;
$$;

CREATE FUNCTION public.notice_track_view(p_notice_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
begin
  update public.global_notices
  set views_count = views_count + 1
  where id = p_notice_id;
end;
$$;

CREATE FUNCTION public.notify_on_intake() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  IF NEW.status = 'new' THEN
    INSERT INTO public.notifications (
      owner_id,
      tenant_id,
      type,
      ref_id,
      ref_table,
      payload
    )
    VALUES (
      NEW.owner_id,
      NEW.tenant_id,
      'new_patient',
      NEW.id,
      'patient_intake_requests',
      jsonb_build_object(
        'title',  'Novo cadastro externo',
        'detail', COALESCE(NEW.nome_completo, 'Paciente'),
        'deeplink', '/therapist/patients/cadastro/recebidos',
        'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
      )
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$                                                                                                                   BEGIN                                                                                                                     IF NEW.status = 'pendente' THEN
      INSERT INTO public.notifications (                                                                                        owner_id,                                                                                                               tenant_id,                                                                                                              type,                                                                                                                   ref_id,                                                                                                                 ref_table,                                                                                                              payload                                                                                                               )                                                                                                                       VALUES (
        NEW.owner_id,                                                                                                           NEW.tenant_id,
        'new_scheduling',                                                                                                       NEW.id,                                                                                                                 'agendador_solicitacoes',                                                                                               jsonb_build_object(                                                                                                       'title',  'Nova solicita????o de agendamento',                                                                            'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || ' ??? ' ||    COALESCE(NEW.tipo, ''),                                                                                                         'deeplink', '/therapist/agendamentos-recebidos',                                                                        'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome,     ''), 1))                                                                                                                      )                                                                                                                     );                                                                                                                    END IF;                                                                                                                 RETURN NEW;                                                                                                           END;                                                                                                                    $$;

CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_nome text;
BEGIN
  IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN

    SELECT nome_completo
      INTO v_nome
      FROM public.patients
     WHERE id = NEW.patient_id
     LIMIT 1;

    INSERT INTO public.notifications (
      owner_id,
      tenant_id,
      type,
      ref_id,
      ref_table,
      payload
    )
    VALUES (
      NEW.owner_id,
      NEW.tenant_id,
      'session_status',
      NEW.id,
      'agenda_eventos',
      jsonb_build_object(
        'title',           CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sess??o cancelada' END,
        'detail',          COALESCE(v_nome, 'Paciente') || ' ??? ' || to_char(NEW.inicio_em, 'DD/MM HH24:MI'),
        'deeplink',        '/therapist/agenda',
        'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
      )
    );

  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.on_new_user_seed_patient_groups() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
  BEGIN
    PERFORM public.seed_default_patient_groups(NEW.id);
    RETURN NEW;
  END;
  $$;

CREATE FUNCTION public.patients_validate_member_consistency() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  v_tenant_responsible uuid;
  v_tenant_therapist uuid;
BEGIN
  -- responsible_member sempre deve existir e ser do tenant
  SELECT tenant_id INTO v_tenant_responsible
  FROM public.tenant_members
  WHERE id = NEW.responsible_member_id;

  IF v_tenant_responsible IS NULL THEN
    RAISE EXCEPTION 'Responsible member not found';
  END IF;

  IF NEW.tenant_id IS NULL THEN
    RAISE EXCEPTION 'tenant_id is required';
  END IF;

  IF v_tenant_responsible <> NEW.tenant_id THEN
    RAISE EXCEPTION 'Responsible member must belong to the same tenant';
  END IF;

  -- therapist scope: therapist_member_id deve existir e ser do mesmo tenant
  IF NEW.patient_scope = 'therapist' THEN
    IF NEW.therapist_member_id IS NULL THEN
      RAISE EXCEPTION 'therapist_member_id is required when patient_scope=therapist';
    END IF;

    SELECT tenant_id INTO v_tenant_therapist
    FROM public.tenant_members
    WHERE id = NEW.therapist_member_id;

    IF v_tenant_therapist IS NULL THEN
      RAISE EXCEPTION 'Therapist member not found';
    END IF;

    IF v_tenant_therapist <> NEW.tenant_id THEN
      RAISE EXCEPTION 'Therapist member must belong to the same tenant';
    END IF;
  END IF;

  RETURN NEW;
END;
$$;

CREATE FUNCTION public.patients_validate_responsible_member_tenant() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
declare
  m_tenant uuid;
begin
  select tenant_id into m_tenant
  from public.tenant_members
  where id = new.responsible_member_id;

  if m_tenant is null then
    raise exception 'Responsible member not found';
  end if;

  if new.tenant_id is null then
    raise exception 'tenant_id is required';
  end if;

  if m_tenant <> new.tenant_id then
    raise exception 'Responsible member must belong to the same tenant';
  end if;

  return new;
end;
$$;

CREATE FUNCTION public.populate_notification_queue() RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  INSERT INTO public.notification_queue (
    tenant_id, owner_id, agenda_evento_id, patient_id,
    channel, template_key, schedule_key,
    resolved_vars, recipient_address,
    scheduled_at, idempotency_key
  )
  SELECT
    ae.tenant_id,
    ae.owner_id,
    ae.id AS agenda_evento_id,
    ae.patient_id,
    ch.channel,
    'session.' || REPLACE(ns.event_type, '_sessao', '') || '.' || ch.channel,
    ns.schedule_key,
    jsonb_build_object(
      'nome_paciente',   COALESCE(p.nome_completo, 'Paciente'),
      'data_sessao',     TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'DD/MM/YYYY'),
      'hora_sessao',     TO_CHAR(ae.inicio_em AT TIME ZONE 'America/Sao_Paulo', 'HH24:MI'),
      'nome_terapeuta',  COALESCE(prof.full_name, 'Terapeuta'),
      'modalidade',      COALESCE(ae.modalidade, 'Presencial'),
      'titulo',          COALESCE(ae.titulo, 'Sess??o')
    ),
    CASE ch.channel
      WHEN 'whatsapp' THEN COALESCE(p.telefone, '')
      WHEN 'sms'      THEN COALESCE(p.telefone, '')
      WHEN 'email'    THEN COALESCE(p.email_principal, '')
    END,
    CASE
      WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
           < ns.allowed_time_start
      THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
           + ns.allowed_time_start
      WHEN (ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)::time
           > ns.allowed_time_end
      THEN DATE_TRUNC('day', ae.inicio_em - (ns.offset_minutes || ' minutes')::interval)
           + ns.allowed_time_start
      ELSE ae.inicio_em - (ns.offset_minutes || ' minutes')::interval
    END,
    ae.id::text || ':' || ns.schedule_key || ':' || ch.channel || ':'
      || ae.inicio_em::date::text
  FROM public.agenda_eventos ae
  JOIN public.patients p ON p.id = ae.patient_id
  LEFT JOIN public.profiles prof ON prof.id = ae.owner_id
  JOIN public.notification_schedules ns
    ON ns.owner_id = ae.owner_id
    AND ns.is_active = true
    AND ns.deleted_at IS NULL
    AND ns.trigger_type = 'before_event'
    AND ns.event_type = 'lembrete_sessao'
  JOIN public.notification_channels nc
    ON nc.owner_id = ae.owner_id
    AND nc.is_active = true
    AND nc.deleted_at IS NULL
  CROSS JOIN LATERAL (
    SELECT 'whatsapp' AS channel WHERE ns.whatsapp_enabled AND nc.channel = 'whatsapp'
    UNION ALL
    SELECT 'email'    AS channel WHERE ns.email_enabled    AND nc.channel = 'email'
    UNION ALL
    SELECT 'sms'      AS channel WHERE ns.sms_enabled      AND nc.channel = 'sms'
  ) ch
  LEFT JOIN public.notification_preferences np
    ON np.patient_id = ae.patient_id
    AND np.owner_id = ae.owner_id
    AND np.deleted_at IS NULL
  WHERE
    ae.inicio_em > now()
    AND ae.inicio_em <= now() + interval '48 hours'
    AND ae.status NOT IN ('cancelado', 'faltou')
    AND CASE ch.channel
          WHEN 'whatsapp' THEN COALESCE(p.telefone, '') != ''
          WHEN 'sms'      THEN COALESCE(p.telefone, '') != ''
          WHEN 'email'    THEN COALESCE(p.email_principal, '') != ''
        END
    AND CASE ch.channel
          WHEN 'whatsapp' THEN COALESCE(np.whatsapp_opt_in, true)
          WHEN 'email'    THEN COALESCE(np.email_opt_in, true)
          WHEN 'sms'      THEN COALESCE(np.sms_opt_in, false)
        END
    AND EXISTS (
      SELECT 1 FROM public.profiles tp
      WHERE tp.id = ae.owner_id
      AND COALESCE(tp.notify_reminders, true) = true
    )
  ON CONFLICT (idempotency_key) DO NOTHING;
END;
$$;

CREATE FUNCTION public.prevent_promoting_to_system() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if new.is_system = true and old.is_system is distinct from true then
    raise exception 'N??o ?? permitido transformar um grupo comum em grupo do sistema.';
  end if;
  return new;
end;
$$;

CREATE FUNCTION public.prevent_saas_membership() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  IF EXISTS (
    SELECT 1
    FROM public.profiles
    WHERE id = NEW.user_id
    AND role = 'saas_admin'
  ) THEN
    RAISE EXCEPTION 'SaaS admin cannot belong to tenant';
  END IF;

  RETURN NEW;
END;
$$;

CREATE FUNCTION public.prevent_system_group_changes() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  -- Se for grupo do sistema, regras r??gidas:
  if old.is_system = true then

    -- nunca pode deletar
    if tg_op = 'DELETE' then
      raise exception 'Grupos padr??o do sistema n??o podem ser alterados ou exclu??dos.';
    end if;

    if tg_op = 'UPDATE' then
      -- permite SOMENTE mudar tenant_id e/ou updated_at
      -- qualquer mudan??a de conte??do permanece proibida
      if
        new.nome         is distinct from old.nome or
        new.descricao    is distinct from old.descricao or
        new.cor          is distinct from old.cor or
        new.is_active    is distinct from old.is_active or
        new.is_system    is distinct from old.is_system or
        new.owner_id     is distinct from old.owner_id or
        new.therapist_id is distinct from old.therapist_id or
        new.created_at   is distinct from old.created_at
      then
        raise exception 'Grupos padr??o do sistema n??o podem ser alterados ou exclu??dos.';
      end if;

      -- chegou aqui: s?? tenant_id/updated_at mudaram -> ok
      return new;
    end if;

  end if;

  -- n??o-system: deixa passar
  if tg_op = 'DELETE' then
    return old;
  end if;

  return new;
end;
$$;

CREATE FUNCTION public.provision_account_tenant(p_user_id uuid, p_kind text, p_name text DEFAULT NULL::text) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_tenant_id    uuid;
  v_account_type text;
  v_name         text;
BEGIN
  IF p_kind NOT IN ('therapist', 'clinic_coworking', 'clinic_reception', 'clinic_full') THEN
    RAISE EXCEPTION 'kind inv??lido: "%". Use: therapist, clinic_coworking, clinic_reception, clinic_full.', p_kind
      USING ERRCODE = 'P0001';
  END IF;

  v_account_type := CASE WHEN p_kind = 'therapist' THEN 'therapist' ELSE 'clinic' END;

  IF EXISTS (
    SELECT 1
    FROM public.tenant_members tm
    JOIN public.tenants t ON t.id = tm.tenant_id
    WHERE tm.user_id = p_user_id
      AND tm.role = 'tenant_admin'
      AND tm.status = 'active'
      AND t.kind = p_kind
  ) THEN
    RAISE EXCEPTION 'Usu??rio j?? possui um tenant do tipo "%".', p_kind
      USING ERRCODE = 'P0001';
  END IF;

  v_name := COALESCE(
    NULLIF(TRIM(p_name), ''),
    (
      SELECT COALESCE(NULLIF(TRIM(pr.full_name), ''), SPLIT_PART(au.email, '@', 1))
      FROM public.profiles pr
      JOIN auth.users au ON au.id = pr.id
      WHERE pr.id = p_user_id
    ),
    'Conta'
  );

  INSERT INTO public.tenants (name, kind, created_at)
  VALUES (v_name, p_kind, now())
  RETURNING id INTO v_tenant_id;

  INSERT INTO public.tenant_members (tenant_id, user_id, role, status, created_at)
  VALUES (v_tenant_id, p_user_id, 'tenant_admin', 'active', now());

  UPDATE public.profiles
  SET account_type = v_account_type
  WHERE id = p_user_id;

  PERFORM public.seed_determined_commitments(v_tenant_id);

  RETURN v_tenant_id;
END;
$$;

CREATE FUNCTION public.reactivate_subscription(p_subscription_id uuid) RETURNS public.subscriptions
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_sub public.subscriptions;
  v_owner_type text;
  v_owner_ref uuid;
begin

  select *
  into v_sub
  from public.subscriptions
  where id = p_subscription_id
  for update;

  if not found then
    raise exception 'Subscription n??o encontrada';
  end if;

  if v_sub.status = 'active' then
    return v_sub;
  end if;

  if v_sub.tenant_id is not null then
    v_owner_type := 'clinic';
    v_owner_ref := v_sub.tenant_id;
  elsif v_sub.user_id is not null then
    v_owner_type := 'therapist';
    v_owner_ref := v_sub.user_id;
  else
    v_owner_type := null;
    v_owner_ref := null;
  end if;

  update public.subscriptions
  set status = 'active',
      cancel_at_period_end = false,
      updated_at = now()
  where id = p_subscription_id
  returning * into v_sub;

  insert into public.subscription_events(
    subscription_id,
    owner_id,
    owner_type,
    owner_ref,
    event_type,
    old_plan_id,
    new_plan_id,
    created_by,
    reason,
    source,
    metadata
  )
  values (
    v_sub.id,
    v_owner_ref,
    v_owner_type,
    v_owner_ref,
    'reactivated',
    v_sub.plan_id,
    v_sub.plan_id,
    auth.uid(),
    'Reativa????o manual via admin',
    'admin_panel',
    jsonb_build_object('previous_status', 'canceled')
  );

  if v_owner_ref is not null then
    insert into public.entitlements_invalidation(owner_id, changed_at)
    values (v_owner_ref, now())
    on conflict (owner_id)
    do update set changed_at = excluded.changed_at;
  end if;

  return v_sub;

end;
$$;

CREATE FUNCTION public.rebuild_owner_entitlements(p_owner_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_plan_id uuid;
begin
  -- Plano ativo do owner (owner = subscriptions.user_id)
  select s.plan_id
    into v_plan_id
  from public.subscriptions s
  where s.user_id = p_owner_id
    and s.status = 'active'
  order by s.created_at desc
  limit 1;

  -- Sempre zera entitlements do owner (rebuild)
  delete from public.owner_feature_entitlements e
  where e.owner_id = p_owner_id;

  -- Se n??o tem assinatura ativa, acabou
  if v_plan_id is null then
    return;
  end if;

  -- Recria entitlements esperados pelo plano
  insert into public.owner_feature_entitlements (owner_id, feature_key, sources, limits_list)
  select
    p_owner_id as owner_id,
    f.key as feature_key,
    array['plan'::text] as sources,
    '{}'::jsonb as limits_list
  from public.plan_features pf
  join public.features f on f.id = pf.feature_id
  where pf.plan_id = v_plan_id;

end;
$$;

CREATE FUNCTION public.record_submission_attempt(p_endpoint text, p_ip_hash text, p_success boolean, p_blocked_by text DEFAULT NULL::text, p_error_code text DEFAULT NULL::text, p_error_msg text DEFAULT NULL::text, p_user_agent text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
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;
$$;

CREATE FUNCTION public.revoke_support_session(p_token text) RETURNS boolean
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_admin_id uuid;
  v_role     text;
BEGIN
  v_admin_id := auth.uid();
  IF v_admin_id IS NULL THEN
    RAISE EXCEPTION 'N??o autenticado.' USING ERRCODE = 'P0001';
  END IF;

  SELECT role INTO v_role FROM public.profiles WHERE id = v_admin_id;
  IF v_role <> 'saas_admin' THEN
    RAISE EXCEPTION 'Acesso negado.' USING ERRCODE = 'P0002';
  END IF;

  DELETE FROM public.support_sessions
   WHERE token = p_token
     AND admin_id = v_admin_id;

  RETURN FOUND;
END;
$$;

CREATE FUNCTION public.rotate_patient_invite_token(p_new_token text) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
declare
  v_uid uuid;
  v_id uuid;
begin
  -- pega o usu??rio logado
  v_uid := auth.uid();
  if v_uid is null then
    raise exception 'Usu??rio n??o autenticado';
  end if;

  -- desativa tokens antigos ativos do usu??rio
  update public.patient_invites
    set active = false
  where owner_id = v_uid
    and active = true;

  -- cria novo token
  insert into public.patient_invites (owner_id, token, active)
  values (v_uid, p_new_token, true)
  returning id into v_id;

  return v_id;
end;
$$;

CREATE FUNCTION public.rotate_patient_invite_token_v2() RETURNS text
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_uid        uuid;
  v_tenant_id  uuid;
  v_new_token  text;
BEGIN
  v_uid := auth.uid();
  IF v_uid IS NULL THEN
    RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000';
  END IF;

  -- Token gerado no servidor (criptograficamente seguro via pgcrypto)
  v_new_token := replace(gen_random_uuid()::text, '-', '');

  -- Resolve tenant_id do usuário (active)
  SELECT tenant_id
    INTO v_tenant_id
  FROM public.tenant_members
  WHERE user_id = v_uid
    AND status = 'active'
  ORDER BY created_at ASC
  LIMIT 1;

  -- Desativa tokens ativos anteriores
  UPDATE public.patient_invites
    SET active = false
  WHERE owner_id = v_uid
    AND active = true;

  -- Insere novo
  INSERT INTO public.patient_invites (owner_id, tenant_id, token, active)
  VALUES (v_uid, v_tenant_id, v_new_token, true);

  RETURN v_new_token;
END;
$$;

CREATE FUNCTION public.saas_votar_doc(p_doc_id uuid, p_util boolean) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_uid         uuid := auth.uid();
  v_voto_antigo boolean;
begin
  if v_uid is null then
    raise exception 'N??o autenticado';
  end if;

  -- Verifica se j?? votou
  select util into v_voto_antigo
  from public.saas_doc_votos
  where doc_id = p_doc_id and user_id = v_uid;

  if found then
    -- J?? votou igual ??? cancela o voto (toggle)
    if v_voto_antigo = p_util then
      delete from public.saas_doc_votos
      where doc_id = p_doc_id and user_id = v_uid;

      update public.saas_docs set
        votos_util     = greatest(0, votos_util     - (case when p_util  then 1 else 0 end)),
        votos_nao_util = greatest(0, votos_nao_util - (case when not p_util then 1 else 0 end)),
        updated_at = now()
      where id = p_doc_id;

      return jsonb_build_object('acao', 'removido', 'util', null);
    else
      -- Mudou de voto
      update public.saas_doc_votos set util = p_util, updated_at = now()
      where doc_id = p_doc_id and user_id = v_uid;

      update public.saas_docs set
        votos_util     = greatest(0, votos_util     + (case when p_util  then 1 else -1 end)),
        votos_nao_util = greatest(0, votos_nao_util + (case when not p_util then 1 else -1 end)),
        updated_at = now()
      where id = p_doc_id;

      return jsonb_build_object('acao', 'atualizado', 'util', p_util);
    end if;
  else
    -- Primeiro voto
    insert into public.saas_doc_votos (doc_id, user_id, util)
    values (p_doc_id, v_uid, p_util);

    update public.saas_docs set
      votos_util     = votos_util     + (case when p_util  then 1 else 0 end),
      votos_nao_util = votos_nao_util + (case when not p_util then 1 else 0 end),
      updated_at = now()
    where id = p_doc_id;

    return jsonb_build_object('acao', 'registrado', 'util', p_util);
  end if;
end;
$$;

CREATE FUNCTION public.saas_wa_credits_revenue_evolution(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now(), p_bucket_days integer DEFAULT 7) RETURNS TABLE(bucket_start timestamp with time zone, purchases_count integer, revenue_brl numeric)
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    RETURN QUERY
    WITH purchases AS (
        SELECT p.paid_at, p.amount_brl
        FROM public.whatsapp_credit_purchases p
        WHERE p.status = 'paid'
          AND p.paid_at >= p_from
          AND p.paid_at <= p_to
    ),
    bucketed AS (
        SELECT
            p_from + (
                FLOOR(EXTRACT(EPOCH FROM (paid_at - p_from)) / (p_bucket_days * 86400))::INT
                * p_bucket_days * interval '1 day'
            ) AS bucket_start,
            amount_brl
        FROM purchases
    )
    SELECT
        bucket_start,
        COUNT(*)::INT AS purchases_count,
        SUM(amount_brl)::NUMERIC AS revenue_brl
    FROM bucketed
    GROUP BY bucket_start
    ORDER BY bucket_start;
END;
$$;

CREATE FUNCTION public.saas_wa_credits_revenue_stats(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(revenue_brl numeric, purchases_count integer, tenants_count integer, credits_sold integer, avg_ticket_brl numeric)
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    RETURN QUERY
    SELECT
        COALESCE(SUM(p.amount_brl), 0)::NUMERIC AS revenue_brl,
        COUNT(*)::INT AS purchases_count,
        COUNT(DISTINCT p.tenant_id)::INT AS tenants_count,
        COALESCE(SUM(p.credits), 0)::INT AS credits_sold,
        CASE WHEN COUNT(*) = 0 THEN 0
             ELSE ROUND(COALESCE(AVG(p.amount_brl), 0), 2)
        END AS avg_ticket_brl
    FROM public.whatsapp_credit_purchases p
    WHERE p.status = 'paid'
      AND p.paid_at >= p_from
      AND p.paid_at <= p_to;
END;
$$;

CREATE FUNCTION public.saas_wa_credits_top_packages(p_from timestamp with time zone DEFAULT (now() - '30 days'::interval), p_to timestamp with time zone DEFAULT now()) RETURNS TABLE(package_id uuid, package_name text, purchases_count integer, revenue_brl numeric, credits_sold integer)
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    RETURN QUERY
    SELECT
        p.package_id,
        -- Nome snapshot do momento da compra; se tem package_id, usa o nome
        -- atual pra consolidar pacotes renomeados
        COALESCE(
            (SELECT pk.name FROM public.whatsapp_credit_packages pk WHERE pk.id = p.package_id),
            p.package_name
        ) AS package_name,
        COUNT(*)::INT AS purchases_count,
        SUM(p.amount_brl)::NUMERIC AS revenue_brl,
        SUM(p.credits)::INT AS credits_sold
    FROM public.whatsapp_credit_purchases p
    WHERE p.status = 'paid'
      AND p.paid_at >= p_from
      AND p.paid_at <= p_to
    GROUP BY p.package_id, p.package_name
    ORDER BY revenue_brl DESC
    LIMIT 10;
END;
$$;

CREATE FUNCTION public.saas_wa_credits_usage_summary() RETURNS TABLE(lifetime_purchased integer, lifetime_used integer, current_balance integer, usage_rate numeric, tenants_with_balance integer)
    LANGUAGE plpgsql STABLE SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'permission_denied';
    END IF;

    RETURN QUERY
    SELECT
        COALESCE(SUM(lifetime_purchased), 0)::INT AS lifetime_purchased,
        COALESCE(SUM(lifetime_used), 0)::INT AS lifetime_used,
        COALESCE(SUM(balance), 0)::INT AS current_balance,
        CASE WHEN COALESCE(SUM(lifetime_purchased), 0) = 0 THEN 0
             ELSE ROUND(100.0 * COALESCE(SUM(lifetime_used), 0) / SUM(lifetime_purchased), 1)
        END AS usage_rate,
        COUNT(*)::INT AS tenants_with_balance
    FROM public.whatsapp_credits_balance;
END;
$$;

CREATE FUNCTION public.safe_delete_patient(p_patient_id uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  -- Bloqueia se houver hist??rico
  IF NOT public.can_delete_patient(p_patient_id) THEN
    RETURN jsonb_build_object(
      'ok',      false,
      'error',   'has_history',
      'message', 'Este paciente possui hist??rico cl??nico ou financeiro e n??o pode ser removido. Voc?? pode desativar ou arquivar o paciente.'
    );
  END IF;

  -- Verifica ownership via RLS (owner_id ou responsible_member_id)
  IF NOT EXISTS (
    SELECT 1 FROM public.patients
     WHERE id = p_patient_id
       AND (
         owner_id = auth.uid()
         OR responsible_member_id IN (
           SELECT id FROM public.tenant_members WHERE user_id = auth.uid()
         )
       )
  ) THEN
    RETURN jsonb_build_object(
      'ok',      false,
      'error',   'forbidden',
      'message', 'Sem permiss??o para excluir este paciente.'
    );
  END IF;

  DELETE FROM public.patients WHERE id = p_patient_id;

  RETURN jsonb_build_object('ok', true);
END;
$$;

CREATE FUNCTION public.sanitize_phone_br(raw_phone text) RETURNS text
    LANGUAGE plpgsql IMMUTABLE
    AS $$                                                                           DECLARE digits text;
  BEGIN
    digits := regexp_replace(COALESCE(raw_phone, ''), '[^0-9]', '', 'g');
    IF digits = '' THEN RETURN ''; END IF;
    IF length(digits) = 10 OR length(digits) = 11 THEN
      digits := '55' || digits;
    END IF;
    RETURN digits;
  END; $$;

CREATE FUNCTION public.search_global(p_q text, p_scope text[] DEFAULT NULL::text[], p_limit integer DEFAULT 8) RETURNS jsonb
    LANGUAGE plpgsql STABLE
    SET search_path TO 'public', 'pg_temp'
    AS $_$
DECLARE
    v_q            text;
    v_pattern      text;
    v_limit        int;
    v_patients     jsonb := '[]'::jsonb;
    v_appointments jsonb := '[]'::jsonb;
    v_documents    jsonb := '[]'::jsonb;
    v_services     jsonb := '[]'::jsonb;
    v_intakes      jsonb := '[]'::jsonb;
BEGIN
    -- Sanitize + length guards
    v_q := nullif(btrim(coalesce(p_q, '')), '');
    IF v_q IS NULL OR length(v_q) < 2 THEN
        RETURN jsonb_build_object(
            'patients',     '[]'::jsonb,
            'appointments', '[]'::jsonb,
            'documents',    '[]'::jsonb,
            'services',     '[]'::jsonb,
            'intakes',      '[]'::jsonb
        );
    END IF;
    v_q := left(v_q, 80);
    v_pattern := '%' || v_q || '%';
    v_limit := GREATEST(1, LEAST(coalesce(p_limit, 8), 20));

    -- ─────────────────────────────────────────────────────────────────────
    -- Pacientes
    -- ─────────────────────────────────────────────────────────────────────
    IF p_scope IS NULL OR 'patients' = ANY(p_scope) THEN
        WITH ranked AS (
            SELECT
                p.id,
                p.nome_completo,
                p.email_principal,
                p.telefone,
                p.avatar_url,
                GREATEST(
                    similarity(coalesce(p.nome_completo,   ''), v_q),
                    similarity(coalesce(p.email_principal, ''), v_q) * 0.7,
                    similarity(coalesce(p.telefone,        ''), v_q) * 0.5,
                    similarity(coalesce(p.cpf,             ''), v_q) * 0.6
                ) AS score
            FROM public.patients p
            WHERE p.nome_completo   ILIKE v_pattern
               OR p.email_principal ILIKE v_pattern
               OR p.telefone        ILIKE v_pattern
               OR p.cpf             ILIKE v_pattern
            ORDER BY score DESC, p.nome_completo ASC
            LIMIT v_limit
        )
        SELECT coalesce(jsonb_agg(jsonb_build_object(
            'id',         id,
            'label',      nome_completo,
            'sublabel',   coalesce(nullif(email_principal, ''), nullif(telefone, ''), ''),
            'avatar_url', avatar_url,
            'deeplink',   '/therapist/patients/cadastro/' || id::text,
            'score',      round(score::numeric, 3)
        )), '[]'::jsonb) INTO v_patients
        FROM ranked;
    END IF;

    -- ─────────────────────────────────────────────────────────────────────
    -- Agendamentos (com nome do paciente via join)
    -- ─────────────────────────────────────────────────────────────────────
    IF p_scope IS NULL OR 'appointments' = ANY(p_scope) THEN
        WITH ranked AS (
            SELECT
                e.id,
                coalesce(nullif(e.titulo_custom, ''), nullif(e.titulo, ''), 'Sessão') AS label,
                e.inicio_em,
                pat.nome_completo AS patient_name,
                GREATEST(
                    similarity(coalesce(e.titulo,        ''), v_q),
                    similarity(coalesce(e.titulo_custom, ''), v_q),
                    similarity(coalesce(pat.nome_completo, ''), v_q) * 0.9
                ) AS score
            FROM public.agenda_eventos e
            LEFT JOIN public.patients pat ON pat.id = e.patient_id
            WHERE e.titulo          ILIKE v_pattern
               OR e.titulo_custom   ILIKE v_pattern
               OR pat.nome_completo ILIKE v_pattern
            ORDER BY score DESC, e.inicio_em DESC
            LIMIT v_limit
        )
        SELECT coalesce(jsonb_agg(jsonb_build_object(
            'id',       id,
            'label',    label,
            'sublabel', trim(both ' · ' from
                            coalesce(patient_name, '') || ' · '
                            || to_char(inicio_em, 'DD/MM/YYYY HH24:MI')),
            'deeplink', '/therapist/agenda?event=' || id::text,
            'score',    round(score::numeric, 3)
        )), '[]'::jsonb) INTO v_appointments
        FROM ranked;
    END IF;

    -- ─────────────────────────────────────────────────────────────────────
    -- Documentos
    -- ─────────────────────────────────────────────────────────────────────
    IF p_scope IS NULL OR 'documents' = ANY(p_scope) THEN
        WITH ranked AS (
            SELECT
                d.id,
                d.patient_id,
                d.nome_original,
                d.tipo_documento,
                pat.nome_completo AS patient_name,
                GREATEST(
                    similarity(coalesce(d.nome_original, ''), v_q),
                    similarity(coalesce(d.descricao,     ''), v_q) * 0.7
                ) AS score
            FROM public.documents d
            LEFT JOIN public.patients pat ON pat.id = d.patient_id
            WHERE d.nome_original ILIKE v_pattern
               OR d.descricao     ILIKE v_pattern
            ORDER BY score DESC, d.nome_original ASC
            LIMIT v_limit
        )
        SELECT coalesce(jsonb_agg(jsonb_build_object(
            'id',       id,
            'label',    nome_original,
            'sublabel', trim(both ' · ' from
                            coalesce(patient_name, '') || ' · '
                            || coalesce(tipo_documento, '')),
            'deeplink', '/therapist/patients/' || patient_id::text || '/documents',
            'score',    round(score::numeric, 3)
        )), '[]'::jsonb) INTO v_documents
        FROM ranked;
    END IF;

    -- ─────────────────────────────────────────────────────────────────────
    -- Serviços (ativos)
    -- ─────────────────────────────────────────────────────────────────────
    IF p_scope IS NULL OR 'services' = ANY(p_scope) THEN
        WITH ranked AS (
            SELECT
                s.id,
                s.name,
                s.price,
                s.duration_min,
                GREATEST(
                    similarity(coalesce(s.name,        ''), v_q),
                    similarity(coalesce(s.description, ''), v_q) * 0.7
                ) AS score
            FROM public.services s
            WHERE s.active IS TRUE
              AND (s.name        ILIKE v_pattern
                OR s.description ILIKE v_pattern)
            ORDER BY score DESC, s.name ASC
            LIMIT v_limit
        )
        SELECT coalesce(jsonb_agg(jsonb_build_object(
            'id',       id,
            'label',    name,
            'sublabel', trim(both ' · ' from
                            'R$ ' || to_char(price, 'FM999G999G990D00') || ' · '
                            || coalesce(duration_min::text || ' min', '')),
            'deeplink', '/configuracoes/precificacao',
            'score',    round(score::numeric, 3)
        )), '[]'::jsonb) INTO v_services
        FROM ranked;
    END IF;

    -- ─────────────────────────────────────────────────────────────────────
    -- Intakes pendentes (patient_intake_requests com status='new')
    -- ─────────────────────────────────────────────────────────────────────
    IF p_scope IS NULL OR 'intakes' = ANY(p_scope) THEN
        WITH ranked AS (
            SELECT
                r.id,
                r.nome_completo,
                r.email_principal,
                r.telefone,
                r.created_at,
                GREATEST(
                    similarity(coalesce(r.nome_completo,   ''), v_q),
                    similarity(coalesce(r.email_principal, ''), v_q) * 0.7,
                    similarity(coalesce(r.telefone,        ''), v_q) * 0.5
                ) AS score
            FROM public.patient_intake_requests r
            WHERE r.status = 'new'
              AND (r.nome_completo   ILIKE v_pattern
                OR r.email_principal ILIKE v_pattern
                OR r.telefone        ILIKE v_pattern)
            ORDER BY score DESC, r.created_at DESC
            LIMIT v_limit
        )
        SELECT coalesce(jsonb_agg(jsonb_build_object(
            'id',       id,
            'label',    coalesce(nullif(trim(nome_completo), ''), '(sem nome)'),
            'sublabel', trim(both ' · ' from
                            coalesce(nullif(email_principal, ''), nullif(telefone, ''), '') || ' · '
                            || 'recebido ' || to_char(created_at, 'DD/MM/YYYY')),
            'deeplink', '/therapist/patients/cadastro/recebidos?id=' || id::text,
            'score',    round(score::numeric, 3)
        )), '[]'::jsonb) INTO v_intakes
        FROM ranked;
    END IF;

    RETURN jsonb_build_object(
        'patients',     v_patients,
        'appointments', v_appointments,
        'documents',    v_documents,
        'services',     v_services,
        'intakes',      v_intakes
    );
END;
$_$;

CREATE FUNCTION public.seed_default_financial_categories(p_user_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
  INSERT INTO public.financial_categories (user_id, name, type, color, icon, sort_order)
  VALUES
    (p_user_id, 'Sess??o',             'receita', '#22c55e', 'pi pi-heart',                    1),
    (p_user_id, 'Supervis??o',         'receita', '#6366f1', 'pi pi-users',                    2),
    (p_user_id, 'Conv??nio',           'receita', '#3b82f6', 'pi pi-building',                 3),
    (p_user_id, 'Grupo terap??utico',  'receita', '#f59e0b', 'pi pi-sitemap',                  4),
    (p_user_id, 'Outro (receita)',    'receita', '#8b5cf6', 'pi pi-plus-circle',              5),
    (p_user_id, 'Aluguel sala',       'despesa', '#ef4444', 'pi pi-home',                     1),
    (p_user_id, 'Plataforma/SaaS',    'despesa', '#f97316', 'pi pi-desktop',                  2),
    (p_user_id, 'Repasse cl??nica',    'despesa', '#64748b', 'pi pi-arrow-right-arrow-left',   3),
    (p_user_id, 'Supervis??o (custo)', 'despesa', '#6366f1', 'pi pi-users',                    4),
    (p_user_id, 'Outro (despesa)',    'despesa', '#94a3b8', 'pi pi-minus-circle',             5)
  ON CONFLICT DO NOTHING;
END;
$$;

CREATE FUNCTION public.seed_default_patient_groups(p_tenant_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_owner_id uuid;
BEGIN
  -- busca o owner (tenant_admin) do tenant
  SELECT user_id INTO v_owner_id
  FROM public.tenant_members
  WHERE tenant_id = p_tenant_id
    AND role = 'tenant_admin'
    AND status = 'active'
  LIMIT 1;

  IF v_owner_id IS NULL THEN
    RETURN;
  END IF;

  INSERT INTO public.patient_groups (owner_id, nome, cor, is_system, tenant_id)
  VALUES
    (v_owner_id, 'Crian??as',     '#60a5fa', true, p_tenant_id),
    (v_owner_id, 'Adolescentes', '#a78bfa', true, p_tenant_id),
    (v_owner_id, 'Idosos',       '#34d399', true, p_tenant_id)
  ON CONFLICT (owner_id, nome) DO NOTHING;
END;
$$;

CREATE FUNCTION public.seed_determined_commitments(p_tenant_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_id uuid;
begin
  -- Sessão (locked + sempre ativa)
  if not exists (
    select 1 from public.determined_commitments
    where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
  ) then
    insert into public.determined_commitments
      (tenant_id, is_native, native_key, is_locked, active, name, description)
    values
      (p_tenant_id, true, 'session', true, true, 'Sessão', 'Sessão com paciente');
  end if;

  -- Leitura
  if not exists (
    select 1 from public.determined_commitments
    where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
  ) then
    insert into public.determined_commitments
      (tenant_id, is_native, native_key, is_locked, active, name, description)
    values
      (p_tenant_id, true, 'reading', false, true, 'Leitura', 'Praticar leitura');
  end if;

  -- Supervisão
  if not exists (
    select 1 from public.determined_commitments
    where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
  ) then
    insert into public.determined_commitments
      (tenant_id, is_native, native_key, is_locked, active, name, description)
    values
      (p_tenant_id, true, 'supervision', false, true, 'Supervisão', 'Supervisão');
  end if;

  -- Aula
  if not exists (
    select 1 from public.determined_commitments
    where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
  ) then
    insert into public.determined_commitments
      (tenant_id, is_native, native_key, is_locked, active, name, description)
    values
      (p_tenant_id, true, 'class', false, false, 'Aula', 'Dar aula');
  end if;

  -- Análise pessoal
  if not exists (
    select 1 from public.determined_commitments
    where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
  ) then
    insert into public.determined_commitments
      (tenant_id, is_native, native_key, is_locked, active, name, description)
    values
      (p_tenant_id, true, 'analysis', false, true, 'Análise Pessoal', 'Minha análise pessoal');
  end if;

  -- -------------------------------------------------------
  -- Campos padrão (idempotentes por (commitment_id, key))
  -- -------------------------------------------------------

  -- Sessão (NOVO em 2026-05-11: 'notes' como Observação default)
  select id into v_id
  from public.determined_commitments
  where tenant_id = p_tenant_id and is_native = true and native_key = 'session'
  limit 1;

  if v_id is not null then
    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
    end if;
  end if;

  -- Leitura
  select id into v_id
  from public.determined_commitments
  where tenant_id = p_tenant_id and is_native = true and native_key = 'reading'
  limit 1;

  if v_id is not null then
    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'book') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'book', 'Livro', 'text', false, 10);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'author') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'author', 'Autor', 'text', false, 20);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
    end if;
  end if;

  -- Supervisão
  select id into v_id
  from public.determined_commitments
  where tenant_id = p_tenant_id and is_native = true and native_key = 'supervision'
  limit 1;

  if v_id is not null then
    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'supervisor') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'supervisor', 'Supervisor', 'text', false, 10);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'topic') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'topic', 'Assunto', 'text', false, 20);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
    end if;
  end if;

  -- Aula
  select id into v_id
  from public.determined_commitments
  where tenant_id = p_tenant_id and is_native = true and native_key = 'class'
  limit 1;

  if v_id is not null then
    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'theme') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'theme', 'Tema', 'text', false, 10);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'group') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'group', 'Turma', 'text', false, 20);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
    end if;
  end if;

  -- Análise
  select id into v_id
  from public.determined_commitments
  where tenant_id = p_tenant_id and is_native = true and native_key = 'analysis'
  limit 1;

  if v_id is not null then
    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'analyst') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'analyst', 'Analista', 'text', false, 10);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'focus') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'focus', 'Foco', 'text', false, 20);
    end if;

    if not exists (select 1 from public.determined_commitment_fields where commitment_id = v_id and key = 'notes') then
      insert into public.determined_commitment_fields (tenant_id, commitment_id, key, label, field_type, required, sort_order)
      values (p_tenant_id, v_id, 'notes', 'Observação', 'textarea', false, 30);
    end if;
  end if;
end;
$$;

CREATE FUNCTION public.set_insurance_plans_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;

CREATE FUNCTION public.set_medicos_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$;

CREATE FUNCTION public.set_owner_id() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
  if new.owner_id is null then
    new.owner_id := auth.uid();
  end if;
  return new;
end;
$$;

CREATE FUNCTION public.set_services_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.set_tenant_feature_exception(p_tenant_id uuid, p_feature_key text, p_enabled boolean, p_reason text DEFAULT NULL::text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $_$
DECLARE
  v_caller         uuid := auth.uid();
  v_is_saas        boolean := public.is_saas_admin();
  v_is_tenant_adm  boolean;
  v_plan_allows    boolean;
  v_feature_key    text;
  v_reason         text;
  v_is_exception   boolean;
BEGIN
  -- ───────────────────────────────────────────────────────────────────────
  -- Sanitização (padrão V#31)
  -- ───────────────────────────────────────────────────────────────────────
  IF v_caller IS NULL THEN
    RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
  END IF;

  IF p_tenant_id IS NULL THEN
    RAISE EXCEPTION 'tenant_id obrigatório' USING ERRCODE = '22023';
  END IF;

  IF p_enabled IS NULL THEN
    RAISE EXCEPTION 'enabled obrigatório' USING ERRCODE = '22023';
  END IF;

  v_feature_key := nullif(btrim(coalesce(p_feature_key, '')), '');
  IF v_feature_key IS NULL THEN
    RAISE EXCEPTION 'feature_key obrigatório' USING ERRCODE = '22023';
  END IF;
  IF length(v_feature_key) > 80 THEN
    RAISE EXCEPTION 'feature_key inválido (>80)' USING ERRCODE = '22023';
  END IF;
  IF v_feature_key !~ '^[a-z][a-z0-9_.]*$' THEN
    RAISE EXCEPTION 'feature_key formato inválido' USING ERRCODE = '22023';
  END IF;

  v_reason := nullif(btrim(coalesce(p_reason, '')), '');
  IF v_reason IS NOT NULL AND length(v_reason) > 500 THEN
    v_reason := substring(v_reason FROM 1 FOR 500);
  END IF;

  IF NOT EXISTS (SELECT 1 FROM public.features WHERE key = v_feature_key) THEN
    RAISE EXCEPTION 'feature_key desconhecida: %', v_feature_key USING ERRCODE = '22023';
  END IF;

  IF NOT EXISTS (SELECT 1 FROM public.tenants WHERE id = p_tenant_id) THEN
    RAISE EXCEPTION 'tenant não encontrado' USING ERRCODE = '22023';
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- Plano permite essa feature?
  -- ───────────────────────────────────────────────────────────────────────
  SELECT EXISTS (
    SELECT 1
    FROM public.v_tenant_entitlements vte
    WHERE vte.tenant_id = p_tenant_id
      AND vte.feature_key = v_feature_key
  ) INTO v_plan_allows;

  v_is_exception := (p_enabled = true AND NOT v_plan_allows);

  -- ───────────────────────────────────────────────────────────────────────
  -- Caller é tenant_admin desse tenant?
  -- ───────────────────────────────────────────────────────────────────────
  v_is_tenant_adm := EXISTS (
    SELECT 1 FROM public.tenant_members tm
    WHERE tm.tenant_id = p_tenant_id
      AND tm.user_id   = v_caller
      AND tm.status    = 'active'
      AND tm.role IN ('tenant_admin','admin','owner')
  );

  -- ───────────────────────────────────────────────────────────────────────
  -- Autorização (assimétrica — V#34 Opção B2)
  -- ───────────────────────────────────────────────────────────────────────
  IF v_is_exception THEN
    -- Override positivo fora do plano = exceção comercial
    IF NOT v_is_saas THEN
      RAISE EXCEPTION 'Apenas saas_admin pode liberar feature fora do plano' USING ERRCODE = '42501';
    END IF;
    IF v_reason IS NULL THEN
      RAISE EXCEPTION 'reason obrigatório para exceção comercial' USING ERRCODE = '22023';
    END IF;
  ELSE
    -- Demais casos: tenant_admin OR saas_admin
    IF NOT (v_is_saas OR v_is_tenant_adm) THEN
      RAISE EXCEPTION 'Sem permissão para alterar features deste tenant' USING ERRCODE = '42501';
    END IF;
  END IF;

  -- ───────────────────────────────────────────────────────────────────────
  -- Persistência: bypass controlado do trigger guard quando é exceção
  -- (escopo de transação via SET LOCAL — só esta RPC vê)
  -- ───────────────────────────────────────────────────────────────────────
  IF v_is_exception THEN
    PERFORM set_config('app.allow_feature_exception', 'true', true);
  END IF;

  INSERT INTO public.tenant_features (tenant_id, feature_key, enabled, updated_at)
  VALUES (p_tenant_id, v_feature_key, p_enabled, now())
  ON CONFLICT (tenant_id, feature_key)
  DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = now();

  -- Restaura flag (defensivo — SET LOCAL já é por transação, mas explicito)
  IF v_is_exception THEN
    PERFORM set_config('app.allow_feature_exception', 'false', true);
  END IF;

  INSERT INTO public.tenant_feature_exceptions_log
    (tenant_id, feature_key, enabled, reason, created_by)
  VALUES
    (p_tenant_id, v_feature_key, p_enabled, v_reason, v_caller);

  RETURN jsonb_build_object(
    'tenant_id',    p_tenant_id,
    'feature_key',  v_feature_key,
    'enabled',      p_enabled,
    'plan_allows',  v_plan_allows,
    'is_exception', v_is_exception,
    'reason',       v_reason
  );
END;
$_$;

CREATE FUNCTION public.set_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.set_updated_at_recurrence() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$;

CREATE FUNCTION public.sla_mark_notified(p_breach_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    UPDATE public.conversation_sla_breaches
        SET notified_at = now(),
            notification_count = notification_count + 1
        WHERE id = p_breach_id;
END;
$$;

CREATE FUNCTION public.sla_open_breach(p_tenant_id uuid, p_thread_key text, p_assigned_to uuid, p_last_inbound_at timestamp with time zone, p_threshold_minutes integer) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_existing_id UUID;
    v_new_id UUID;
BEGIN
    IF p_tenant_id IS NULL OR p_thread_key IS NULL THEN
        RAISE EXCEPTION 'tenant_and_thread_required';
    END IF;

    -- Ja tem aberto? Retorna o mesmo id (idempotente)
    SELECT id INTO v_existing_id
        FROM public.conversation_sla_breaches
        WHERE tenant_id = p_tenant_id
          AND thread_key = p_thread_key
          AND resolved_at IS NULL;

    IF FOUND THEN
        UPDATE public.conversation_sla_breaches
            SET assigned_to = COALESCE(p_assigned_to, assigned_to),
                last_inbound_at = COALESCE(p_last_inbound_at, last_inbound_at)
            WHERE id = v_existing_id;
        RETURN v_existing_id;
    END IF;

    INSERT INTO public.conversation_sla_breaches
        (tenant_id, thread_key, assigned_to, last_inbound_at, threshold_minutes_at_breach)
    VALUES
        (p_tenant_id, p_thread_key, p_assigned_to, p_last_inbound_at, p_threshold_minutes)
    RETURNING id INTO v_new_id;

    RETURN v_new_id;
END;
$$;

CREATE FUNCTION public.split_recurrence_at(p_recurrence_id uuid, p_from_date date) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_old   public.recurrence_rules;
  v_new_id uuid;
BEGIN
  -- busca a regra original
  SELECT * INTO v_old
  FROM public.recurrence_rules
  WHERE id = p_recurrence_id;

  IF NOT FOUND THEN
    RAISE EXCEPTION 'recurrence_rule % n??o encontrada', p_recurrence_id;
  END IF;

  -- encerra a regra antiga na data anterior
  UPDATE public.recurrence_rules
  SET
    end_date   = p_from_date - INTERVAL '1 day',
    open_ended = false,
    updated_at = now()
  WHERE id = p_recurrence_id;

  -- cria nova regra a partir de p_from_date
  INSERT INTO public.recurrence_rules (
    tenant_id, owner_id, therapist_id, patient_id,
    determined_commitment_id, type, interval, weekdays,
    start_time, end_time, timezone, duration_min,
    start_date, end_date, max_occurrences, open_ended,
    modalidade, titulo_custom, observacoes, extra_fields, status
  )
  SELECT
    tenant_id, owner_id, therapist_id, patient_id,
    determined_commitment_id, type, interval, weekdays,
    start_time, end_time, timezone, duration_min,
    p_from_date, v_old.end_date, v_old.max_occurrences, v_old.open_ended,
    modalidade, titulo_custom, observacoes, extra_fields, status
  FROM public.recurrence_rules
  WHERE id = p_recurrence_id
  RETURNING id INTO v_new_id;

  RETURN v_new_id;
END;
$$;

CREATE FUNCTION public.subscription_intents_view_insert() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_target text;
  v_plan_id uuid;
begin
  select p.id, p.target into v_plan_id, v_target
  from public.plans p
  where p.key = new.plan_key;

  if v_plan_id is null then
    raise exception 'Plano inv??lido: plan_key=%', new.plan_key;
  end if;

  if lower(v_target) = 'clinic' then
    if new.tenant_id is null then
      raise exception 'Inten????o clinic exige tenant_id.';
    end if;

    insert into public.subscription_intents_tenant (
      id, tenant_id, created_by_user_id, email,
      plan_id, plan_key, interval, amount_cents, currency,
      status, source, notes, created_at, paid_at
    ) values (
      coalesce(new.id, gen_random_uuid()),
      new.tenant_id, new.created_by_user_id, new.email,
      v_plan_id, new.plan_key, coalesce(new.interval,'month'),
      new.amount_cents, coalesce(new.currency,'BRL'),
      coalesce(new.status,'pending'), coalesce(new.source,'manual'),
      new.notes, coalesce(new.created_at, now()), new.paid_at
    );

    new.plan_target := 'clinic';
    return new;
  end if;

  -- therapist ou supervisor ??? tabela personal
  if lower(v_target) in ('therapist', 'supervisor') then
    insert into public.subscription_intents_personal (
      id, user_id, created_by_user_id, email,
      plan_id, plan_key, interval, amount_cents, currency,
      status, source, notes, created_at, paid_at
    ) values (
      coalesce(new.id, gen_random_uuid()),
      new.user_id, new.created_by_user_id, new.email,
      v_plan_id, new.plan_key, coalesce(new.interval,'month'),
      new.amount_cents, coalesce(new.currency,'BRL'),
      coalesce(new.status,'pending'), coalesce(new.source,'manual'),
      new.notes, coalesce(new.created_at, now()), new.paid_at
    );

    new.plan_target := lower(v_target); -- 'therapist' ou 'supervisor'
    return new;
  end if;

  raise exception 'Target de plano n??o suportado: %', v_target;
end;
$$;

CREATE FUNCTION public.subscriptions_validate_scope() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  v_target text;
BEGIN
  SELECT lower(p.target) INTO v_target
  FROM public.plans p
  WHERE p.id = NEW.plan_id;

  IF v_target IS NULL THEN
    RAISE EXCEPTION 'Plano inv??lido (target nulo).';
  END IF;

  IF v_target = 'clinic' THEN
    IF NEW.tenant_id IS NULL THEN
      RAISE EXCEPTION 'Assinatura clinic exige tenant_id.';
    END IF;
    IF NEW.user_id IS NOT NULL THEN
      RAISE EXCEPTION 'Assinatura clinic n??o pode ter user_id (XOR).';
    END IF;

  ELSIF v_target IN ('therapist', 'supervisor') THEN
    -- supervisor ?? pessoal como therapist
    IF NEW.tenant_id IS NOT NULL THEN
      RAISE EXCEPTION 'Assinatura % n??o deve ter tenant_id.', v_target;
    END IF;
    IF NEW.user_id IS NULL THEN
      RAISE EXCEPTION 'Assinatura % exige user_id.', v_target;
    END IF;

  ELSIF v_target = 'patient' THEN
    IF NEW.tenant_id IS NOT NULL THEN
      RAISE EXCEPTION 'Assinatura patient n??o deve ter tenant_id.';
    END IF;
    IF NEW.user_id IS NULL THEN
      RAISE EXCEPTION 'Assinatura patient exige user_id.';
    END IF;

  ELSE
    RAISE EXCEPTION 'Target de plano inv??lido: %', v_target;
  END IF;

  RETURN NEW;
END;
$$;

CREATE FUNCTION public.sync_busy_mirror_agenda_eventos() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
declare
  clinic_tenant uuid;
  is_personal boolean;
  should_mirror boolean;
begin
  -- Anti-recurs??o: espelho n??o espelha
  if (tg_op <> 'DELETE') then
    if new.mirror_of_event_id is not null then
      return new;
    end if;
  else
    if old.mirror_of_event_id is not null then
      return old;
    end if;
  end if;

  -- Define se ?? pessoal e se deve espelhar
  if (tg_op = 'DELETE') then
    is_personal := (old.tenant_id = old.owner_id);
    should_mirror := (old.visibility_scope in ('busy_only','private'));
  else
    is_personal := (new.tenant_id = new.owner_id);
    should_mirror := (new.visibility_scope in ('busy_only','private'));
  end if;

  -- Se n??o ?? pessoal, n??o faz nada
  if not is_personal then
    if (tg_op = 'DELETE') then
      return old;
    end if;
    return new;
  end if;

  -- DELETE: remove espelhos existentes
  if (tg_op = 'DELETE') then
    delete from public.agenda_eventos e
    where e.mirror_of_event_id = old.id
      and e.mirror_source = 'personal_busy_mirror';

    return old;
  end if;

  -- INSERT/UPDATE:
  -- Se n??o deve espelhar, remove espelhos e sai
  if not should_mirror then
    delete from public.agenda_eventos e
    where e.mirror_of_event_id = new.id
      and e.mirror_source = 'personal_busy_mirror';

    return new;
  end if;

  -- Para cada cl??nica onde o usu??rio ?? therapist active, cria/atualiza o "Ocupado"
  for clinic_tenant in
    select tm.tenant_id
    from public.tenant_members tm
    where tm.user_id = new.owner_id
      and tm.role = 'therapist'
      and tm.status = 'active'
      and tm.tenant_id <> new.owner_id
  loop
    insert into public.agenda_eventos (
      tenant_id,
      owner_id,
      terapeuta_id,
      paciente_id,
      tipo,
      status,
      titulo,
      observacoes,
      inicio_em,
      fim_em,
      mirror_of_event_id,
      mirror_source,
      visibility_scope,
      created_at,
      updated_at
    ) values (
      clinic_tenant,
      new.owner_id,
      new.owner_id,
      null,
      'bloqueio'::public.tipo_evento_agenda,
      'agendado'::public.status_evento_agenda,
      'Ocupado',
      null,
      new.inicio_em,
      new.fim_em,
      new.id,
      'personal_busy_mirror',
      'public',
      now(),
      now()
    )
    on conflict (tenant_id, mirror_of_event_id) where mirror_of_event_id is not null
    do update set
      owner_id = excluded.owner_id,
      terapeuta_id = excluded.terapeuta_id,
      tipo = excluded.tipo,
      status = excluded.status,
      titulo = excluded.titulo,
      observacoes = excluded.observacoes,
      inicio_em = excluded.inicio_em,
      fim_em = excluded.fim_em,
      updated_at = now();
  end loop;

  -- Limpa espelhos de cl??nicas onde o v??nculo therapist active n??o existe mais
  delete from public.agenda_eventos e
  where e.mirror_of_event_id = new.id
    and e.mirror_source = 'personal_busy_mirror'
    and not exists (
      select 1
      from public.tenant_members tm
      where tm.user_id = new.owner_id
        and tm.role = 'therapist'
        and tm.status = 'active'
        and tm.tenant_id = e.tenant_id
    );

  return new;
end;
$$;

CREATE FUNCTION public.sync_legacy_email_fields() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_entity_type TEXT;
    v_entity_id UUID;
    v_primary TEXT;
    v_secondary TEXT;
BEGIN
    IF TG_OP = 'DELETE' THEN
        v_entity_type := OLD.entity_type;
        v_entity_id := OLD.entity_id;
    ELSE
        v_entity_type := NEW.entity_type;
        v_entity_id := NEW.entity_id;
    END IF;

    SELECT email INTO v_primary
        FROM public.contact_emails
        WHERE entity_type = v_entity_type AND entity_id = v_entity_id
        ORDER BY is_primary DESC, position ASC, created_at ASC
        LIMIT 1;

    SELECT email INTO v_secondary
        FROM public.contact_emails
        WHERE entity_type = v_entity_type AND entity_id = v_entity_id
          AND is_primary = false
        ORDER BY position ASC, created_at ASC
        OFFSET 0
        LIMIT 1;

    IF v_entity_type = 'patient' THEN
        UPDATE public.patients
            SET email_principal = v_primary,
                email_alternativo = v_secondary
            WHERE id = v_entity_id;
    ELSIF v_entity_type = 'medico' THEN
        UPDATE public.medicos
            SET email = v_primary
            WHERE id = v_entity_id;
    END IF;

    IF TG_OP = 'DELETE' THEN RETURN OLD; ELSE RETURN NEW; END IF;
END;
$$;

CREATE FUNCTION public.sync_legacy_phone_fields() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_entity_type TEXT;
    v_entity_id UUID;
    v_primary TEXT;
    v_secondary TEXT;
    v_whatsapp_slug TEXT;
    v_whatsapp TEXT;
BEGIN
    -- Identifica entidade afetada (pode ser OLD em delete)
    IF TG_OP = 'DELETE' THEN
        v_entity_type := OLD.entity_type;
        v_entity_id := OLD.entity_id;
    ELSE
        v_entity_type := NEW.entity_type;
        v_entity_id := NEW.entity_id;
    END IF;

    -- Pega primary (ou primeiro)
    SELECT number INTO v_primary
        FROM public.contact_phones
        WHERE entity_type = v_entity_type AND entity_id = v_entity_id
        ORDER BY is_primary DESC, position ASC, created_at ASC
        LIMIT 1;

    -- Pega segundo (depois do primary)
    SELECT number INTO v_secondary
        FROM public.contact_phones
        WHERE entity_type = v_entity_type AND entity_id = v_entity_id
          AND is_primary = false
        ORDER BY position ASC, created_at ASC
        OFFSET 0
        LIMIT 1;

    -- Sincroniza campos legados
    IF v_entity_type = 'patient' THEN
        UPDATE public.patients
            SET telefone = v_primary,
                telefone_alternativo = v_secondary
            WHERE id = v_entity_id;
    ELSIF v_entity_type = 'medico' THEN
        -- Medicos: telefone_profissional = primary; telefone_pessoal = secundario
        UPDATE public.medicos
            SET telefone_profissional = v_primary,
                telefone_pessoal = v_secondary
            WHERE id = v_entity_id;
    END IF;

    IF TG_OP = 'DELETE' THEN
        RETURN OLD;
    ELSE
        RETURN NEW;
    END IF;
END;
$$;

CREATE FUNCTION public.sync_overdue_financial_records() RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_count integer;
BEGIN
  UPDATE public.financial_records
  SET
    status     = 'overdue',
    updated_at = NOW()
  WHERE status     = 'pending'
    AND due_date   IS NOT NULL
    AND due_date   < CURRENT_DATE
    AND deleted_at IS NULL;

  GET DIAGNOSTICS v_count = ROW_COUNT;
  RETURN v_count;
END;
$$;

CREATE FUNCTION public.tenant_accept_invite(p_token uuid) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'auth'
    AS $$
declare
  v_uid uuid;
  v_email text;
  v_invite public.tenant_invites%rowtype;
begin
  -- 1) precisa estar autenticado
  v_uid := auth.uid();
  if v_uid is null then
    raise exception 'not_authenticated' using errcode = 'P0001';
  end if;

  -- 2) pega email real do usu??rio logado sem depender do JWT claim
  select u.email
    into v_email
  from auth.users u
  where u.id = v_uid;

  if v_email is null or length(trim(v_email)) = 0 then
    raise exception 'missing_user_email' using errcode = 'P0001';
  end if;

  -- 3) carrega o invite e trava linha (evita 2 aceites concorrentes)
  select *
    into v_invite
  from public.tenant_invites i
  where i.token = p_token
  for update;

  if not found then
    raise exception 'invite_not_found' using errcode = 'P0001';
  end if;

  -- 4) valida????es de estado
  if v_invite.revoked_at is not null then
    raise exception 'invite_revoked' using errcode = 'P0001';
  end if;

  if v_invite.accepted_at is not null then
    raise exception 'invite_already_accepted' using errcode = 'P0001';
  end if;

  if v_invite.expires_at is not null and v_invite.expires_at <= now() then
    raise exception 'invite_expired' using errcode = 'P0001';
  end if;

  -- 5) valida email (case-insensitive)
  if lower(trim(v_invite.email)) <> lower(trim(v_email)) then
    raise exception 'email_mismatch' using errcode = 'P0001';
  end if;

  -- 6) consome o invite
  update public.tenant_invites
  set accepted_at = now(),
      accepted_by = v_uid
  where id = v_invite.id;

  -- 7) cria ou reativa o membership
  insert into public.tenant_members (tenant_id, user_id, role, status, created_at)
  values (v_invite.tenant_id, v_uid, v_invite.role, 'active', now())
  on conflict (tenant_id, user_id)
  do update set
    role = excluded.role,
    status = 'active';

  -- 8) retorno ??til pro front (voc?? j?? tenta ler tenant_id no AcceptInvitePage)
  return jsonb_build_object(
    'ok', true,
    'tenant_id', v_invite.tenant_id,
    'role', v_invite.role
  );
end;
$$;

CREATE FUNCTION public.tenant_add_member_by_email(p_tenant_id uuid, p_email text, p_role text DEFAULT 'therapist'::text) RETURNS public.tenant_members
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'auth'
    AS $$
declare
  v_target_uid uuid;
  v_member public.tenant_members%rowtype;
  v_is_admin boolean;
  v_email text;
begin
  if p_tenant_id is null then
    raise exception 'tenant_id ?? obrigat??rio';
  end if;

  v_email := lower(trim(coalesce(p_email, '')));
  if v_email = '' then
    raise exception 'email ?? obrigat??rio';
  end if;

  -- valida role permitida
  if p_role not in ('tenant_admin','therapist','secretary','patient') then
    raise exception 'role inv??lida: %', p_role;
  end if;

  -- apenas admin do tenant (role real no banco)
  select exists (
    select 1
    from public.tenant_members tm
    where tm.tenant_id = p_tenant_id
      and tm.user_id = auth.uid()
      and tm.role = 'tenant_admin'
      and coalesce(tm.status,'active') = 'active'
  ) into v_is_admin;

  if not v_is_admin then
    raise exception 'sem permiss??o: apenas admin da cl??nica pode adicionar membros';
  end if;

  -- acha usu??rio pelo e-mail no Supabase Auth
  select u.id
  into v_target_uid
  from auth.users u
  where lower(u.email) = v_email
  limit 1;

  if v_target_uid is null then
    raise exception 'nenhum usu??rio encontrado com este e-mail';
  end if;

  -- cria ou reativa membro
  insert into public.tenant_members (tenant_id, user_id, role, status)
  values (p_tenant_id, v_target_uid, p_role, 'active')
  on conflict (tenant_id, user_id)
  do update set
    role = excluded.role,
    status = 'active'
  returning * into v_member;

  return v_member;
end;
$$;

CREATE FUNCTION public.tenant_feature_allowed(p_tenant_id uuid, p_feature_key text) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select exists (
    select 1
    from public.v_tenant_entitlements v
    where v.tenant_id = p_tenant_id
      and v.feature_key = p_feature_key
      and coalesce(v.allowed, false) = true
  );
$$;

CREATE FUNCTION public.tenant_feature_enabled(p_tenant_id uuid, p_feature_key text) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select coalesce(
    (select tf.enabled
     from public.tenant_features tf
     where tf.tenant_id = p_tenant_id and tf.feature_key = p_feature_key),
    false
  );
$$;

CREATE FUNCTION public.tenant_features_guard_with_plan() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
DECLARE
  v_allowed   boolean;
  v_bypass    text;
BEGIN
  -- Só valida quando está habilitando
  IF new.enabled IS DISTINCT FROM true THEN
    RETURN new;
  END IF;

  -- Bypass autorizado: setado pela RPC set_tenant_feature_exception
  -- após validar que o caller é saas_admin com reason.
  v_bypass := current_setting('app.allow_feature_exception', true);
  IF v_bypass = 'true' THEN
    RETURN new;
  END IF;

  -- Permitido pelo plano do tenant?
  SELECT EXISTS (
    SELECT 1
    FROM public.v_tenant_entitlements_full v
    WHERE v.tenant_id = new.tenant_id
      AND v.feature_key = new.feature_key
      AND v.allowed = true
  ) INTO v_allowed;

  IF NOT v_allowed THEN
    RAISE EXCEPTION 'Feature % não permitida pelo plano atual do tenant %.',
      new.feature_key, new.tenant_id
      USING ERRCODE = 'P0001';
  END IF;

  RETURN new;
END;
$$;

CREATE FUNCTION public.tenant_has_feature(_tenant_id uuid, _feature text) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select
    exists (
      select 1
      from public.v_tenant_entitlements e
      where e.tenant_id = _tenant_id
        and e.feature_key = _feature
        and e.allowed = true
    )
    or exists (
      select 1
      from public.tenant_features tf
      where tf.tenant_id = _tenant_id
        and tf.feature_key = _feature
        and tf.enabled = true
    );
$$;

CREATE FUNCTION public.tenant_invite_member_by_email(p_tenant_id uuid, p_email text, p_role text) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public', 'auth'
    AS $$
declare
  v_email text;
  v_my_email text;
  v_token uuid;
  v_updated int;
begin
  -- valida????es b??sicas
  if p_tenant_id is null then
    raise exception 'tenant_id inv??lido' using errcode = 'P0001';
  end if;

  v_email := lower(trim(coalesce(p_email, '')));
  if v_email = '' then
    raise exception 'Informe um email' using errcode = 'P0001';
  end if;

  -- role permitido (ajuste se quiser)
  if p_role is null or p_role not in ('therapist', 'secretary') then
    raise exception 'Role inv??lido (use therapist/secretary)' using errcode = 'P0001';
  end if;

  -- ??? bloqueio: auto-convite
  v_my_email := public.get_my_email();
  if v_my_email is not null and v_email = v_my_email then
    raise exception 'Voc?? n??o pode convidar o seu pr??prio email.' using errcode = 'P0001';
  end if;

  -- ??? bloqueio: j?? ?? membro ativo do tenant
  if exists (
    select 1
    from tenant_members tm
    join auth.users au on au.id = tm.user_id
    where tm.tenant_id = p_tenant_id
      and tm.status = 'active'
      and lower(au.email) = v_email
  ) then
    raise exception 'Este email j?? est?? vinculado a esta cl??nica.' using errcode = 'P0001';
  end if;

  -- ??? permiss??o: s?? admin do tenant pode convidar
  if not exists (
    select 1
    from tenant_members me
    where me.tenant_id = p_tenant_id
      and me.user_id = auth.uid()
      and me.status = 'active'
      and me.role in ('tenant_admin','clinic_admin')
  ) then
    raise exception 'Sem permiss??o para convidar membros.' using errcode = 'P0001';
  end if;

  -- Gera token (reenvio simples / regenera????o)
  v_token := gen_random_uuid();

  -- 1) tenta "regerar" um convite pendente existente (mesmo email)
  update tenant_invites
     set token = v_token,
         role = p_role,
         created_at = now(),
         expires_at = now() + interval '7 days',
         accepted_at = null,
         revoked_at = null
   where tenant_id = p_tenant_id
     and lower(email) = v_email
     and accepted_at is null
     and revoked_at is null;

  get diagnostics v_updated = row_count;

  -- 2) se n??o atualizou nada, cria convite novo
  if v_updated = 0 then
    insert into tenant_invites (tenant_id, email, role, token, created_at, expires_at)
    values (p_tenant_id, v_email, p_role, v_token, now(), now() + interval '7 days');
  end if;

  return v_token;
end;
$$;

CREATE FUNCTION public.tenant_reactivate_member(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
begin
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  update public.tenant_members
     set status = 'active'
   where tenant_id = p_tenant_id
     and user_id = p_member_user_id;

  if not found then
    raise exception 'membership_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.tenant_remove_member(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
declare
  v_role text;
begin
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  if p_member_user_id = auth.uid() then
    raise exception 'cannot_remove_self';
  end if;

  -- pega role atual do membro (se n??o existir, erro)
  select role into v_role
  from public.tenant_members
  where tenant_id = p_tenant_id
    and user_id = p_member_user_id;

  if v_role is null then
    raise exception 'membership_not_found';
  end if;

  -- trava: se for therapist, n??o pode remover com eventos futuros
  if v_role = 'therapist' then
    if exists (
      select 1
      from public.agenda_eventos e
      where e.owner_id = p_tenant_id
        and e.terapeuta_id = p_member_user_id
        and e.inicio_em >= now()
        and e.status::text not in ('cancelado','cancelled','canceled')
      limit 1
    ) then
      raise exception 'cannot_remove_therapist_with_future_events';
    end if;
  end if;

  update public.tenant_members
     set status = 'inactive'
   where tenant_id = p_tenant_id
     and user_id = p_member_user_id;

  if not found then
    raise exception 'membership_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.tenant_remove_member_soft(p_tenant_id uuid, p_member_user_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
begin
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  if p_member_user_id = auth.uid() then
    raise exception 'cannot_remove_self';
  end if;

  update public.tenant_members
     set status = 'inactive'
   where tenant_id = p_tenant_id
     and user_id = p_member_user_id;

  if not found then
    raise exception 'membership_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.tenant_revoke_invite(p_tenant_id uuid, p_email text, p_role text) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
declare
  v_email text;
begin
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  v_email := lower(trim(p_email));

  update public.tenant_invites
     set revoked_at = now(),
         revoked_by = auth.uid()
   where tenant_id = p_tenant_id
     and lower(email) = v_email
     and role = p_role
     and accepted_at is null
     and revoked_at is null;

  if not found then
    raise exception 'invite_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.tenant_set_member_status(p_tenant_id uuid, p_member_user_id uuid, p_new_status text) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
begin
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  -- valida status (adapte aos seus valores reais)
  if p_new_status not in ('active','inactive','suspended','invited') then
    raise exception 'invalid_status: %', p_new_status;
  end if;

  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  -- evita desativar a si mesmo (opcional)
  if p_member_user_id = auth.uid() and p_new_status <> 'active' then
    raise exception 'cannot_disable_self';
  end if;

  update public.tenant_members
     set status = p_new_status
   where tenant_id = p_tenant_id
     and user_id = p_member_user_id;

  if not found then
    raise exception 'membership_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.tenant_update_member_role(p_tenant_id uuid, p_member_user_id uuid, p_new_role text) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    SET row_security TO 'off'
    AS $$
begin
  -- exige auth
  if auth.uid() is null then
    raise exception 'not_authenticated';
  end if;

  -- valida role
  if p_new_role not in ('tenant_admin','therapist','secretary','patient') then
    raise exception 'invalid_role: %', p_new_role;
  end if;

  -- somente tenant_admin ativo pode alterar role
  if not public.is_tenant_admin(p_tenant_id) then
    raise exception 'not_allowed';
  end if;

  -- evita o admin remover o pr??prio admin sem querer (opcional mas recomendado)
  if p_member_user_id = auth.uid() and p_new_role <> 'tenant_admin' then
    raise exception 'cannot_demote_self';
  end if;

  update public.tenant_members
     set role = p_new_role
   where tenant_id = p_tenant_id
     and user_id = p_member_user_id;

  if not found then
    raise exception 'membership_not_found';
  end if;
end;
$$;

CREATE FUNCTION public.toggle_plan(owner uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  current_key text;
  new_key text;
begin
  select p.key into current_key
  from subscriptions s
  join plans p on p.id = s.plan_id
  where s.owner_id = owner
    and s.status = 'active';

  new_key := case
    when current_key = 'pro' then 'free'
    else 'pro'
  end;

  update subscriptions s
  set plan_id = p.id
  from plans p
  where p.key = new_key
    and s.owner_id = owner
    and s.status = 'active';
end;
$$;

CREATE FUNCTION public.transition_subscription(p_subscription_id uuid, p_to_status text, p_reason text DEFAULT NULL::text, p_metadata jsonb DEFAULT NULL::jsonb) RETURNS public.subscriptions
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
declare
  v_sub public.subscriptions;
  v_uid uuid;
  v_is_allowed boolean := false;
begin
  v_uid := auth.uid();

  select *
    into v_sub
  from public.subscriptions
  where id = p_subscription_id;

  if not found then
    raise exception 'Assinatura n??o encontrada';
  end if;

  -- =====================================================
  -- ???? BLOCO DE AUTORIZA????O
  -- =====================================================

  -- 1) SaaS admin pode tudo
  if is_saas_admin() then
    v_is_allowed := true;
  end if;

  -- 2) Assinatura pessoal (therapist)
  if not v_is_allowed
     and v_sub.tenant_id is null
     and v_sub.user_id = v_uid then
    v_is_allowed := true;
  end if;

  -- 3) Assinatura de clinic (tenant)
  if not v_is_allowed
     and v_sub.tenant_id is not null then

    if exists (
      select 1
      from public.tenant_members tm
      where tm.tenant_id = v_sub.tenant_id
        and tm.user_id = v_uid
        and tm.status = 'active'
        and tm.role = 'tenant_admin'
    ) then
      v_is_allowed := true;
    end if;

  end if;

  if not v_is_allowed then
    raise exception 'Sem permiss??o para transicionar esta assinatura';
  end if;

  -- =====================================================
  -- ???? TRANSI????O
  -- =====================================================

  update public.subscriptions
  set status = p_to_status,
      updated_at = now(),
      cancelled_at = case when p_to_status = 'cancelled' then now() else cancelled_at end,
      suspended_at = case when p_to_status = 'suspended' then now() else suspended_at end,
      past_due_since = case when p_to_status = 'past_due' then now() else past_due_since end,
      expired_at = case when p_to_status = 'expired' then now() else expired_at end,
      activated_at = case when p_to_status = 'active' then now() else activated_at end
  where id = p_subscription_id
  returning * into v_sub;

  -- =====================================================
  -- ???? EVENT LOG
  -- =====================================================

  insert into public.subscription_events (
    subscription_id,
    owner_id,
    event_type,
    created_at,
    created_by,
    source,
    reason,
    metadata,
    owner_type,
    owner_ref
  )
  values (
    v_sub.id,
    coalesce(v_sub.tenant_id, v_sub.user_id),
    'status_changed',
    now(),
    v_uid,
    'manual_transition',
    p_reason,
    p_metadata,
    case when v_sub.tenant_id is not null then 'tenant' else 'personal' end,
    coalesce(v_sub.tenant_id, v_sub.user_id)
  );

  return v_sub;
end;
$$;

CREATE FUNCTION public.trg_fn_financial_records_auto_overdue() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
  IF NEW.status = 'pending'
    AND NEW.due_date IS NOT NULL
    AND NEW.due_date < CURRENT_DATE
  THEN
    NEW.status := 'overdue';
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.trg_fn_patient_risco_timeline() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  IF OLD.risco_elevado IS DISTINCT FROM NEW.risco_elevado THEN
    INSERT INTO public.patient_timeline (
      patient_id, tenant_id,
      evento_tipo, titulo, descricao, icone_cor,
      gerado_por, ocorrido_em
    ) VALUES (
      NEW.id, NEW.tenant_id,
      CASE WHEN NEW.risco_elevado THEN 'risco_sinalizado' ELSE 'risco_removido' END,
      CASE WHEN NEW.risco_elevado THEN 'Risco elevado sinalizado' ELSE 'Sinalização de risco removida' END,
      NEW.risco_nota,
      CASE WHEN NEW.risco_elevado THEN 'red' ELSE 'green' END,
      auth.uid(),
      now()
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.trg_fn_patient_status_history() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
    INSERT INTO public.patient_status_history (
      patient_id, tenant_id,
      status_anterior, status_novo,
      motivo, encaminhado_para, data_saida,
      alterado_por, alterado_em
    ) VALUES (
      NEW.id, NEW.tenant_id,
      CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.status END,
      NEW.status,
      NEW.motivo_saida,
      NEW.encaminhado_para,
      NEW.data_saida,
      auth.uid(),
      now()
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.trg_fn_patient_status_timeline() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
BEGIN
  IF (TG_OP = 'INSERT') OR (OLD.status IS DISTINCT FROM NEW.status) THEN
    INSERT INTO public.patient_timeline (
      patient_id, tenant_id,
      evento_tipo, titulo, descricao, icone_cor,
      gerado_por, ocorrido_em
    ) VALUES (
      NEW.id, NEW.tenant_id,
      'status_alterado',
      'Status alterado para ' || NEW.status,
      CASE
        WHEN TG_OP = 'INSERT' THEN 'Paciente cadastrado'
        ELSE 'De ' || OLD.status || ' → ' || NEW.status ||
             CASE WHEN NEW.motivo_saida IS NOT NULL THEN ' · ' || NEW.motivo_saida ELSE '' END
      END,
      CASE NEW.status
        WHEN 'Ativo'        THEN 'green'
        WHEN 'Alta'         THEN 'blue'
        WHEN 'Inativo'      THEN 'gray'
        WHEN 'Encaminhado'  THEN 'amber'
        WHEN 'Arquivado'    THEN 'gray'
        ELSE 'gray'
      END,
      auth.uid(),
      now()
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.trg_set_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$                                                                                                   BEGIN
    NEW.updated_at = now();
    RETURN NEW;
  END;
  $$;

CREATE FUNCTION public.unstick_notification_queue() RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    AS $$
DECLARE
  v_unstuck integer;
BEGIN
  UPDATE public.notification_queue
  SET status = 'pendente',
      attempts = attempts + 1,
      last_error = 'Timeout: preso em processando por >10min',
      next_retry_at = now() + interval '2 minutes'
  WHERE status = 'processando'
    AND updated_at < now() - interval '10 minutes';

  GET DIAGNOSTICS v_unstuck = ROW_COUNT;
  RETURN v_unstuck;
END;
$$;

CREATE FUNCTION public.update_payment_settings_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.update_professional_pricing_updated_at() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$;

CREATE FUNCTION public.update_twilio_config(p_account_sid text DEFAULT NULL::text, p_whatsapp_webhook_url text DEFAULT NULL::text, p_usd_brl_rate numeric DEFAULT NULL::numeric, p_margin_multiplier numeric DEFAULT NULL::numeric, p_notes text DEFAULT NULL::text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $_$
DECLARE
    v_caller         uuid := auth.uid();
    v_account_sid    text;
    v_webhook_url    text;
    v_notes          text;
BEGIN
    IF v_caller IS NULL THEN
        RAISE EXCEPTION 'Não autenticado' USING ERRCODE = '28000';
    END IF;
    IF NOT public.is_saas_admin() THEN
        RAISE EXCEPTION 'Apenas saas_admin pode atualizar config Twilio' USING ERRCODE = '42501';
    END IF;

    -- Sanitização
    v_account_sid := nullif(btrim(coalesce(p_account_sid, '')), '');
    v_webhook_url := nullif(btrim(coalesce(p_whatsapp_webhook_url, '')), '');
    v_notes       := nullif(btrim(coalesce(p_notes, '')), '');

    IF v_account_sid IS NOT NULL AND v_account_sid !~ '^AC[a-zA-Z0-9]{32}$' THEN
        RAISE EXCEPTION 'account_sid inválido (esperado AC + 32 chars)' USING ERRCODE = '22023';
    END IF;
    IF v_webhook_url IS NOT NULL AND v_webhook_url !~ '^https?://' THEN
        RAISE EXCEPTION 'webhook_url deve começar com http(s)://' USING ERRCODE = '22023';
    END IF;
    IF p_usd_brl_rate IS NOT NULL AND (p_usd_brl_rate <= 0 OR p_usd_brl_rate >= 100) THEN
        RAISE EXCEPTION 'usd_brl_rate fora da faixa (0..100)' USING ERRCODE = '22023';
    END IF;
    IF p_margin_multiplier IS NOT NULL AND (p_margin_multiplier < 1 OR p_margin_multiplier > 10) THEN
        RAISE EXCEPTION 'margin_multiplier fora da faixa (1..10)' USING ERRCODE = '22023';
    END IF;
    IF v_notes IS NOT NULL AND length(v_notes) > 1000 THEN
        v_notes := substring(v_notes FROM 1 FOR 1000);
    END IF;

    UPDATE saas_twilio_config
       SET account_sid          = COALESCE(v_account_sid, account_sid),
           whatsapp_webhook_url = COALESCE(v_webhook_url, whatsapp_webhook_url),
           usd_brl_rate         = COALESCE(p_usd_brl_rate, usd_brl_rate),
           margin_multiplier    = COALESCE(p_margin_multiplier, margin_multiplier),
           notes                = COALESCE(v_notes, notes),
           updated_at           = now(),
           updated_by           = v_caller
     WHERE id = true;

    RETURN public.get_twilio_config();
END;
$_$;

CREATE FUNCTION public.user_has_feature(_user_id uuid, _feature text) RETURNS boolean
    LANGUAGE sql STABLE
    AS $$
  select exists (
    select 1
    from public.v_user_entitlements e
    where e.user_id = _user_id
      and e.feature_key = _feature
      and e.allowed = true
  );
$$;

CREATE FUNCTION public.validate_share_token(p_token text) RETURNS jsonb
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    sl document_share_links%ROWTYPE;
    v_doc documents%ROWTYPE;
    v_token text;
BEGIN
    v_token := nullif(btrim(coalesce(p_token, '')), '');
    IF v_token IS NULL THEN
        RAISE EXCEPTION 'token obrigatório' USING ERRCODE = '22023';
    END IF;

    SELECT * INTO sl FROM document_share_links WHERE token = v_token LIMIT 1;
    IF NOT FOUND THEN
        RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000';
    END IF;
    IF sl.ativo IS NOT TRUE THEN
        RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000';
    END IF;
    IF sl.expira_em IS NOT NULL AND sl.expira_em < now() THEN
        RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000';
    END IF;
    IF sl.usos_max IS NOT NULL AND sl.usos >= sl.usos_max THEN
        RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000';
    END IF;

    UPDATE document_share_links SET usos = usos + 1 WHERE id = sl.id;

    BEGIN
        INSERT INTO document_access_logs (document_id, tenant_id, action, share_link_id)
        SELECT sl.document_id, d.tenant_id, 'shared_link_access', sl.id
        FROM documents d WHERE d.id = sl.document_id;
    EXCEPTION WHEN OTHERS THEN
        NULL;
    END;

    SELECT * INTO v_doc FROM documents WHERE id = sl.document_id;

    RETURN jsonb_build_object(
        'document_id',    sl.document_id,
        'bucket',         v_doc.storage_bucket,
        'bucket_path',    v_doc.bucket_path,
        'nome_original',  v_doc.nome_original,
        'mime_type',      v_doc.mime_type,
        'tamanho_bytes',  v_doc.tamanho_bytes
    );
END;
$$;

CREATE FUNCTION public.validate_support_session(p_token text) RETURNS json
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
  v_session support_sessions;
BEGIN
  IF p_token IS NULL OR length(trim(p_token)) < 32 THEN
    RETURN json_build_object('valid', false, 'tenant_id', null);
  END IF;

  SELECT * INTO v_session
    FROM public.support_sessions
   WHERE token = p_token
     AND expires_at > now()
   LIMIT 1;

  IF NOT FOUND THEN
    RETURN json_build_object('valid', false, 'tenant_id', null);
  END IF;

  RETURN json_build_object(
    'valid',     true,
    'tenant_id', v_session.tenant_id
  );
END;
$$;

CREATE FUNCTION public.verify_math_challenge(p_id uuid, p_answer integer) RETURNS boolean
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
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;
$$;

CREATE FUNCTION public.whatsapp_heartbeat_mark_notified(p_incident_id uuid) RETURNS void
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
BEGIN
    UPDATE public.whatsapp_connection_incidents
        SET notified_at = now(),
            notification_count = notification_count + 1
        WHERE id = p_incident_id;
END;
$$;

CREATE FUNCTION public.whatsapp_heartbeat_open_incident(p_channel_id uuid, p_kind text, p_last_state text DEFAULT NULL::text, p_details jsonb DEFAULT NULL::jsonb) RETURNS uuid
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_tenant_id UUID;
    v_provider TEXT;
    v_existing_id UUID;
    v_new_id UUID;
BEGIN
    -- Busca tenant/provider do channel
    SELECT tenant_id, provider INTO v_tenant_id, v_provider
        FROM public.notification_channels
        WHERE id = p_channel_id
          AND deleted_at IS NULL;

    IF NOT FOUND THEN
        RAISE EXCEPTION 'channel_not_found';
    END IF;

    IF p_kind NOT IN ('disconnected', 'error', 'qr_pending', 'connecting', 'unknown') THEN
        RAISE EXCEPTION 'invalid_kind: %', p_kind;
    END IF;

    -- Ja tem aberto? Retorna o mesmo id (idempotente)
    SELECT id INTO v_existing_id
        FROM public.whatsapp_connection_incidents
        WHERE channel_id = p_channel_id
          AND resolved_at IS NULL;

    IF FOUND THEN
        -- Atualiza o incident existente com detalhes frescos
        UPDATE public.whatsapp_connection_incidents
            SET last_state = COALESCE(p_last_state, last_state),
                details = COALESCE(p_details, details),
                kind = p_kind  -- pode mudar de qr_pending → disconnected, por ex
            WHERE id = v_existing_id;
        RETURN v_existing_id;
    END IF;

    -- Abre novo
    INSERT INTO public.whatsapp_connection_incidents
        (tenant_id, channel_id, provider, kind, last_state, details)
    VALUES
        (v_tenant_id, p_channel_id, v_provider, p_kind, p_last_state, p_details)
    RETURNING id INTO v_new_id;

    RETURN v_new_id;
END;
$$;

CREATE FUNCTION public.whatsapp_heartbeat_resolve_open_incidents(p_channel_id uuid) RETURNS integer
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'public'
    AS $$
DECLARE
    v_count INT := 0;
BEGIN
    UPDATE public.whatsapp_connection_incidents
        SET resolved_at = now(),
            duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::INT
        WHERE channel_id = p_channel_id
          AND resolved_at IS NULL;

    GET DIAGNOSTICS v_count = ROW_COUNT;
    RETURN v_count;
END;
$$;

CREATE FUNCTION public.whoami() RETURNS TABLE(uid uuid, role text)
    LANGUAGE sql STABLE
    AS $$
  select auth.uid() as uid, auth.role() as role;
$$;

CREATE FUNCTION realtime.apply_rls(wal jsonb, max_record_bytes integer DEFAULT (1024 * 1024)) RETURNS SETOF realtime.wal_rls
    LANGUAGE plpgsql
    AS $$
declare
-- Regclass of the table e.g. public.notes
entity_ regclass = (quote_ident(wal ->> 'schema') || '.' || quote_ident(wal ->> 'table'))::regclass;

-- I, U, D, T: insert, update ...
action realtime.action = (
    case wal ->> 'action'
        when 'I' then 'INSERT'
        when 'U' then 'UPDATE'
        when 'D' then 'DELETE'
        else 'ERROR'
    end
);

-- Is row level security enabled for the table
is_rls_enabled bool = relrowsecurity from pg_class where oid = entity_;

subscriptions realtime.subscription[] = array_agg(subs)
    from
        realtime.subscription subs
    where
        subs.entity = entity_;

-- Subscription vars
roles regrole[] = array_agg(distinct us.claims_role::text)
    from
        unnest(subscriptions) us;

working_role regrole;
claimed_role regrole;
claims jsonb;

subscription_id uuid;
subscription_has_access bool;
visible_to_subscription_ids uuid[] = '{}';

-- structured info for wal's columns
columns realtime.wal_column[];
-- previous identity values for update/delete
old_columns realtime.wal_column[];

error_record_exceeds_max_size boolean = octet_length(wal::text) > max_record_bytes;

-- Primary jsonb output for record
output jsonb;

begin
perform set_config('role', null, true);

columns =
    array_agg(
        (
            x->>'name',
            x->>'type',
            x->>'typeoid',
            realtime.cast(
                (x->'value') #>> '{}',
                coalesce(
                    (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4
                    (x->>'type')::regtype
                )
            ),
            (pks ->> 'name') is not null,
            true
        )::realtime.wal_column
    )
    from
        jsonb_array_elements(wal -> 'columns') x
        left join jsonb_array_elements(wal -> 'pk') pks
            on (x ->> 'name') = (pks ->> 'name');

old_columns =
    array_agg(
        (
            x->>'name',
            x->>'type',
            x->>'typeoid',
            realtime.cast(
                (x->'value') #>> '{}',
                coalesce(
                    (x->>'typeoid')::regtype, -- null when wal2json version <= 2.4
                    (x->>'type')::regtype
                )
            ),
            (pks ->> 'name') is not null,
            true
        )::realtime.wal_column
    )
    from
        jsonb_array_elements(wal -> 'identity') x
        left join jsonb_array_elements(wal -> 'pk') pks
            on (x ->> 'name') = (pks ->> 'name');

for working_role in select * from unnest(roles) loop

    -- Update `is_selectable` for columns and old_columns
    columns =
        array_agg(
            (
                c.name,
                c.type_name,
                c.type_oid,
                c.value,
                c.is_pkey,
                pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')
            )::realtime.wal_column
        )
        from
            unnest(columns) c;

    old_columns =
            array_agg(
                (
                    c.name,
                    c.type_name,
                    c.type_oid,
                    c.value,
                    c.is_pkey,
                    pg_catalog.has_column_privilege(working_role, entity_, c.name, 'SELECT')
                )::realtime.wal_column
            )
            from
                unnest(old_columns) c;

    if action <> 'DELETE' and count(1) = 0 from unnest(columns) c where c.is_pkey then
        return next (
            jsonb_build_object(
                'schema', wal ->> 'schema',
                'table', wal ->> 'table',
                'type', action
            ),
            is_rls_enabled,
            -- subscriptions is already filtered by entity
            (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role),
            array['Error 400: Bad Request, no primary key']
        )::realtime.wal_rls;

    -- The claims role does not have SELECT permission to the primary key of entity
    elsif action <> 'DELETE' and sum(c.is_selectable::int) <> count(1) from unnest(columns) c where c.is_pkey then
        return next (
            jsonb_build_object(
                'schema', wal ->> 'schema',
                'table', wal ->> 'table',
                'type', action
            ),
            is_rls_enabled,
            (select array_agg(s.subscription_id) from unnest(subscriptions) as s where claims_role = working_role),
            array['Error 401: Unauthorized']
        )::realtime.wal_rls;

    else
        output = jsonb_build_object(
            'schema', wal ->> 'schema',
            'table', wal ->> 'table',
            'type', action,
            'commit_timestamp', to_char(
                ((wal ->> 'timestamp')::timestamptz at time zone 'utc'),
                'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
            ),
            'columns', (
                select
                    jsonb_agg(
                        jsonb_build_object(
                            'name', pa.attname,
                            'type', pt.typname
                        )
                        order by pa.attnum asc
                    )
                from
                    pg_attribute pa
                    join pg_type pt
                        on pa.atttypid = pt.oid
                where
                    attrelid = entity_
                    and attnum > 0
                    and pg_catalog.has_column_privilege(working_role, entity_, pa.attname, 'SELECT')
            )
        )
        -- Add "record" key for insert and update
        || case
            when action in ('INSERT', 'UPDATE') then
                jsonb_build_object(
                    'record',
                    (
                        select
                            jsonb_object_agg(
                                -- if unchanged toast, get column name and value from old record
                                coalesce((c).name, (oc).name),
                                case
                                    when (c).name is null then (oc).value
                                    else (c).value
                                end
                            )
                        from
                            unnest(columns) c
                            full outer join unnest(old_columns) oc
                                on (c).name = (oc).name
                        where
                            coalesce((c).is_selectable, (oc).is_selectable)
                            and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
                    )
                )
            else '{}'::jsonb
        end
        -- Add "old_record" key for update and delete
        || case
            when action = 'UPDATE' then
                jsonb_build_object(
                        'old_record',
                        (
                            select jsonb_object_agg((c).name, (c).value)
                            from unnest(old_columns) c
                            where
                                (c).is_selectable
                                and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
                        )
                    )
            when action = 'DELETE' then
                jsonb_build_object(
                    'old_record',
                    (
                        select jsonb_object_agg((c).name, (c).value)
                        from unnest(old_columns) c
                        where
                            (c).is_selectable
                            and ( not error_record_exceeds_max_size or (octet_length((c).value::text) <= 64))
                            and ( not is_rls_enabled or (c).is_pkey ) -- if RLS enabled, we can't secure deletes so filter to pkey
                    )
                )
            else '{}'::jsonb
        end;

        -- Create the prepared statement
        if is_rls_enabled and action <> 'DELETE' then
            if (select 1 from pg_prepared_statements where name = 'walrus_rls_stmt' limit 1) > 0 then
                deallocate walrus_rls_stmt;
            end if;
            execute realtime.build_prepared_statement_sql('walrus_rls_stmt', entity_, columns);
        end if;

        visible_to_subscription_ids = '{}';

        for subscription_id, claims in (
                select
                    subs.subscription_id,
                    subs.claims
                from
                    unnest(subscriptions) subs
                where
                    subs.entity = entity_
                    and subs.claims_role = working_role
                    and (
                        realtime.is_visible_through_filters(columns, subs.filters)
                        or (
                          action = 'DELETE'
                          and realtime.is_visible_through_filters(old_columns, subs.filters)
                        )
                    )
        ) loop

            if not is_rls_enabled or action = 'DELETE' then
                visible_to_subscription_ids = visible_to_subscription_ids || subscription_id;
            else
                -- Check if RLS allows the role to see the record
                perform
                    -- Trim leading and trailing quotes from working_role because set_config
                    -- doesn't recognize the role as valid if they are included
                    set_config('role', trim(both '"' from working_role::text), true),
                    set_config('request.jwt.claims', claims::text, true);

                execute 'execute walrus_rls_stmt' into subscription_has_access;

                if subscription_has_access then
                    visible_to_subscription_ids = visible_to_subscription_ids || subscription_id;
                end if;
            end if;
        end loop;

        perform set_config('role', null, true);

        return next (
            output,
            is_rls_enabled,
            visible_to_subscription_ids,
            case
                when error_record_exceeds_max_size then array['Error 413: Payload Too Large']
                else '{}'
            end
        )::realtime.wal_rls;

    end if;
end loop;

perform set_config('role', null, true);
end;
$$;

CREATE FUNCTION realtime.broadcast_changes(topic_name text, event_name text, operation text, table_name text, table_schema text, new record, old record, level text DEFAULT 'ROW'::text) RETURNS void
    LANGUAGE plpgsql
    AS $$
DECLARE
    -- Declare a variable to hold the JSONB representation of the row
    row_data jsonb := '{}'::jsonb;
BEGIN
    IF level = 'STATEMENT' THEN
        RAISE EXCEPTION 'function can only be triggered for each row, not for each statement';
    END IF;
    -- Check the operation type and handle accordingly
    IF operation = 'INSERT' OR operation = 'UPDATE' OR operation = 'DELETE' THEN
        row_data := jsonb_build_object('old_record', OLD, 'record', NEW, 'operation', operation, 'table', table_name, 'schema', table_schema);
        PERFORM realtime.send (row_data, event_name, topic_name);
    ELSE
        RAISE EXCEPTION 'Unexpected operation type: %', operation;
    END IF;
EXCEPTION
    WHEN OTHERS THEN
        RAISE EXCEPTION 'Failed to process the row: %', SQLERRM;
END;

$$;

CREATE FUNCTION realtime.build_prepared_statement_sql(prepared_statement_name text, entity regclass, columns realtime.wal_column[]) RETURNS text
    LANGUAGE sql
    AS $$
      /*
      Builds a sql string that, if executed, creates a prepared statement to
      tests retrive a row from *entity* by its primary key columns.
      Example
          select realtime.build_prepared_statement_sql('public.notes', '{"id"}'::text[], '{"bigint"}'::text[])
      */
          select
      'prepare ' || prepared_statement_name || ' as
          select
              exists(
                  select
                      1
                  from
                      ' || entity || '
                  where
                      ' || string_agg(quote_ident(pkc.name) || '=' || quote_nullable(pkc.value #>> '{}') , ' and ') || '
              )'
          from
              unnest(columns) pkc
          where
              pkc.is_pkey
          group by
              entity
      $$;

CREATE FUNCTION realtime."cast"(val text, type_ regtype) RETURNS jsonb
    LANGUAGE plpgsql IMMUTABLE
    AS $$
    declare
      res jsonb;
    begin
      execute format('select to_jsonb(%L::'|| type_::text || ')', val)  into res;
      return res;
    end
    $$;

CREATE FUNCTION realtime.check_equality_op(op realtime.equality_op, type_ regtype, val_1 text, val_2 text) RETURNS boolean
    LANGUAGE plpgsql IMMUTABLE
    AS $$
      /*
      Casts *val_1* and *val_2* as type *type_* and check the *op* condition for truthiness
      */
      declare
          op_symbol text = (
              case
                  when op = 'eq' then '='
                  when op = 'neq' then '!='
                  when op = 'lt' then '<'
                  when op = 'lte' then '<='
                  when op = 'gt' then '>'
                  when op = 'gte' then '>='
                  when op = 'in' then '= any'
                  else 'UNKNOWN OP'
              end
          );
          res boolean;
      begin
          execute format(
              'select %L::'|| type_::text || ' ' || op_symbol
              || ' ( %L::'
              || (
                  case
                      when op = 'in' then type_::text || '[]'
                      else type_::text end
              )
              || ')', val_1, val_2) into res;
          return res;
      end;
      $$;

CREATE FUNCTION realtime.is_visible_through_filters(columns realtime.wal_column[], filters realtime.user_defined_filter[]) RETURNS boolean
    LANGUAGE sql IMMUTABLE
    AS $_$
    /*
    Should the record be visible (true) or filtered out (false) after *filters* are applied
    */
        select
            -- Default to allowed when no filters present
            $2 is null -- no filters. this should not happen because subscriptions has a default
            or array_length($2, 1) is null -- array length of an empty array is null
            or bool_and(
                coalesce(
                    realtime.check_equality_op(
                        op:=f.op,
                        type_:=coalesce(
                            col.type_oid::regtype, -- null when wal2json version <= 2.4
                            col.type_name::regtype
                        ),
                        -- cast jsonb to text
                        val_1:=col.value #>> '{}',
                        val_2:=f.value
                    ),
                    false -- if null, filter does not match
                )
            )
        from
            unnest(filters) f
            join unnest(columns) col
                on f.column_name = col.name;
    $_$;

CREATE FUNCTION realtime.list_changes(publication name, slot_name name, max_changes integer, max_record_bytes integer) RETURNS SETOF realtime.wal_rls
    LANGUAGE sql
    SET log_min_messages TO 'fatal'
    AS $$
      with pub as (
        select
          concat_ws(
            ',',
            case when bool_or(pubinsert) then 'insert' else null end,
            case when bool_or(pubupdate) then 'update' else null end,
            case when bool_or(pubdelete) then 'delete' else null end
          ) as w2j_actions,
          coalesce(
            string_agg(
              realtime.quote_wal2json(format('%I.%I', schemaname, tablename)::regclass),
              ','
            ) filter (where ppt.tablename is not null and ppt.tablename not like '% %'),
            ''
          ) w2j_add_tables
        from
          pg_publication pp
          left join pg_publication_tables ppt
            on pp.pubname = ppt.pubname
        where
          pp.pubname = publication
        group by
          pp.pubname
        limit 1
      ),
      w2j as (
        select
          x.*, pub.w2j_add_tables
        from
          pub,
          pg_logical_slot_get_changes(
            slot_name, null, max_changes,
            'include-pk', 'true',
            'include-transaction', 'false',
            'include-timestamp', 'true',
            'include-type-oids', 'true',
            'format-version', '2',
            'actions', pub.w2j_actions,
            'add-tables', pub.w2j_add_tables
          ) x
      )
      select
        xyz.wal,
        xyz.is_rls_enabled,
        xyz.subscription_ids,
        xyz.errors
      from
        w2j,
        realtime.apply_rls(
          wal := w2j.data::jsonb,
          max_record_bytes := max_record_bytes
        ) xyz(wal, is_rls_enabled, subscription_ids, errors)
      where
        w2j.w2j_add_tables <> ''
        and xyz.subscription_ids[1] is not null
    $$;

CREATE FUNCTION realtime.quote_wal2json(entity regclass) RETURNS text
    LANGUAGE sql IMMUTABLE STRICT
    AS $$
      select
        (
          select string_agg('' || ch,'')
          from unnest(string_to_array(nsp.nspname::text, null)) with ordinality x(ch, idx)
          where
            not (x.idx = 1 and x.ch = '"')
            and not (
              x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
              and x.ch = '"'
            )
        )
        || '.'
        || (
          select string_agg('' || ch,'')
          from unnest(string_to_array(pc.relname::text, null)) with ordinality x(ch, idx)
          where
            not (x.idx = 1 and x.ch = '"')
            and not (
              x.idx = array_length(string_to_array(nsp.nspname::text, null), 1)
              and x.ch = '"'
            )
          )
      from
        pg_class pc
        join pg_namespace nsp
          on pc.relnamespace = nsp.oid
      where
        pc.oid = entity
    $$;

CREATE FUNCTION realtime.send(payload jsonb, event text, topic text, private boolean DEFAULT true) RETURNS void
    LANGUAGE plpgsql
    AS $$
DECLARE
  generated_id uuid;
  final_payload jsonb;
BEGIN
  BEGIN
    -- Generate a new UUID for the id
    generated_id := gen_random_uuid();

    -- Check if payload has an 'id' key, if not, add the generated UUID
    IF payload ? 'id' THEN
      final_payload := payload;
    ELSE
      final_payload := jsonb_set(payload, '{id}', to_jsonb(generated_id));
    END IF;

    -- Set the topic configuration
    EXECUTE format('SET LOCAL realtime.topic TO %L', topic);

    -- Attempt to insert the message
    INSERT INTO realtime.messages (id, payload, event, topic, private, extension)
    VALUES (generated_id, final_payload, event, topic, private, 'broadcast');
  EXCEPTION
    WHEN OTHERS THEN
      -- Capture and notify the error
      RAISE WARNING 'ErrorSendingBroadcastMessage: %', SQLERRM;
  END;
END;
$$;

CREATE FUNCTION realtime.subscription_check_filters() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
    /*
    Validates that the user defined filters for a subscription:
    - refer to valid columns that the claimed role may access
    - values are coercable to the correct column type
    */
    declare
        col_names text[] = coalesce(
                array_agg(c.column_name order by c.ordinal_position),
                '{}'::text[]
            )
            from
                information_schema.columns c
            where
                format('%I.%I', c.table_schema, c.table_name)::regclass = new.entity
                and pg_catalog.has_column_privilege(
                    (new.claims ->> 'role'),
                    format('%I.%I', c.table_schema, c.table_name)::regclass,
                    c.column_name,
                    'SELECT'
                );
        filter realtime.user_defined_filter;
        col_type regtype;

        in_val jsonb;
    begin
        for filter in select * from unnest(new.filters) loop
            -- Filtered column is valid
            if not filter.column_name = any(col_names) then
                raise exception 'invalid column for filter %', filter.column_name;
            end if;

            -- Type is sanitized and safe for string interpolation
            col_type = (
                select atttypid::regtype
                from pg_catalog.pg_attribute
                where attrelid = new.entity
                      and attname = filter.column_name
            );
            if col_type is null then
                raise exception 'failed to lookup type for column %', filter.column_name;
            end if;

            -- Set maximum number of entries for in filter
            if filter.op = 'in'::realtime.equality_op then
                in_val = realtime.cast(filter.value, (col_type::text || '[]')::regtype);
                if coalesce(jsonb_array_length(in_val), 0) > 100 then
                    raise exception 'too many values for `in` filter. Maximum 100';
                end if;
            else
                -- raises an exception if value is not coercable to type
                perform realtime.cast(filter.value, col_type);
            end if;

        end loop;

        -- Apply consistent order to filters so the unique constraint on
        -- (subscription_id, entity, filters) can't be tricked by a different filter order
        new.filters = coalesce(
            array_agg(f order by f.column_name, f.op, f.value),
            '{}'
        ) from unnest(new.filters) f;

        return new;
    end;
    $$;

CREATE FUNCTION realtime.to_regrole(role_name text) RETURNS regrole
    LANGUAGE sql IMMUTABLE
    AS $$ select role_name::regrole $$;

CREATE FUNCTION realtime.topic() RETURNS text
    LANGUAGE sql STABLE
    AS $$
select nullif(current_setting('realtime.topic', true), '')::text;
$$;

CREATE FUNCTION storage.can_insert_object(bucketid text, name text, owner uuid, metadata jsonb) RETURNS void
    LANGUAGE plpgsql
    AS $$
BEGIN
  INSERT INTO "storage"."objects" ("bucket_id", "name", "owner", "metadata") VALUES (bucketid, name, owner, metadata);
  -- hack to rollback the successful insert
  RAISE sqlstate 'PT200' using
  message = 'ROLLBACK',
  detail = 'rollback successful insert';
END
$$;

CREATE FUNCTION storage.enforce_bucket_name_length() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
begin
    if length(new.name) > 100 then
        raise exception 'bucket name "%" is too long (% characters). Max is 100.', new.name, length(new.name);
    end if;
    return new;
end;
$$;

CREATE FUNCTION storage.extension(name text) RETURNS text
    LANGUAGE plpgsql
    AS $$
DECLARE
_parts text[];
_filename text;
BEGIN
	select string_to_array(name, '/') into _parts;
	select _parts[array_length(_parts,1)] into _filename;
	-- @todo return the last part instead of 2
	return reverse(split_part(reverse(_filename), '.', 1));
END
$$;

CREATE FUNCTION storage.filename(name text) RETURNS text
    LANGUAGE plpgsql
    AS $$
DECLARE
_parts text[];
BEGIN
	select string_to_array(name, '/') into _parts;
	return _parts[array_length(_parts,1)];
END
$$;

CREATE FUNCTION storage.foldername(name text) RETURNS text[]
    LANGUAGE plpgsql
    AS $$
DECLARE
_parts text[];
BEGIN
	select string_to_array(name, '/') into _parts;
	return _parts[1:array_length(_parts,1)-1];
END
$$;

CREATE FUNCTION storage.get_common_prefix(p_key text, p_prefix text, p_delimiter text) RETURNS text
    LANGUAGE sql IMMUTABLE
    AS $$
SELECT CASE
    WHEN position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)) > 0
    THEN left(p_key, length(p_prefix) + position(p_delimiter IN substring(p_key FROM length(p_prefix) + 1)))
    ELSE NULL
END;
$$;

CREATE FUNCTION storage.get_size_by_bucket() RETURNS TABLE(size bigint, bucket_id text)
    LANGUAGE plpgsql
    AS $$
BEGIN
    return query
        select sum((metadata->>'size')::int) as size, obj.bucket_id
        from "storage".objects as obj
        group by obj.bucket_id;
END
$$;

CREATE FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, next_key_token text DEFAULT ''::text, next_upload_token text DEFAULT ''::text) RETURNS TABLE(key text, id text, created_at timestamp with time zone)
    LANGUAGE plpgsql
    AS $_$
BEGIN
    RETURN QUERY EXECUTE
        'SELECT DISTINCT ON(key COLLATE "C") * from (
            SELECT
                CASE
                    WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
                        substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1)))
                    ELSE
                        key
                END AS key, id, created_at
            FROM
                storage.s3_multipart_uploads
            WHERE
                bucket_id = $5 AND
                key ILIKE $1 || ''%'' AND
                CASE
                    WHEN $4 != '''' AND $6 = '''' THEN
                        CASE
                            WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
                                substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4
                            ELSE
                                key COLLATE "C" > $4
                            END
                    ELSE
                        true
                END AND
                CASE
                    WHEN $6 != '''' THEN
                        id COLLATE "C" > $6
                    ELSE
                        true
                    END
            ORDER BY
                key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3'
        USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token;
END;
$_$;

CREATE FUNCTION storage.list_objects_with_delimiter(_bucket_id text, prefix_param text, delimiter_param text, max_keys integer DEFAULT 100, start_after text DEFAULT ''::text, next_token text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, metadata jsonb, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone)
    LANGUAGE plpgsql STABLE
    AS $_$
DECLARE
    v_peek_name TEXT;
    v_current RECORD;
    v_common_prefix TEXT;

    -- Configuration
    v_is_asc BOOLEAN;
    v_prefix TEXT;
    v_start TEXT;
    v_upper_bound TEXT;
    v_file_batch_size INT;

    -- Seek state
    v_next_seek TEXT;
    v_count INT := 0;

    -- Dynamic SQL for batch query only
    v_batch_query TEXT;

BEGIN
    -- ========================================================================
    -- INITIALIZATION
    -- ========================================================================
    v_is_asc := lower(coalesce(sort_order, 'asc')) = 'asc';
    v_prefix := coalesce(prefix_param, '');
    v_start := CASE WHEN coalesce(next_token, '') <> '' THEN next_token ELSE coalesce(start_after, '') END;
    v_file_batch_size := LEAST(GREATEST(max_keys * 2, 100), 1000);

    -- Calculate upper bound for prefix filtering (bytewise, using COLLATE "C")
    IF v_prefix = '' THEN
        v_upper_bound := NULL;
    ELSIF right(v_prefix, 1) = delimiter_param THEN
        v_upper_bound := left(v_prefix, -1) || chr(ascii(delimiter_param) + 1);
    ELSE
        v_upper_bound := left(v_prefix, -1) || chr(ascii(right(v_prefix, 1)) + 1);
    END IF;

    -- Build batch query (dynamic SQL - called infrequently, amortized over many rows)
    IF v_is_asc THEN
        IF v_upper_bound IS NOT NULL THEN
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' ||
                'AND o.name COLLATE "C" < $3 ORDER BY o.name COLLATE "C" ASC LIMIT $4';
        ELSE
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" >= $2 ' ||
                'ORDER BY o.name COLLATE "C" ASC LIMIT $4';
        END IF;
    ELSE
        IF v_upper_bound IS NOT NULL THEN
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' ||
                'AND o.name COLLATE "C" >= $3 ORDER BY o.name COLLATE "C" DESC LIMIT $4';
        ELSE
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND o.name COLLATE "C" < $2 ' ||
                'ORDER BY o.name COLLATE "C" DESC LIMIT $4';
        END IF;
    END IF;

    -- ========================================================================
    -- SEEK INITIALIZATION: Determine starting position
    -- ========================================================================
    IF v_start = '' THEN
        IF v_is_asc THEN
            v_next_seek := v_prefix;
        ELSE
            -- DESC without cursor: find the last item in range
            IF v_upper_bound IS NOT NULL THEN
                SELECT o.name INTO v_next_seek FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix AND o.name COLLATE "C" < v_upper_bound
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            ELSIF v_prefix <> '' THEN
                SELECT o.name INTO v_next_seek FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_prefix
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            ELSE
                SELECT o.name INTO v_next_seek FROM storage.objects o
                WHERE o.bucket_id = _bucket_id
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            END IF;

            IF v_next_seek IS NOT NULL THEN
                v_next_seek := v_next_seek || delimiter_param;
            ELSE
                RETURN;
            END IF;
        END IF;
    ELSE
        -- Cursor provided: determine if it refers to a folder or leaf
        IF EXISTS (
            SELECT 1 FROM storage.objects o
            WHERE o.bucket_id = _bucket_id
              AND o.name COLLATE "C" LIKE v_start || delimiter_param || '%'
            LIMIT 1
        ) THEN
            -- Cursor refers to a folder
            IF v_is_asc THEN
                v_next_seek := v_start || chr(ascii(delimiter_param) + 1);
            ELSE
                v_next_seek := v_start || delimiter_param;
            END IF;
        ELSE
            -- Cursor refers to a leaf object
            IF v_is_asc THEN
                v_next_seek := v_start || delimiter_param;
            ELSE
                v_next_seek := v_start;
            END IF;
        END IF;
    END IF;

    -- ========================================================================
    -- MAIN LOOP: Hybrid peek-then-batch algorithm
    -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch
    -- ========================================================================
    LOOP
        EXIT WHEN v_count >= max_keys;

        -- STEP 1: PEEK using STATIC SQL (plan cached, very fast)
        IF v_is_asc THEN
            IF v_upper_bound IS NOT NULL THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek AND o.name COLLATE "C" < v_upper_bound
                ORDER BY o.name COLLATE "C" ASC LIMIT 1;
            ELSE
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" >= v_next_seek
                ORDER BY o.name COLLATE "C" ASC LIMIT 1;
            END IF;
        ELSE
            IF v_upper_bound IS NOT NULL THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            ELSIF v_prefix <> '' THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek AND o.name COLLATE "C" >= v_prefix
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            ELSE
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = _bucket_id AND o.name COLLATE "C" < v_next_seek
                ORDER BY o.name COLLATE "C" DESC LIMIT 1;
            END IF;
        END IF;

        EXIT WHEN v_peek_name IS NULL;

        -- STEP 2: Check if this is a FOLDER or FILE
        v_common_prefix := storage.get_common_prefix(v_peek_name, v_prefix, delimiter_param);

        IF v_common_prefix IS NOT NULL THEN
            -- FOLDER: Emit and skip to next folder (no heap access needed)
            name := rtrim(v_common_prefix, delimiter_param);
            id := NULL;
            updated_at := NULL;
            created_at := NULL;
            last_accessed_at := NULL;
            metadata := NULL;
            RETURN NEXT;
            v_count := v_count + 1;

            -- Advance seek past the folder range
            IF v_is_asc THEN
                v_next_seek := left(v_common_prefix, -1) || chr(ascii(delimiter_param) + 1);
            ELSE
                v_next_seek := v_common_prefix;
            END IF;
        ELSE
            -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows)
            -- For ASC: upper_bound is the exclusive upper limit (< condition)
            -- For DESC: prefix is the inclusive lower limit (>= condition)
            FOR v_current IN EXECUTE v_batch_query USING _bucket_id, v_next_seek,
                CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix) ELSE v_prefix END, v_file_batch_size
            LOOP
                v_common_prefix := storage.get_common_prefix(v_current.name, v_prefix, delimiter_param);

                IF v_common_prefix IS NOT NULL THEN
                    -- Hit a folder: exit batch, let peek handle it
                    v_next_seek := v_current.name;
                    EXIT;
                END IF;

                -- Emit file
                name := v_current.name;
                id := v_current.id;
                updated_at := v_current.updated_at;
                created_at := v_current.created_at;
                last_accessed_at := v_current.last_accessed_at;
                metadata := v_current.metadata;
                RETURN NEXT;
                v_count := v_count + 1;

                -- Advance seek past this file
                IF v_is_asc THEN
                    v_next_seek := v_current.name || delimiter_param;
                ELSE
                    v_next_seek := v_current.name;
                END IF;

                EXIT WHEN v_count >= max_keys;
            END LOOP;
        END IF;
    END LOOP;
END;
$_$;

CREATE FUNCTION storage.operation() RETURNS text
    LANGUAGE plpgsql STABLE
    AS $$
BEGIN
    RETURN current_setting('storage.operation', true);
END;
$$;

CREATE FUNCTION storage.protect_delete() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
    -- Check if storage.allow_delete_query is set to 'true'
    IF COALESCE(current_setting('storage.allow_delete_query', true), 'false') != 'true' THEN
        RAISE EXCEPTION 'Direct deletion from storage tables is not allowed. Use the Storage API instead.'
            USING HINT = 'This prevents accidental data loss from orphaned objects.',
                  ERRCODE = '42501';
    END IF;
    RETURN NULL;
END;
$$;

CREATE FUNCTION storage.search(prefix text, bucketname text, limits integer DEFAULT 100, levels integer DEFAULT 1, offsets integer DEFAULT 0, search text DEFAULT ''::text, sortcolumn text DEFAULT 'name'::text, sortorder text DEFAULT 'asc'::text) RETURNS TABLE(name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
    LANGUAGE plpgsql STABLE
    AS $_$
DECLARE
    v_peek_name TEXT;
    v_current RECORD;
    v_common_prefix TEXT;
    v_delimiter CONSTANT TEXT := '/';

    -- Configuration
    v_limit INT;
    v_prefix TEXT;
    v_prefix_lower TEXT;
    v_is_asc BOOLEAN;
    v_order_by TEXT;
    v_sort_order TEXT;
    v_upper_bound TEXT;
    v_file_batch_size INT;

    -- Dynamic SQL for batch query only
    v_batch_query TEXT;

    -- Seek state
    v_next_seek TEXT;
    v_count INT := 0;
    v_skipped INT := 0;
BEGIN
    -- ========================================================================
    -- INITIALIZATION
    -- ========================================================================
    v_limit := LEAST(coalesce(limits, 100), 1500);
    v_prefix := coalesce(prefix, '') || coalesce(search, '');
    v_prefix_lower := lower(v_prefix);
    v_is_asc := lower(coalesce(sortorder, 'asc')) = 'asc';
    v_file_batch_size := LEAST(GREATEST(v_limit * 2, 100), 1000);

    -- Validate sort column
    CASE lower(coalesce(sortcolumn, 'name'))
        WHEN 'name' THEN v_order_by := 'name';
        WHEN 'updated_at' THEN v_order_by := 'updated_at';
        WHEN 'created_at' THEN v_order_by := 'created_at';
        WHEN 'last_accessed_at' THEN v_order_by := 'last_accessed_at';
        ELSE v_order_by := 'name';
    END CASE;

    v_sort_order := CASE WHEN v_is_asc THEN 'asc' ELSE 'desc' END;

    -- ========================================================================
    -- NON-NAME SORTING: Use path_tokens approach (unchanged)
    -- ========================================================================
    IF v_order_by != 'name' THEN
        RETURN QUERY EXECUTE format(
            $sql$
            WITH folders AS (
                SELECT path_tokens[$1] AS folder
                FROM storage.objects
                WHERE objects.name ILIKE $2 || '%%'
                  AND bucket_id = $3
                  AND array_length(objects.path_tokens, 1) <> $1
                GROUP BY folder
                ORDER BY folder %s
            )
            (SELECT folder AS "name",
                   NULL::uuid AS id,
                   NULL::timestamptz AS updated_at,
                   NULL::timestamptz AS created_at,
                   NULL::timestamptz AS last_accessed_at,
                   NULL::jsonb AS metadata FROM folders)
            UNION ALL
            (SELECT path_tokens[$1] AS "name",
                   id, updated_at, created_at, last_accessed_at, metadata
             FROM storage.objects
             WHERE objects.name ILIKE $2 || '%%'
               AND bucket_id = $3
               AND array_length(objects.path_tokens, 1) = $1
             ORDER BY %I %s)
            LIMIT $4 OFFSET $5
            $sql$, v_sort_order, v_order_by, v_sort_order
        ) USING levels, v_prefix, bucketname, v_limit, offsets;
        RETURN;
    END IF;

    -- ========================================================================
    -- NAME SORTING: Hybrid skip-scan with batch optimization
    -- ========================================================================

    -- Calculate upper bound for prefix filtering
    IF v_prefix_lower = '' THEN
        v_upper_bound := NULL;
    ELSIF right(v_prefix_lower, 1) = v_delimiter THEN
        v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(v_delimiter) + 1);
    ELSE
        v_upper_bound := left(v_prefix_lower, -1) || chr(ascii(right(v_prefix_lower, 1)) + 1);
    END IF;

    -- Build batch query (dynamic SQL - called infrequently, amortized over many rows)
    IF v_is_asc THEN
        IF v_upper_bound IS NOT NULL THEN
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' ||
                'AND lower(o.name) COLLATE "C" < $3 ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4';
        ELSE
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" >= $2 ' ||
                'ORDER BY lower(o.name) COLLATE "C" ASC LIMIT $4';
        END IF;
    ELSE
        IF v_upper_bound IS NOT NULL THEN
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' ||
                'AND lower(o.name) COLLATE "C" >= $3 ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4';
        ELSE
            v_batch_query := 'SELECT o.name, o.id, o.updated_at, o.created_at, o.last_accessed_at, o.metadata ' ||
                'FROM storage.objects o WHERE o.bucket_id = $1 AND lower(o.name) COLLATE "C" < $2 ' ||
                'ORDER BY lower(o.name) COLLATE "C" DESC LIMIT $4';
        END IF;
    END IF;

    -- Initialize seek position
    IF v_is_asc THEN
        v_next_seek := v_prefix_lower;
    ELSE
        -- DESC: find the last item in range first (static SQL)
        IF v_upper_bound IS NOT NULL THEN
            SELECT o.name INTO v_peek_name FROM storage.objects o
            WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower AND lower(o.name) COLLATE "C" < v_upper_bound
            ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
        ELSIF v_prefix_lower <> '' THEN
            SELECT o.name INTO v_peek_name FROM storage.objects o
            WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_prefix_lower
            ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
        ELSE
            SELECT o.name INTO v_peek_name FROM storage.objects o
            WHERE o.bucket_id = bucketname
            ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
        END IF;

        IF v_peek_name IS NOT NULL THEN
            v_next_seek := lower(v_peek_name) || v_delimiter;
        ELSE
            RETURN;
        END IF;
    END IF;

    -- ========================================================================
    -- MAIN LOOP: Hybrid peek-then-batch algorithm
    -- Uses STATIC SQL for peek (hot path) and DYNAMIC SQL for batch
    -- ========================================================================
    LOOP
        EXIT WHEN v_count >= v_limit;

        -- STEP 1: PEEK using STATIC SQL (plan cached, very fast)
        IF v_is_asc THEN
            IF v_upper_bound IS NOT NULL THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek AND lower(o.name) COLLATE "C" < v_upper_bound
                ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1;
            ELSE
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" >= v_next_seek
                ORDER BY lower(o.name) COLLATE "C" ASC LIMIT 1;
            END IF;
        ELSE
            IF v_upper_bound IS NOT NULL THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower
                ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
            ELSIF v_prefix_lower <> '' THEN
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek AND lower(o.name) COLLATE "C" >= v_prefix_lower
                ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
            ELSE
                SELECT o.name INTO v_peek_name FROM storage.objects o
                WHERE o.bucket_id = bucketname AND lower(o.name) COLLATE "C" < v_next_seek
                ORDER BY lower(o.name) COLLATE "C" DESC LIMIT 1;
            END IF;
        END IF;

        EXIT WHEN v_peek_name IS NULL;

        -- STEP 2: Check if this is a FOLDER or FILE
        v_common_prefix := storage.get_common_prefix(lower(v_peek_name), v_prefix_lower, v_delimiter);

        IF v_common_prefix IS NOT NULL THEN
            -- FOLDER: Handle offset, emit if needed, skip to next folder
            IF v_skipped < offsets THEN
                v_skipped := v_skipped + 1;
            ELSE
                name := split_part(rtrim(v_common_prefix, v_delimiter), v_delimiter, levels);
                id := NULL;
                updated_at := NULL;
                created_at := NULL;
                last_accessed_at := NULL;
                metadata := NULL;
                RETURN NEXT;
                v_count := v_count + 1;
            END IF;

            -- Advance seek past the folder range
            IF v_is_asc THEN
                v_next_seek := lower(left(v_common_prefix, -1)) || chr(ascii(v_delimiter) + 1);
            ELSE
                v_next_seek := lower(v_common_prefix);
            END IF;
        ELSE
            -- FILE: Batch fetch using DYNAMIC SQL (overhead amortized over many rows)
            -- For ASC: upper_bound is the exclusive upper limit (< condition)
            -- For DESC: prefix_lower is the inclusive lower limit (>= condition)
            FOR v_current IN EXECUTE v_batch_query
                USING bucketname, v_next_seek,
                    CASE WHEN v_is_asc THEN COALESCE(v_upper_bound, v_prefix_lower) ELSE v_prefix_lower END, v_file_batch_size
            LOOP
                v_common_prefix := storage.get_common_prefix(lower(v_current.name), v_prefix_lower, v_delimiter);

                IF v_common_prefix IS NOT NULL THEN
                    -- Hit a folder: exit batch, let peek handle it
                    v_next_seek := lower(v_current.name);
                    EXIT;
                END IF;

                -- Handle offset skipping
                IF v_skipped < offsets THEN
                    v_skipped := v_skipped + 1;
                ELSE
                    -- Emit file
                    name := split_part(v_current.name, v_delimiter, levels);
                    id := v_current.id;
                    updated_at := v_current.updated_at;
                    created_at := v_current.created_at;
                    last_accessed_at := v_current.last_accessed_at;
                    metadata := v_current.metadata;
                    RETURN NEXT;
                    v_count := v_count + 1;
                END IF;

                -- Advance seek past this file
                IF v_is_asc THEN
                    v_next_seek := lower(v_current.name) || v_delimiter;
                ELSE
                    v_next_seek := lower(v_current.name);
                END IF;

                EXIT WHEN v_count >= v_limit;
            END LOOP;
        END IF;
    END LOOP;
END;
$_$;

CREATE FUNCTION storage.search_by_timestamp(p_prefix text, p_bucket_id text, p_limit integer, p_level integer, p_start_after text, p_sort_order text, p_sort_column text, p_sort_column_after text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
    LANGUAGE plpgsql STABLE
    AS $_$
DECLARE
    v_cursor_op text;
    v_query text;
    v_prefix text;
BEGIN
    v_prefix := coalesce(p_prefix, '');

    IF p_sort_order = 'asc' THEN
        v_cursor_op := '>';
    ELSE
        v_cursor_op := '<';
    END IF;

    v_query := format($sql$
        WITH raw_objects AS (
            SELECT
                o.name AS obj_name,
                o.id AS obj_id,
                o.updated_at AS obj_updated_at,
                o.created_at AS obj_created_at,
                o.last_accessed_at AS obj_last_accessed_at,
                o.metadata AS obj_metadata,
                storage.get_common_prefix(o.name, $1, '/') AS common_prefix
            FROM storage.objects o
            WHERE o.bucket_id = $2
              AND o.name COLLATE "C" LIKE $1 || '%%'
        ),
        -- Aggregate common prefixes (folders)
        -- Both created_at and updated_at use MIN(obj_created_at) to match the old prefixes table behavior
        aggregated_prefixes AS (
            SELECT
                rtrim(common_prefix, '/') AS name,
                NULL::uuid AS id,
                MIN(obj_created_at) AS updated_at,
                MIN(obj_created_at) AS created_at,
                NULL::timestamptz AS last_accessed_at,
                NULL::jsonb AS metadata,
                TRUE AS is_prefix
            FROM raw_objects
            WHERE common_prefix IS NOT NULL
            GROUP BY common_prefix
        ),
        leaf_objects AS (
            SELECT
                obj_name AS name,
                obj_id AS id,
                obj_updated_at AS updated_at,
                obj_created_at AS created_at,
                obj_last_accessed_at AS last_accessed_at,
                obj_metadata AS metadata,
                FALSE AS is_prefix
            FROM raw_objects
            WHERE common_prefix IS NULL
        ),
        combined AS (
            SELECT * FROM aggregated_prefixes
            UNION ALL
            SELECT * FROM leaf_objects
        ),
        filtered AS (
            SELECT *
            FROM combined
            WHERE (
                $5 = ''
                OR ROW(
                    date_trunc('milliseconds', %I),
                    name COLLATE "C"
                ) %s ROW(
                    COALESCE(NULLIF($6, '')::timestamptz, 'epoch'::timestamptz),
                    $5
                )
            )
        )
        SELECT
            split_part(name, '/', $3) AS key,
            name,
            id,
            updated_at,
            created_at,
            last_accessed_at,
            metadata
        FROM filtered
        ORDER BY
            COALESCE(date_trunc('milliseconds', %I), 'epoch'::timestamptz) %s,
            name COLLATE "C" %s
        LIMIT $4
    $sql$,
        p_sort_column,
        v_cursor_op,
        p_sort_column,
        p_sort_order,
        p_sort_order
    );

    RETURN QUERY EXECUTE v_query
    USING v_prefix, p_bucket_id, p_level, p_limit, p_start_after, p_sort_column_after;
END;
$_$;

CREATE FUNCTION storage.search_v2(prefix text, bucket_name text, limits integer DEFAULT 100, levels integer DEFAULT 1, start_after text DEFAULT ''::text, sort_order text DEFAULT 'asc'::text, sort_column text DEFAULT 'name'::text, sort_column_after text DEFAULT ''::text) RETURNS TABLE(key text, name text, id uuid, updated_at timestamp with time zone, created_at timestamp with time zone, last_accessed_at timestamp with time zone, metadata jsonb)
    LANGUAGE plpgsql STABLE
    AS $$
DECLARE
    v_sort_col text;
    v_sort_ord text;
    v_limit int;
BEGIN
    -- Cap limit to maximum of 1500 records
    v_limit := LEAST(coalesce(limits, 100), 1500);

    -- Validate and normalize sort_order
    v_sort_ord := lower(coalesce(sort_order, 'asc'));
    IF v_sort_ord NOT IN ('asc', 'desc') THEN
        v_sort_ord := 'asc';
    END IF;

    -- Validate and normalize sort_column
    v_sort_col := lower(coalesce(sort_column, 'name'));
    IF v_sort_col NOT IN ('name', 'updated_at', 'created_at') THEN
        v_sort_col := 'name';
    END IF;

    -- Route to appropriate implementation
    IF v_sort_col = 'name' THEN
        -- Use list_objects_with_delimiter for name sorting (most efficient: O(k * log n))
        RETURN QUERY
        SELECT
            split_part(l.name, '/', levels) AS key,
            l.name AS name,
            l.id,
            l.updated_at,
            l.created_at,
            l.last_accessed_at,
            l.metadata
        FROM storage.list_objects_with_delimiter(
            bucket_name,
            coalesce(prefix, ''),
            '/',
            v_limit,
            start_after,
            '',
            v_sort_ord
        ) l;
    ELSE
        -- Use aggregation approach for timestamp sorting
        -- Not efficient for large datasets but supports correct pagination
        RETURN QUERY SELECT * FROM storage.search_by_timestamp(
            prefix, bucket_name, v_limit, levels, start_after,
            v_sort_ord, v_sort_col, sort_column_after
        );
    END IF;
END;
$$;

CREATE FUNCTION storage.update_updated_at_column() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW; 
END;
$$;

CREATE FUNCTION supabase_functions.http_request() RETURNS trigger
    LANGUAGE plpgsql SECURITY DEFINER
    SET search_path TO 'supabase_functions'
    AS $$
  DECLARE
    request_id bigint;
    payload jsonb;
    url text := TG_ARGV[0]::text;
    method text := TG_ARGV[1]::text;
    headers jsonb DEFAULT '{}'::jsonb;
    params jsonb DEFAULT '{}'::jsonb;
    timeout_ms integer DEFAULT 1000;
  BEGIN
    IF url IS NULL OR url = 'null' THEN
      RAISE EXCEPTION 'url argument is missing';
    END IF;

    IF method IS NULL OR method = 'null' THEN
      RAISE EXCEPTION 'method argument is missing';
    END IF;

    IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
      headers = '{"Content-Type": "application/json"}'::jsonb;
    ELSE
      headers = TG_ARGV[2]::jsonb;
    END IF;

    IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
      params = '{}'::jsonb;
    ELSE
      params = TG_ARGV[3]::jsonb;
    END IF;

    IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
      timeout_ms = 1000;
    ELSE
      timeout_ms = TG_ARGV[4]::integer;
    END IF;

    CASE
      WHEN method = 'GET' THEN
        SELECT http_get INTO request_id FROM net.http_get(
          url,
          params,
          headers,
          timeout_ms
        );
      WHEN method = 'POST' THEN
        payload = jsonb_build_object(
          'old_record', OLD,
          'record', NEW,
          'type', TG_OP,
          'table', TG_TABLE_NAME,
          'schema', TG_TABLE_SCHEMA
        );

        SELECT http_post INTO request_id FROM net.http_post(
          url,
          payload,
          params,
          headers,
          timeout_ms
        );
      ELSE
        RAISE EXCEPTION 'method argument % is invalid', method;
    END CASE;

    INSERT INTO supabase_functions.hooks
      (hook_table_id, hook_name, request_id)
    VALUES
      (TG_RELID, TG_NAME, request_id);

    RETURN NEW;
  END
$$;
