-- LA PUERTA DEL PANEL DE LA IMPRENTA NO TENIA FRENO (2026-07-29)
--
-- Probado en vivo contra sello.gustitoxpress.app/api/admin: cinco intentos
-- seguidos con la clave mala responden 401 401 401 401 401, sin bloqueo, sin
-- registro y sin espera. Ese panel es el que decide quien factura y quien no
-- entre TODOS los clientes de la imprenta, y esta expuesto a internet entero.
-- Es el hueco mas grande que queda hoy, mas grande que la Estacion.
--
-- Ironia del dia: esa misma madrugada se arreglo el freno de las llaves de las
-- maquinas (migration 20260729060000) y la puerta de las personas quedo abierta.
--
-- QUE SE HACE ACA
--
-- 1. fiscal.intentos deja de ser el cuaderno de las maquinas solamente: ahora
--    anota tambien a quien toca la puerta del panel. Se le agrega `tipo` y se
--    renombra `rif` a `quien`, que es lo que de verdad guarda (un RIF cuando es
--    una maquina, un nombre de usuario cuando es una persona).
--
-- 2. entrar_panel cuenta los fallos recientes ANTES de gastar bcrypt. Quien
--    esta probando claves paga una consulta barata a un indice y se va; el
--    operador de verdad nunca toca ese camino.
--
-- 3. entrar_panel deja rastro de todo, el que entro y el que fallo. Aca SI se
--    puede anotar dentro de la funcion (a diferencia de fiscal.autenticar, ver
--    migration 20260729060000) porque esta devuelve una fila diciendo que no,
--    no revienta con `raise`, y entonces el insert no se revierte.
--
-- 4. Un usuario que no existe cuesta lo mismo que uno que si (bcrypt señuelo).
--    Sin eso, el tiempo de respuesta canta cuales nombres de operador son
--    reales: 73 ms si existe, 1 ms si no.
--
-- POR QUE DOS FRENOS Y NO UNO. El freno por usuario protege la clave; el freno
-- por direccion protege contra el que prueba muchos usuarios distintos desde el
-- mismo lado. Y por que la salida de emergencia de la direccion conocida: sin
-- ella, cualquiera puede dejar a Gilberto trancado afuera de su propio panel a
-- punta de intentos fallidos con su nombre de usuario. Desde una direccion que
-- ya entro bien alguna vez, el tope sube en vez de trancarse.
begin;

-- 1. EL CUADERNO ------------------------------------------------------------
alter table fiscal.intentos rename column rif to quien;
alter table fiscal.intentos add column if not exists tipo text not null default 'estacion';
alter table fiscal.intentos drop constraint if exists intentos_tipo_ck;
alter table fiscal.intentos add constraint intentos_tipo_ck check (tipo in ('estacion', 'panel'));

drop index if exists fiscal.intentos_rif_en;
-- Los dos indices son parciales sobre los fallidos a proposito: la tabla se
-- llena de exitosos (uno por documento emitido) y el freno solo mira fallidos.
create index if not exists intentos_fallos_quien on fiscal.intentos (tipo, quien, en desc) where not entro;
create index if not exists intentos_fallos_desde on fiscal.intentos (desde, en desc) where not entro;
-- Este si mira los exitosos: es el que reconoce una direccion conocida.
create index if not exists intentos_entradas_desde on fiscal.intentos (desde, en desc) where entro;

comment on column fiscal.intentos.quien is 'RIF cuando es una maquina, usuario cuando es una persona en el panel';
comment on column fiscal.intentos.tipo is 'estacion (llave de maquina) | panel (clave de persona)';

-- 2. EL QUE ANOTA DESDE AFUERA ----------------------------------------------
-- Cambia de firma (p_rif pasa a p_quien y aparece p_tipo), asi que se tumba la
-- vieja primero para no dejar dos versiones conviviendo.
drop function if exists public.fn_registrar_intento(text, text, text);

create function public.fn_registrar_intento(
  p_quien text,
  p_motivo text,
  p_desde text default null,
  p_tipo text default 'estacion'
)
returns void
language sql
security definer
set search_path to 'public', 'fiscal'
as $fn$
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo)
  values (
    left(coalesce(p_quien, '?'), 20),
    false,
    left(coalesce(p_motivo, 'no autorizado'), 100),
    p_desde,
    case when p_tipo = 'panel' then 'panel' else 'estacion' end
  );
$fn$;

revoke execute on function public.fn_registrar_intento(text, text, text, text) from anon, authenticated, public;
grant execute on function public.fn_registrar_intento(text, text, text, text) to service_role;

-- 3. LOS QUE LEIAN LA COLUMNA VIEJA -----------------------------------------
create or replace function fiscal.autenticar(p_llave text, p_rif text, p_motivo text)
returns uuid
language plpgsql
security definer
set search_path to 'fiscal', 'public', 'extensions'
as $fn$
declare
  v_emisor uuid;
  v_llave_id uuid;
  v_recientes int;
