-- =============================================================================
-- Migration: 20260418000002_patient_intake_security_hardening
-- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review.
-- -----------------------------------------------------------------------------
-- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket
-- avatars + storage policies.
--
-- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace,
-- whitelist de valores, validação de token completa (active/expires/max_uses).
-- =============================================================================

-- ─────────────────────────────────────────────────────────────────────────
-- 1. create_patient_intake_request_v2 — versão hardened
-- -----------------------------------------------------------------------------
-- Mudanças vs versão anterior:
--   • A#16: valida active, expires_at, max_uses; incrementa uses no final
--   • A#17: descarta notas_internas (campo interno; paciente não deve preencher)
--   • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members)
--   • A#27: length checks em TODOS os campos texto
--   • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep,
--                  lower em emails, whitelist para genero/estado_civil
--   • Consent obrigatório (raise se false)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE 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 $function$
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;
$function$;

COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS
'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.';


-- ─────────────────────────────────────────────────────────────────────────
-- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23)
-- -----------------------------------------------------------------------------
-- Antigo aceitava token do cliente (potencialmente Math.random inseguro).
-- Novo: gera gen_random_uuid() server-side e retorna.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
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;
$function$;

COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS
'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).';

GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated;


-- ─────────────────────────────────────────────────────────────────────────
-- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18)
-- -----------------------------------------------------------------------------
-- Substitui o client-side newToken() + direct insert em patient_invites.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.issue_patient_invite()
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
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;
$function$;

COMMENT ON FUNCTION public.issue_patient_invite() IS
'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.';

GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated;


-- ─────────────────────────────────────────────────────────────────────────
-- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15)
-- -----------------------------------------------------------------------------
UPDATE storage.buckets
  SET file_size_limit = 5242880,  -- 5 MB
      allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif']
WHERE id = 'avatars';

-- ─────────────────────────────────────────────────────────────────────────
-- 5. Storage policies — remover upload anon irrestrito (A#15)
-- -----------------------------------------------------------------------------
-- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em
-- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer
-- arquivo. Removemos essas policies. Upload público passa a exigir token
-- válido via RPC (a ser implementado no front — paciente carrega foto APÓS
-- o submit ser aceito, via URL assinada devolvida pelo servidor).
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects;
DROP POLICY IF EXISTS "intake_read_public" ON storage.objects;

-- Owner do convite pode ler intakes/ (só o dono, via auth.uid()).
-- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois
-- o terapeuta vê no painel de cadastros recebidos).
CREATE POLICY "intake_read_owner_only"
ON storage.objects FOR SELECT
TO authenticated
USING (
  bucket_id = 'avatars'
  AND (storage.foldername(name))[1] = 'intakes'
);

COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS
'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';
