-- RPCs for the per-user key flow. SECURITY DEFINER so the lockout counter
-- is server-authoritative even if a malicious client suppresses
-- record_pin_attempt — try_unlock_user_key refuses to deliver ciphertext
-- while a lockout window is active.

-- 1) try_unlock_user_key ---------------------------------------------------

create or replace function public.try_unlock_user_key(p_user_id uuid)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  row    public.user_keys%rowtype;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  select * into row from public.user_keys where user_id = p_user_id;
  if not found then
    return jsonb_build_object('exists', false);
  end if;

  if row.locked_until is not null and row.locked_until > now() then
    return jsonb_build_object(
      'exists',       true,
      'locked',       true,
      'locked_until', row.locked_until
    );
  end if;

  return jsonb_build_object(
    'exists',                     true,
    'locked',                     false,
    'sealed_private_key',         encode(row.sealed_private_key, 'base64'),
    'salt',                       encode(row.salt, 'base64'),
    'kdf_params',                 row.kdf_params,
    'recovery_sealed_private_key',
      case when row.recovery_sealed_private_key is null
           then null
           else encode(row.recovery_sealed_private_key, 'base64') end,
    'recovery_salt',
      case when row.recovery_salt is null
           then null
           else encode(row.recovery_salt, 'base64') end,
    'failed_attempts',            row.failed_attempts,
    'failed_recovery_attempts',   row.failed_recovery_attempts,
    'recovery_locked_until',      row.recovery_locked_until,
    'key_version',                row.key_version
  );
end;
$$;

revoke execute on function public.try_unlock_user_key(uuid) from public, anon;
grant  execute on function public.try_unlock_user_key(uuid) to authenticated;

-- 2) record_pin_attempt ----------------------------------------------------

create or replace function public.record_pin_attempt(
  p_user_id  uuid,
  p_success  boolean,
  p_recovery boolean default false
)
returns jsonb
language plpgsql
security definer
set search_path = public
as $$
declare
  caller    uuid := auth.uid();
  attempts  int;
  cooldown  interval;
  col_attempts text;
  col_locked   text;
  threshold int;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  if p_recovery then
    col_attempts := 'failed_recovery_attempts';
    col_locked   := 'recovery_locked_until';
    threshold    := 20;
  else
    col_attempts := 'failed_attempts';
    col_locked   := 'locked_until';
    threshold    := 10;
  end if;

  if p_success then
    execute format(
      'update public.user_keys set %I = 0, %I = null, updated_at = now() where user_id = $1',
      col_attempts, col_locked
    ) using p_user_id;
    return jsonb_build_object('failed_attempts', 0, 'locked_until', null);
  end if;

  execute format(
    'update public.user_keys set %I = %I + 1, updated_at = now() where user_id = $1 returning %I',
    col_attempts, col_attempts, col_attempts
  ) using p_user_id into attempts;

  cooldown := case
    when attempts < 5      then interval '0 second'
    when attempts = 5      then interval '5 second'
    when attempts = 6      then interval '30 second'
    when attempts = 7      then interval '2 minute'
    when attempts = 8      then interval '10 minute'
    when attempts = 9      then interval '1 hour'
    when attempts >= threshold and not p_recovery
                           then interval '24 hour'
    when attempts >= threshold and p_recovery
                           then interval '100 year'
    else interval '0 second'
  end;

  if cooldown > interval '0 second' then
    execute format(
      'update public.user_keys set %I = now() + $2 where user_id = $1',
      col_locked
    ) using p_user_id, cooldown;
  end if;

  return jsonb_build_object(
    'failed_attempts', attempts,
    'locked_until',
      case when cooldown > interval '0 second' then now() + cooldown else null end
  );
end;
$$;

revoke execute on function public.record_pin_attempt(uuid, boolean, boolean) from public, anon;
grant  execute on function public.record_pin_attempt(uuid, boolean, boolean) to authenticated;

-- 3) share_conv_keys: now accepts recipient_user_id in each bundle --------

drop function if exists public.share_conv_keys(uuid, uuid, int, jsonb);