begin
  select id into v_emisor from fiscal.emisores
   where rif = public.fn_rif_normalizado(p_rif) and activo;
  -- OJO: no se registra el fallo aca. El `raise` de abajo revertiria el insert.
  -- Lo anota la Pages Function con public.fn_registrar_intento.
  if v_emisor is null then
    raise exception 'No autorizado';
  end if;

  -- CAMINO RAPIDO: un vistazo al indice, sin criptografia cara.
  select l.id into v_llave_id
    from fiscal.llaves l
   where l.emisor_id = v_emisor and l.activa
     and l.llave_sha = fiscal.huella_llave(p_llave);

  if v_llave_id is null then
    -- El freno va DESPUES del camino rapido a proposito: un cliente legitimo
    -- con muchas llamadas nunca lo toca, y quien esta probando llaves paga la
    -- consulta antes de que le hagamos bcrypt.
    select count(*) into v_recientes from fiscal.intentos
     where tipo = 'estacion' and quien = public.fn_rif_normalizado(p_rif)
       and not entro and en > now() - interval '5 minutes';
    if v_recientes >= 20 then
      raise exception 'Demasiados intentos fallidos. Espera unos minutos.';
    end if;

    select l.id into v_llave_id
      from fiscal.llaves l
     where l.emisor_id = v_emisor and l.activa and l.llave_sha is null
       and l.llave_hash = extensions.crypt(p_llave, l.llave_hash)
     limit 1;

    if v_llave_id is null then
      raise exception 'No autorizado';
    end if;
    update fiscal.llaves set llave_sha = fiscal.huella_llave(p_llave) where id = v_llave_id;
  end if;

  update fiscal.llaves set ultimo_uso = now() where id = v_llave_id;
  return v_emisor;
end;
$fn$;

create or replace function fiscal.emitir_con_llave(
  p_llave text,
  p_rif text,
  p_serie text,
  p_tipo text,
  p_numero_documento bigint,
  p_hash text,
  p_fecha timestamptz,
  p_base_bs numeric,
  p_iva_bs numeric,
  p_total_bs numeric,
  p_documento jsonb,
  p_desde text default null
)
returns table(numero_control bigint, token text, ya_estaba boolean)
language plpgsql
security definer
set search_path to 'fiscal', 'public', 'extensions'
as $fn$
declare v_emisor uuid;
begin
  v_emisor := fiscal.autenticar(p_llave, p_rif, coalesce(p_desde, 'emision'));
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo)
  values (p_rif, true, 'emision', p_desde, 'estacion');

  return query select * from fiscal.recibir_documento(
    p_rif, p_serie, p_tipo, p_numero_documento, p_hash, p_fecha,
    p_base_bs, p_iva_bs, p_total_bs, p_documento
  );
end;
$fn$;

create or replace function fiscal.clientes()
returns table(
  rif text, razon_social text, correo text, telefono text, sistema text,
  serie_asignada text, activo boolean, rif_comprobado boolean,
  llaves_activas integer, documentos_mes integer, total_mes_bs numeric,
  ultimo_documento timestamptz, fallidos_hoy integer
)
language sql
stable
security definer
set search_path to 'fiscal', 'public'
as $fn$
  select e.rif, e.razon_social, e.correo, e.telefono, e.sistema, e.serie_asignada,
         e.activo, e.rif_comprobado,
         (select count(*)::int from fiscal.llaves l where l.emisor_id = e.id and l.activa),
         (select count(*)::int from fiscal.documentos d
           where d.emisor_id = e.id and d.fecha_emision >= date_trunc('month', now())),
         coalesce((select round(sum(d.total_bs), 2) from fiscal.documentos d
           where d.emisor_id = e.id and d.fecha_emision >= date_trunc('month', now())), 0),
         (select max(d.recibido_en) from fiscal.documentos d where d.emisor_id = e.id),
         (select count(*)::int from fiscal.intentos i
           where i.tipo = 'estacion' and i.quien = e.rif and not i.entro and i.en >= current_date)
    from fiscal.emisores e
   order by e.razon_social;
$fn$;

-- El tablero ahora separa las dos puertas: no es lo mismo que una maquina se
-- equivoque de llave a que alguien este probando claves en el panel.
drop function if exists fiscal.tablero();

