begin;

-- Account-level, cross-instance rate limiting for consecutive failed
-- redemption attempts. The audit table records only who failed and when;
-- neither the plaintext code nor its hash is ever stored here. It is
-- failure-only: a successful redemption deletes the account's rows, so no
-- permanent success row is kept.
create table if not exists public.redemption_attempts (
  id bigint generated always as identity primary key,
  user_id uuid not null references auth.users(id) on delete cascade,
  created_at timestamptz not null default now()
);

create index if not exists redemption_attempts_user_created_idx
  on public.redemption_attempts (user_id, created_at desc);

alter table public.redemption_attempts enable row level security;
revoke all on table public.redemption_attempts from anon, authenticated;
grant select on table public.redemption_attempts to service_role;

-- CREATE OR REPLACE cannot change a function's return type, so the previous
-- 3-column redeem_code(text) is dropped and rebuilt inside the same
-- transaction, then its revoke/grant ACLs are restored below.
drop function if exists public.redeem_code(text);

-- redeem_code keeps the redemption-code row lock and the credit_transactions
-- unique constraint. It additionally serializes per-account attempts, counts
-- business failures within a rolling 10-minute window, and returns
-- rate_limited once an account reaches 5 failures. Every business failure
-- writes an audit row; a successful redemption deletes the account's
-- attempts. account_not_eligible is intentionally not fabricated here: there
-- is no account-eligibility restriction model yet. The audit table's
-- identity sequence needs no authenticated USAGE grant because the
-- security-definer function runs as its owner.
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, awarded_credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
  v_user_id uuid := auth.uid();
  v_email text := auth.jwt() ->> 'email';
  v_code public.redemption_codes%rowtype;
  v_balance integer;
  v_failed integer;
begin
  if v_user_id is null then
    return query select false, null::integer, null::integer, 'unauthorized'::text;
    return;
  end if;

  perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0));

  delete from public.redemption_attempts
  where user_id = v_user_id and created_at < now() - interval '10 minutes';

  select count(*) into v_failed
  from public.redemption_attempts
  where user_id = v_user_id;

  if v_failed >= 5 then
    return query select false, null::integer, null::integer, 'rate_limited'::text;
    return;
  end if;

  if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
    insert into public.redemption_attempts (user_id) values (v_user_id);
    return query select false, null::integer, null::integer, 'invalid_code'::text;
    return;
  end if;

  select rc.* into v_code
  from public.redemption_codes rc
  where rc.code_hash = p_code_hash
  for update;
  if not found then
    insert into public.redemption_attempts (user_id) values (v_user_id);
    return query select false, null::integer, null::integer, 'invalid_code'::text;
    return;
  end if;
  if v_code.redeemed_by is not null then
    insert into public.redemption_attempts (user_id) values (v_user_id);
    return query select false, null::integer, null::integer, 'already_redeemed'::text;
    return;
  end if;
  if v_code.revoked_at is not null then
    insert into public.redemption_attempts (user_id) values (v_user_id);
    return query select false, null::integer, null::integer, 'revoked_code'::text;
    return;
  end if;
  if v_code.expires_at is not null and v_code.expires_at <= now() then
    insert into public.redemption_attempts (user_id) values (v_user_id);
    return query select false, null::integer, null::integer, 'expired_code'::text;
    return;
  end if;

  select p.credits into v_balance
  from public.profiles p
  where p.id = v_user_id
  for update;
  if not found then
    return query select false, null::integer, null::integer, 'profile_missing'::text;
    return;
  end if;

  update public.redemption_codes rc
  set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
  where rc.id = v_code.id;

  update public.profiles p
  set credits = p.credits + v_code.credits, updated_at = now()
  where p.id = v_user_id
  returning p.credits into v_balance;

  insert into public.credit_transactions (
    user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
  ) values (
    v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
  );

  delete from public.redemption_attempts where user_id = v_user_id;

  return query select true, v_balance, v_code.credits, null::text;
end;
$$;

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

commit;