create or replace function public.share_conv_keys(
  p_conv_id          uuid,
  p_sender_device_id uuid,    -- legacy; nullable if p_sender_user_id supplied
  p_sender_user_id   uuid,    -- new
  p_key_version      int,
  p_bundles          jsonb
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller        uuid := auth.uid();
  bundle        jsonb;
  inserted      int := 0;
  recipient_uid uuid;
  recipient_did uuid;
  enc_key_hex   text;
  nonce_hex     text;
begin
  if caller is null then raise exception 'not authenticated'; end if;

  if not exists (
    select 1
      from public.conversation_members
     where conversation_id = p_conv_id
       and user_id         = caller
       and accepted        = true
  ) then
    raise exception 'caller is not an accepted member of %', p_conv_id;
  end if;

  if p_sender_user_id is not null and p_sender_user_id <> caller then
    raise exception 'sender_user_id mismatch';
  end if;
  if p_sender_device_id is not null and not exists (
    select 1 from public.devices where id = p_sender_device_id and user_id = caller
  ) then
    raise exception 'sender_device % not owned by caller', p_sender_device_id;
  end if;

  for bundle in select * from jsonb_array_elements(p_bundles) loop
    recipient_uid := nullif(bundle->>'recipient_user_id', '')::uuid;
    recipient_did := nullif(bundle->>'recipient_device_id', '')::uuid;
    enc_key_hex   := bundle->>'encrypted_key';
    nonce_hex     := bundle->>'nonce';

    if recipient_uid is null and recipient_did is not null then
      select user_id into recipient_uid from public.devices where id = recipient_did;
    end if;
    if recipient_uid is null then continue; end if;

    if not exists (
      select 1
        from public.conversation_members
       where conversation_id = p_conv_id
         and user_id         = recipient_uid
         and accepted        = true
    ) then continue; end if;

    insert into public.conversation_keys
      (conversation_id, recipient_user_id, recipient_device_id,
       key_version, sender_user_id, sender_device_id,
       encrypted_key, nonce)
    values
      (p_conv_id, recipient_uid, recipient_did,
       p_key_version, p_sender_user_id, p_sender_device_id,
       decode(enc_key_hex, 'hex'),
       decode(nonce_hex, 'hex'))
    on conflict (conversation_id, recipient_user_id, key_version)
      where recipient_user_id is not null
      do nothing;

    if found then inserted := inserted + 1; end if;
  end loop;

  return inserted;
end;
$$;

revoke execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) from public, anon;
grant  execute on function public.share_conv_keys(uuid, uuid, uuid, int, jsonb) to authenticated;

-- 4) reset_user_key: hard wipe + replace -----------------------------------

create or replace function public.reset_user_key(
  p_user_id            uuid,
  p_public_key_b64     text,
  p_sealed_private_b64 text,
  p_salt_b64           text,
  p_kdf_params         jsonb,
  p_recovery_sealed_b64 text default null,
  p_recovery_salt_b64   text default null
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  rows_deleted int;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  delete from public.conversation_keys
   where recipient_user_id = p_user_id
      or recipient_device_id in (select id from public.devices where user_id = p_user_id);
  get diagnostics rows_deleted = row_count;

  insert into public.user_keys (
    user_id, public_key, sealed_private_key, salt, kdf_params,
    recovery_sealed_private_key, recovery_salt,
    failed_attempts, locked_until,
    failed_recovery_attempts, recovery_locked_until,
    key_version, created_at, updated_at
  ) values (
    p_user_id,
    decode(p_public_key_b64, 'base64'),
    decode(p_sealed_private_b64, 'base64'),
    decode(p_salt_b64, 'base64'),
    p_kdf_params,
    case when p_recovery_sealed_b64 is null then null else decode(p_recovery_sealed_b64, 'base64') end,
    case when p_recovery_salt_b64   is null then null else decode(p_recovery_salt_b64,   'base64') end,
    0, null, 0, null,
    1, now(), now()
  )
  on conflict (user_id) do update set
    public_key                  = excluded.public_key,
    sealed_private_key          = excluded.sealed_private_key,
    salt                        = excluded.salt,
    kdf_params                  = excluded.kdf_params,
    recovery_sealed_private_key = excluded.recovery_sealed_private_key,
    recovery_salt               = excluded.recovery_salt,
    failed_attempts             = 0,
    locked_until                = null,
    failed_recovery_attempts    = 0,
    recovery_locked_until       = null,
    key_version                 = public.user_keys.key_version + 1,
    updated_at                  = now();

  return rows_deleted;
end;
$$;

revoke execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) from public, anon;
grant  execute on function public.reset_user_key(uuid, text, text, text, jsonb, text, text) to authenticated;

-- 5) migrate_user_key_recipients ------------------------------------------

create or replace function public.migrate_user_key_recipients(
  p_conv_id    uuid,
  p_user_id    uuid,
  p_key_version int,
  p_bundles    jsonb -- [{ encrypted_key: hex, nonce: hex, sender_user_id: uuid }]
) returns int
language plpgsql
security definer
set search_path = public
as $$
declare
  caller uuid := auth.uid();
  bundle jsonb;
  inserted int := 0;
begin
  if caller is null or caller <> p_user_id then
    raise exception 'not authenticated as %', p_user_id;
  end if;

  if not exists (
    select 1 from public.conversation_members
     where conversation_id = p_conv_id and user_id = p_user_id and accepted = true
  ) then
    raise exception 'not a member';
  end if;

  for bundle in select * from jsonb_array_elements(p_bundles) loop
    insert into public.conversation_keys (
      conversation_id, recipient_user_id, key_version,
      sender_user_id, encrypted_key, nonce
    ) values (
      p_conv_id, p_user_id, p_key_version,
      nullif(bundle->>'sender_user_id', '')::uuid,
      decode(bundle->>'encrypted_key', 'hex'),
      decode(bundle->>'nonce', 'hex')
    )
    on conflict (conversation_id, recipient_user_id, key_version)
      where recipient_user_id is not null
      do nothing;
    if found then inserted := inserted + 1; end if;
  end loop;
  return inserted;
end;
$$;

revoke execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) from public, anon;
grant  execute on function public.migrate_user_key_recipients(uuid, uuid, int, jsonb) to authenticated;