create function fiscal.tablero()
returns table(
  clientes_activos integer, documentos_mes integer, total_mes_bs numeric,
  documentos_hoy integer, intentos_fallidos_hoy integer,
  intentos_panel_hoy integer, periodos_sin_reportar integer
)
language sql
stable
security definer
set search_path to 'fiscal', 'public'
as $fn$
  select
    (select count(*)::int from fiscal.emisores where activo),
    (select count(*)::int from fiscal.documentos where fecha_emision >= date_trunc('month', now())),
    coalesce((select round(sum(total_bs), 2) from fiscal.documentos where fecha_emision >= date_trunc('month', now())), 0),
    (select count(*)::int from fiscal.documentos where recibido_en >= current_date),
    (select count(*)::int from fiscal.intentos where tipo = 'estacion' and not entro and en >= current_date),
    (select count(*)::int from fiscal.intentos where tipo = 'panel' and not entro and en >= current_date),
    -- Meses cerrados con documentos y sin constancia de envio al SENIAT: eso es
    -- lo que hay que vigilar, porque dos periodos sin reportar es revocatoria.
    (select count(*)::int from (
       select d.emisor_id, date_trunc('month', d.fecha_emision) as periodo
         from fiscal.documentos d
        where d.fecha_emision < date_trunc('month', now())
        group by 1, 2
       except
       select em.emisor_id, em.periodo from fiscal.envios_mensuales em
     ) pendientes);
$fn$;

-- 4. LA PUERTA CON FRENO -----------------------------------------------------
-- Cambia lo que devuelve (ahora dice cuanto falta para poder reintentar), asi
-- que hay que tumbarla antes: `create or replace` no cambia el tipo de retorno.
drop function if exists fiscal.entrar_panel(text, text);

create function fiscal.entrar_panel(p_usuario text, p_clave text, p_desde text default null)
returns table(entro boolean, nombre text, espera_seg integer)
language plpgsql
security definer
set search_path to 'fiscal', 'public', 'extensions'
as $fn$
declare
  -- La ventana y los topes en un solo lugar, para no buscarlos por el cuerpo.
  c_ventana    constant interval := interval '15 minutes';
  c_tope_usu   constant int := 8;   -- fallos con el mismo usuario
  c_tope_ip    constant int := 15;  -- fallos desde la misma direccion
  c_tope_usu_c constant int := 30;  -- lo mismo, desde una direccion conocida
  c_tope_ip_c  constant int := 40;
  -- Hash de una clave que no existe. Sirve para gastar el mismo tiempo cuando
  -- el usuario no existe, y que el reloj no delate cuales nombres son reales.
  c_senuelo    constant text := '$2a$10$IHrhLAMm.gVpUS2Qwhf4DOze.yYhk8xdN3iOEi8owQJdcSX.kFSjG';

  v_usuario  text := left(lower(trim(coalesce(p_usuario, ''))), 20);
  v_desde    text := nullif(trim(coalesce(p_desde, '')), '');
  v_op       record;
  v_conocida boolean := false;
  v_n        int;
  v_viejo    timestamptz;
  v_tope_usu int;
  v_tope_ip  int;
begin
  -- Una direccion desde la que ya se entro bien alguna vez en el ultimo mes es
  -- "la casa": se le sube el tope en vez de trancarle la puerta.
  -- OJO con los alias: esta funcion devuelve una columna que se llama `entro`,
  -- asi que sin el `i.` de adelante Postgres no sabe si uno habla de la columna
  -- de la tabla o de lo que va a devolver, y truena con "entro is ambiguous".
  if v_desde is not null then
    select exists(
      select 1 from fiscal.intentos i
       where i.tipo = 'panel' and i.entro and i.desde = v_desde and i.en > now() - interval '30 days'
    ) into v_conocida;
  end if;
  v_tope_usu := case when v_conocida then c_tope_usu_c else c_tope_usu end;
  v_tope_ip  := case when v_conocida then c_tope_ip_c else c_tope_ip end;

  -- FRENO POR USUARIO. Se mira antes de gastar bcrypt: probar claves tiene que
  -- costar barato para nosotros y caro para el que prueba.
  select count(*), min(t.en) into v_n, v_viejo from (
    select i.en from fiscal.intentos i
     where i.tipo = 'panel' and i.quien = v_usuario and not i.entro and i.en > now() - c_ventana
     order by i.en desc limit v_tope_usu
  ) t;
  if v_n >= v_tope_usu then
    -- No se anota este intento: si se anotara, el que ataca mantendria la
    -- puerta trancada para siempre nada mas insistiendo.
    return query select false, null::text,
      greatest(1, ceil(extract(epoch from (v_viejo + c_ventana - now()))))::int;
    return;
  end if;

  -- FRENO POR DIRECCION. Contra el que prueba usuarios distintos desde el mismo
  -- lado (probando "admin", "gilberto", "imprenta"...).
  if v_desde is not null then
    select count(*), min(t.en) into v_n, v_viejo from (
      select i.en from fiscal.intentos i
       where i.tipo = 'panel' and i.desde = v_desde and not i.entro and i.en > now() - c_ventana
       order by i.en desc limit v_tope_ip
    ) t;
    if v_n >= v_tope_ip then
      return query select false, null::text,
        greatest(1, ceil(extract(epoch from (v_viejo + c_ventana - now()))))::int;
      return;
    end if;
  end if;

  select * into v_op from fiscal.operadores where usuario = v_usuario and activo;

  if v_op.id is null then
    -- Se gasta el bcrypt igual, contra el señuelo, para que un usuario que no
    -- existe tarde lo mismo que uno que si.
    perform extensions.crypt(coalesce(p_clave, ''), c_senuelo);
    insert into fiscal.intentos (quien, entro, motivo, desde, tipo)
    values (v_usuario, false, 'usuario que no existe', v_desde, 'panel');
    return query select false, null::text, 0;
    return;
  end if;

  if v_op.clave_hash is distinct from extensions.crypt(coalesce(p_clave, ''), v_op.clave_hash) then
    -- Aca el insert SI queda: esta funcion responde que no, no revienta con
    -- `raise`, y por eso no se revierte la transaccion (ver 20260729060000).
    insert into fiscal.intentos (quien, entro, motivo, desde, tipo)
    values (v_usuario, false, 'clave incorrecta', v_desde, 'panel');
    return query select false, null::text, 0;
    return;
  end if;

  update fiscal.operadores set ultimo_acceso = now() where id = v_op.id;
  insert into fiscal.intentos (quien, entro, motivo, desde, tipo)
  values (v_usuario, true, 'entro al panel', v_desde, 'panel');
  return query select true, v_op.nombre, 0;
end;
$fn$;

-- La direccion la pone la Pages Function, nunca el navegador.
create or replace function public.fn_panel_imprenta(p_accion text, p_datos jsonb default '{}'::jsonb)
returns jsonb
language plpgsql
security definer
set search_path to 'public', 'fiscal'
as $fn$
declare v jsonb;
begin
  case p_accion
    when 'entrar' then
      select to_jsonb(t) into v from fiscal.entrar_panel(
        p_datos->>'usuario', p_datos->>'clave', p_datos->>'desde') t;
    when 'tablero' then
      select to_jsonb(t) into v from fiscal.tablero() t;
    when 'clientes' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.clientes() t;
    when 'guardar_cliente' then
      select to_jsonb(fiscal.guardar_cliente(
        p_datos->>'rif', p_datos->>'razon_social', p_datos->>'domicilio',
        p_datos->>'representante', p_datos->>'cedula', p_datos->>'correo',
        p_datos->>'telefono', p_datos->>'sistema', coalesce(p_datos->>'serie', 'A'),
        coalesce((p_datos->>'rif_comprobado')::boolean, false), p_datos->>'nota')) into v;
    when 'activar_cliente' then
      select to_jsonb(fiscal.activar_cliente(p_datos->>'rif', (p_datos->>'activo')::boolean)) into v;
    when 'llaves' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.llaves_de(p_datos->>'rif') t;
    when 'crear_llave' then
      select to_jsonb(fiscal.crear_llave(p_datos->>'rif', coalesce(p_datos->>'nombre', 'llave'))) into v;
    when 'revocar_llave' then
      select to_jsonb(fiscal.revocar_llave((p_datos->>'id')::uuid)) into v;
    when 'resumen_mensual' then
      select to_jsonb(t) into v from fiscal.resumen_mensual(
        p_datos->>'rif', (p_datos->>'anio')::int, (p_datos->>'mes')::int) t;
    when 'relacion_mensual' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.relacion_mensual(
        p_datos->>'rif', (p_datos->>'anio')::int, (p_datos->>'mes')::int) t;
    when 'marcar_enviado' then
      select to_jsonb(fiscal.marcar_enviado(
        p_datos->>'rif', (p_datos->>'anio')::int, (p_datos->>'mes')::int,
        p_datos->>'quien', p_datos->>'nota')) into v;
    when 'negocios_gustito' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.negocios_gustito() t;
    when 'buscar_negocio' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.buscar_negocio(p_datos->>'texto') t;
    when 'vincular_negocio' then
      select fiscal.vincular_negocio((p_datos->>'restaurante_id')::uuid, p_datos->>'rif',
                                     p_datos->>'serie', p_datos->>'quien', p_datos->>'nota') into v;
    when 'desvincular_negocio' then
      select to_jsonb(fiscal.desvincular_negocio((p_datos->>'restaurante_id')::uuid)) into v;
    -- Quien esta reportando y quien lleva horas callado.
    when 'estaciones' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.estaciones_vivas() t;
    -- Ventas que no se han podido sellar.
    when 'cola' then
      select coalesce(jsonb_agg(to_jsonb(t)), '[]'::jsonb) into v from fiscal.cola_por_revisar() t;
    else
      raise exception 'Accion desconocida';
  end case;
  return coalesce(v, 'null'::jsonb);
end;
$fn$;

commit;
