-- ============================================================================
-- Milestone 1 initial schema.
--
-- Design decisions (locked in by owner):
--   * Per-device X25519 keypairs. Messages encrypted per recipient device.
--   * Admin flag lives on profiles.
--   * DMs require friendship OR allow_dms_from_strangers on receiver.
--   * Group roles: admin / mod / member.
--   * Edits: own messages only, 24h window.
--   * Soft-delete everywhere.
--   * Reactions: multiple emojis per user-message, same emoji once.
--   * Read receipts: reciprocal opt-out via profiles.show_read_receipts.
--   * Presence state only (no last-seen timestamp).
--   * Attachments: separate table, images for M1.
--   * Server is zero-knowledge: only envelopes hold ciphertext.
-- ============================================================================

-- ============================================================================
-- Extensions
-- ============================================================================
create extension if not exists citext;
create extension if not exists pgcrypto;

-- ============================================================================
-- Enums
-- ============================================================================
create type public.presence_state as enum ('online', 'idle', 'dnd', 'invisible', 'offline');
create type public.conversation_type as enum ('dm', 'group');
create type public.member_role as enum ('admin', 'mod', 'member');
create type public.friendship_status as enum ('pending', 'accepted', 'blocked');
create type public.device_platform as enum ('ios', 'android', 'macos', 'windows', 'linux');

-- ============================================================================
-- profiles
--   One row per auth.users row. username is the login handle (unique, case-
--   insensitive). display_name is free-form.
-- ============================================================================
create table public.profiles (
  user_id                    uuid primary key references auth.users(id) on delete cascade,
  username                   citext not null unique,
  display_name               text not null,
  avatar_url                 text,
  status_message             text,
  presence_state             public.presence_state not null default 'offline',
  show_read_receipts         boolean not null default true,
  allow_dms_from_strangers   boolean not null default true,
  is_admin                   boolean not null default false,
  banned                     boolean not null default false,
  blocked_from_inviting      boolean not null default false,
  created_at                 timestamptz not null default now(),
  updated_at                 timestamptz not null default now(),
  constraint profiles_username_format check (username ~ '^[a-z0-9_]{3,32}$'),
  constraint profiles_display_name_len check (length(display_name) between 1 and 64),
  constraint profiles_status_message_len check (status_message is null or length(status_message) <= 128)
);

-- ============================================================================
-- devices
--   Each physical device registers itself and owns its own X25519 public key.
--   Private key never leaves the device. Messages encrypted per recipient
--   device => per-device envelope rows.
-- ============================================================================
create table public.devices (
  id              uuid primary key default gen_random_uuid(),
  user_id         uuid not null references auth.users(id) on delete cascade,
  name            text not null,
  platform        public.device_platform not null,
  public_key      bytea not null,
  last_seen_at    timestamptz not null default now(),
  created_at      timestamptz not null default now(),
  constraint devices_name_len check (length(name) between 1 and 64)
);

create index devices_user_idx on public.devices(user_id);

-- ============================================================================
-- push_tokens
-- ============================================================================
create table public.push_tokens (
  device_id    uuid primary key references public.devices(id) on delete cascade,
  platform     public.device_platform not null,
  token        text not null,
  updated_at   timestamptz not null default now()
);

-- ============================================================================
-- invites (signup invites, admin-controlled)
-- ============================================================================
create table public.invites (
  code          text primary key,
  created_by    uuid references auth.users(id) on delete set null,
  uses_limit    integer,                -- null = unlimited
  uses_count    integer not null default 0,
  expires_at    timestamptz,            -- null = never
  disabled      boolean not null default false,
  created_at    timestamptz not null default now(),
  constraint invites_code_len check (length(code) between 4 and 64),
  constraint invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0))
);

create index invites_created_by_idx on public.invites(created_by);

-- ============================================================================
-- admin_settings (global feature flags)
-- ============================================================================
create table public.admin_settings (
  key         text primary key,
  value       jsonb not null,
  updated_at  timestamptz not null default now()
);

-- Seed default admin settings.
insert into public.admin_settings (key, value) values
  ('invites_enabled',        'true'::jsonb),
  ('default_invite_ttl_days', '30'::jsonb),
  ('default_invite_uses',     '1'::jsonb)
on conflict (key) do nothing;

-- ============================================================================
-- friendships
--   Stored symmetrically: user_lo < user_hi to keep (A,B) == (B,A) one row.
--   requested_by records who initiated the request.
-- ============================================================================
create table public.friendships (
  user_lo        uuid not null references auth.users(id) on delete cascade,
  user_hi        uuid not null references auth.users(id) on delete cascade,
  status         public.friendship_status not null default 'pending',
  requested_by   uuid not null references auth.users(id) on delete cascade,
  created_at     timestamptz not null default now(),
  accepted_at    timestamptz,
  primary key (user_lo, user_hi),
  constraint friendships_ordered check (user_lo < user_hi)
);

create index friendships_hi_idx on public.friendships(user_hi);

-- ============================================================================
-- conversations
-- ============================================================================
create table public.conversations (
  id            uuid primary key default gen_random_uuid(),
  type          public.conversation_type not null,
  name          text,                    -- null for DMs
  avatar_url    text,                    -- null for DMs
  created_by    uuid references auth.users(id) on delete set null,
  created_at    timestamptz not null default now(),
  constraint conversations_group_has_name check (
    type = 'dm' or (type = 'group' and name is not null and length(name) between 1 and 64)
  )
);

-- ============================================================================
-- conversation_members
--   accepted=false means the DM is still in the "request" inbox for that user.
-- ============================================================================
create table public.conversation_members (
  conversation_id   uuid not null references public.conversations(id) on delete cascade,
  user_id           uuid not null references auth.users(id) on delete cascade,
  role              public.member_role not null default 'member',
  accepted          boolean not null default true,
  joined_at         timestamptz not null default now(),
  primary key (conversation_id, user_id)
);

create index conversation_members_user_idx on public.conversation_members(user_id);

-- ============================================================================
-- group_invites (invite links to join groups)
-- ============================================================================
create table public.group_invites (
  code              text primary key,
  conversation_id   uuid not null references public.conversations(id) on delete cascade,
  created_by        uuid references auth.users(id) on delete set null,
  uses_limit        integer,
  uses_count        integer not null default 0,
  expires_at        timestamptz,
  disabled          boolean not null default false,
  created_at        timestamptz not null default now(),
  constraint group_invites_code_len check (length(code) between 4 and 64),
  constraint group_invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0))
);

create index group_invites_conversation_idx on public.group_invites(conversation_id);

-- ============================================================================
-- messages (plaintext metadata only — ciphertext lives in envelopes)
-- ============================================================================
create table public.messages (
  id                uuid primary key default gen_random_uuid(),
  conversation_id   uuid not null references public.conversations(id) on delete cascade,
  sender_id         uuid not null references auth.users(id) on delete set null,
  reply_to_id       uuid references public.messages(id) on delete set null,
  edited_at         timestamptz,
  deleted_at        timestamptz,
  deleted_by        uuid references auth.users(id) on delete set null,
  created_at        timestamptz not null default now()
);

create index messages_conversation_created_idx
  on public.messages(conversation_id, created_at desc);

-- ============================================================================
-- message_envelopes (per-recipient-device ciphertext)
-- ============================================================================
create table public.message_envelopes (
  message_id            uuid not null references public.messages(id) on delete cascade,
  recipient_device_id   uuid not null references public.devices(id) on delete cascade,
  ciphertext            bytea not null,
  nonce                 bytea not null,
  primary key (message_id, recipient_device_id)
);

create index message_envelopes_device_idx on public.message_envelopes(recipient_device_id);

-- ============================================================================
-- message_reactions
-- ============================================================================
create table public.message_reactions (
  message_id    uuid not null references public.messages(id) on delete cascade,
  user_id       uuid not null references auth.users(id) on delete cascade,
  emoji         text not null,
  created_at    timestamptz not null default now(),
  primary key (message_id, user_id, emoji),
  constraint message_reactions_emoji_len check (length(emoji) between 1 and 16)
);

-- ============================================================================
-- message_reads
-- ============================================================================
create table public.message_reads (
  message_id    uuid not null references public.messages(id) on delete cascade,
  user_id       uuid not null references auth.users(id) on delete cascade,
  read_at       timestamptz not null default now(),
  primary key (message_id, user_id)
);

-- ============================================================================
-- message_attachments (encrypted blobs in Supabase Storage)
-- ============================================================================
create table public.message_attachments (
  id             uuid primary key default gen_random_uuid(),
  message_id     uuid not null references public.messages(id) on delete cascade,
  storage_path   text not null,
  nonce          bytea not null,
  mime_type      text not null,
  size_bytes     bigint not null,
  width          integer,
  height         integer,
  created_at     timestamptz not null default now(),
  constraint message_attachments_size_pos check (size_bytes > 0)
);

create index message_attachments_message_idx on public.message_attachments(message_id);

-- ============================================================================
-- Helper functions (SECURITY DEFINER to dodge RLS recursion)
-- ============================================================================
create or replace function public.is_conversation_member(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
  select exists (
    select 1 from public.conversation_members
    where conversation_id = cid and user_id = auth.uid() and accepted
  );
$$;

create or replace function public.is_conversation_mod_or_higher(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
  select exists (
    select 1 from public.conversation_members
    where conversation_id = cid
      and user_id = auth.uid()
      and accepted
      and role in ('admin', 'mod')
  );
$$;

create or replace function public.is_conversation_admin(cid uuid)
returns boolean language sql security definer stable set search_path = public as $$
  select exists (
    select 1 from public.conversation_members
    where conversation_id = cid
      and user_id = auth.uid()
      and accepted
      and role = 'admin'
  );
$$;

create or replace function public.are_friends(a uuid, b uuid)
returns boolean language sql security definer stable set search_path = public as $$
  select exists (
    select 1 from public.friendships
    where status = 'accepted'
      and ((user_lo = least(a,b) and user_hi = greatest(a,b)))
  );
$$;

create or replace function public.current_user_is_admin()
returns boolean language sql security definer stable set search_path = public as $$
  select coalesce((select is_admin from public.profiles where user_id = auth.uid()), false);
$$;

-- ============================================================================
-- Enable RLS on everything user-facing
-- ============================================================================
alter table public.profiles              enable row level security;
alter table public.devices               enable row level security;
alter table public.push_tokens           enable row level security;
alter table public.invites               enable row level security;
alter table public.admin_settings        enable row level security;
alter table public.friendships           enable row level security;
alter table public.conversations         enable row level security;
alter table public.conversation_members  enable row level security;
alter table public.group_invites         enable row level security;
alter table public.messages              enable row level security;
alter table public.message_envelopes     enable row level security;
alter table public.message_reactions     enable row level security;
alter table public.message_reads         enable row level security;
alter table public.message_attachments   enable row level security;

-- ============================================================================
-- profiles policies
--   Everyone authenticated can look up any profile (for peer lookup + search).
--   Only the owner can update their row. No direct INSERT (handled by trigger).
-- ============================================================================
create policy profiles_select_all on public.profiles
  for select to authenticated using (true);

create policy profiles_update_own on public.profiles
  for update to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- ============================================================================
-- devices policies
-- ============================================================================
create policy devices_select_own on public.devices
  for select to authenticated using (user_id = auth.uid());

-- Any authenticated user can read public keys of peers they converse with.
-- Simpler v1: expose all public keys. Tighten later.
create policy devices_select_all_public_key on public.devices
  for select to authenticated using (true);

create policy devices_insert_own on public.devices
  for insert to authenticated with check (user_id = auth.uid());

create policy devices_update_own on public.devices
  for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid());

create policy devices_delete_own on public.devices
  for delete to authenticated using (user_id = auth.uid());

-- ============================================================================
-- push_tokens policies
-- ============================================================================
create policy push_tokens_select_own on public.push_tokens
  for select to authenticated
  using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()));

create policy push_tokens_write_own on public.push_tokens
  for all to authenticated
  using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()))
  with check (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid()));

-- ============================================================================
-- invites policies (admin-managed)
-- ============================================================================
create policy invites_select_own_or_admin on public.invites
  for select to authenticated
  using (created_by = auth.uid() or public.current_user_is_admin());

create policy invites_insert_own on public.invites
  for insert to authenticated
  with check (
    created_by = auth.uid()
    and not coalesce((select blocked_from_inviting from public.profiles where user_id = auth.uid()), false)
    and coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true)
  );

create policy invites_update_admin on public.invites
  for update to authenticated
  using (public.current_user_is_admin())
  with check (public.current_user_is_admin());

create policy invites_delete_admin on public.invites
  for delete to authenticated
  using (public.current_user_is_admin());

-- ============================================================================
-- admin_settings policies
-- ============================================================================
create policy admin_settings_select_all on public.admin_settings
  for select to authenticated using (true);

create policy admin_settings_write_admin on public.admin_settings
  for all to authenticated
  using (public.current_user_is_admin())
  with check (public.current_user_is_admin());

-- ============================================================================
-- friendships policies
-- ============================================================================
create policy friendships_select_own on public.friendships
  for select to authenticated
  using (user_lo = auth.uid() or user_hi = auth.uid());

-- Insert only allowed when:
--   - row is ordered (user_lo < user_hi)
--   - one side is the caller
--   - requested_by = caller
--   - status starts at 'pending'
create policy friendships_insert_own on public.friendships
  for insert to authenticated
  with check (
    user_lo < user_hi
    and (user_lo = auth.uid() or user_hi = auth.uid())
    and requested_by = auth.uid()
    and status = 'pending'
  );

-- Update allowed for the recipient (the party that did NOT request) to accept or
-- block. Caller can block from their side regardless.
create policy friendships_update_involved on public.friendships
  for update to authenticated
  using (user_lo = auth.uid() or user_hi = auth.uid())
  with check (user_lo = auth.uid() or user_hi = auth.uid());

create policy friendships_delete_own on public.friendships
  for delete to authenticated
  using (user_lo = auth.uid() or user_hi = auth.uid());

-- ============================================================================
-- conversations policies
-- ============================================================================
create policy conversations_select_member on public.conversations
  for select to authenticated using (public.is_conversation_member(id));

create policy conversations_insert_authenticated on public.conversations
  for insert to authenticated with check (created_by = auth.uid());

-- Only admins of a group can update metadata (name, avatar).
create policy conversations_update_admin on public.conversations
  for update to authenticated
  using (type = 'group' and public.is_conversation_admin(id))
  with check (type = 'group' and public.is_conversation_admin(id));

-- ============================================================================
-- conversation_members policies
-- ============================================================================
create policy members_select_co on public.conversation_members
  for select to authenticated using (public.is_conversation_member(conversation_id));

-- Insert cases:
--   (a) Self-inserting as creator (user_id = auth.uid())
--   (b) Adding a peer to a DM you own
--       - dm conversation
--       - target is a friend OR target allows DMs from strangers
--   (c) Admin/Mod adding a friend to a group
create policy members_insert_complex on public.conversation_members
  for insert to authenticated
  with check (
    -- (a) Self-insert (creator bootstrap or accepting group invite via RPC)
    user_id = auth.uid()

    -- (b) DM peer add by creator
    or (
      exists (select 1 from public.conversations c where c.id = conversation_id and c.type = 'dm' and c.created_by = auth.uid())
      and (
        public.are_friends(auth.uid(), user_id)
        or coalesce((select allow_dms_from_strangers from public.profiles where profiles.user_id = conversation_members.user_id), false)
      )
    )

    -- (c) Admin/Mod adding friend to group
    or (
      public.is_conversation_mod_or_higher(conversation_id)
      and public.are_friends(auth.uid(), user_id)
    )
  );

-- Users can accept their own DM requests (flip accepted to true) and
-- admins/mods can change roles. Rest is restricted via triggers.
create policy members_update_self_or_admin on public.conversation_members
  for update to authenticated
  using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id))
  with check (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id));

-- Users can remove themselves. Admins/mods can kick.
create policy members_delete_self_or_admin on public.conversation_members
  for delete to authenticated
  using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id));

-- ============================================================================
-- group_invites policies
-- ============================================================================
create policy group_invites_select_member on public.group_invites
  for select to authenticated
  using (public.is_conversation_member(conversation_id));

create policy group_invites_insert_mod on public.group_invites
  for insert to authenticated
  with check (public.is_conversation_mod_or_higher(conversation_id) and created_by = auth.uid());

create policy group_invites_update_admin on public.group_invites
  for update to authenticated
  using (public.is_conversation_admin(conversation_id))
  with check (public.is_conversation_admin(conversation_id));

create policy group_invites_delete_admin on public.group_invites
  for delete to authenticated
  using (public.is_conversation_admin(conversation_id));

-- ============================================================================
-- messages policies
-- ============================================================================
create policy messages_select_member on public.messages
  for select to authenticated using (public.is_conversation_member(conversation_id));

create policy messages_insert_member on public.messages
  for insert to authenticated
  with check (
    sender_id = auth.uid()
    and public.is_conversation_member(conversation_id)
  );

-- UPDATE allowed by:
--   - sender editing within 24h (edited_at set)
--   - sender soft-deleting own message (deleted_at set, deleted_by = self)
--   - mod/admin soft-deleting any message in conversation (deleted_at set)
-- Detailed field-level enforcement lives in the messages_update_guard trigger.
create policy messages_update_guarded on public.messages
  for update to authenticated
  using (
    sender_id = auth.uid()
    or public.is_conversation_mod_or_higher(conversation_id)
  )
  with check (
    sender_id = auth.uid()
    or public.is_conversation_mod_or_higher(conversation_id)
  );

-- ============================================================================
-- message_envelopes policies
-- ============================================================================
-- Sender inserts envelopes for all recipient devices (including their own).
create policy envelopes_insert_sender on public.message_envelopes
  for insert to authenticated
  with check (
    exists (
      select 1 from public.messages m
      where m.id = message_id and m.sender_id = auth.uid()
    )
  );

-- Reader can fetch envelopes targeted at one of their own devices.
create policy envelopes_select_own_device on public.message_envelopes
  for select to authenticated
  using (
    exists (
      select 1 from public.devices d
      where d.id = recipient_device_id and d.user_id = auth.uid()
    )
  );

-- Sender updates envelopes when editing message (same 24h window enforced via trigger).
create policy envelopes_update_sender on public.message_envelopes
  for update to authenticated
  using (
    exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid())
  )
  with check (
    exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid())
  );

-- Cascade-delete on message_id handles most cleanup.

-- ============================================================================
-- message_reactions policies
-- ============================================================================
create policy reactions_select_member on public.message_reactions
  for select to authenticated
  using (exists (
    select 1 from public.messages m
    where m.id = message_id and public.is_conversation_member(m.conversation_id)
  ));

create policy reactions_insert_own on public.message_reactions
  for insert to authenticated
  with check (
    user_id = auth.uid()
    and exists (
      select 1 from public.messages m
      where m.id = message_id and public.is_conversation_member(m.conversation_id)
    )
  );

create policy reactions_delete_own on public.message_reactions
  for delete to authenticated using (user_id = auth.uid());

-- ============================================================================
-- message_reads policies
--   Reciprocal opt-out: a user without show_read_receipts cannot see others' reads
--   and others cannot see theirs.
-- ============================================================================
create policy reads_select_reciprocal on public.message_reads
  for select to authenticated
  using (
    -- Always see your own reads.
    user_id = auth.uid()
    or (
      -- See others' reads only if caller has receipts on AND target has receipts on.
      coalesce((select show_read_receipts from public.profiles where profiles.user_id = auth.uid()), true)
      and coalesce((select show_read_receipts from public.profiles where profiles.user_id = message_reads.user_id), true)
      and exists (
        select 1 from public.messages m
        where m.id = message_id and public.is_conversation_member(m.conversation_id)
      )
    )
  );

create policy reads_insert_own on public.message_reads
  for insert to authenticated
  with check (
    user_id = auth.uid()
    and exists (
      select 1 from public.messages m
      where m.id = message_id and public.is_conversation_member(m.conversation_id)
    )
  );

-- ============================================================================
-- message_attachments policies
-- ============================================================================
create policy attachments_select_member on public.message_attachments
  for select to authenticated
  using (exists (
    select 1 from public.messages m
    where m.id = message_id and public.is_conversation_member(m.conversation_id)
  ));

create policy attachments_insert_sender on public.message_attachments
  for insert to authenticated
  with check (exists (
    select 1 from public.messages m
    where m.id = message_id and m.sender_id = auth.uid()
  ));

-- ============================================================================
-- Triggers
-- ============================================================================

-- profiles.updated_at bump
create or replace function public.set_updated_at()
returns trigger language plpgsql as $$
begin
  new.updated_at = now();
  return new;
end;
$$;

create trigger profiles_updated_at
  before update on public.profiles
  for each row execute function public.set_updated_at();

-- messages guard: enforce edit-window, deletion rules, envelope-edit-window.
create or replace function public.messages_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
  caller uuid := auth.uid();
  is_mod boolean := public.is_conversation_mod_or_higher(new.conversation_id);
begin
  -- Already deleted? Freeze.
  if old.deleted_at is not null then
    raise exception 'message already deleted';
  end if;

  -- If deleting (deleted_at transitioning from null to not-null)
  if old.deleted_at is null and new.deleted_at is not null then
    if new.deleted_by is null then
      new.deleted_by := caller;
    end if;
    if not (old.sender_id = caller or is_mod) then
      raise exception 'not allowed to delete this message';
    end if;
    return new;
  end if;

  -- Otherwise: edit. Only sender, only within 24h.
  if old.sender_id <> caller then
    raise exception 'only sender can edit';
  end if;

  if now() - old.created_at > interval '24 hours' then
    raise exception 'edit window (24h) expired';
  end if;

  new.edited_at := now();

  -- Preserve immutable fields.
  new.conversation_id := old.conversation_id;
  new.sender_id       := old.sender_id;
  new.created_at      := old.created_at;

  return new;
end;
$$;

create trigger messages_update_guard_trigger
  before update on public.messages
  for each row execute function public.messages_update_guard();

-- envelope edit guard (same 24h window)
create or replace function public.envelopes_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
  caller uuid := auth.uid();
  msg_created timestamptz;
  msg_sender uuid;
begin
  select created_at, sender_id into msg_created, msg_sender
  from public.messages where id = new.message_id;

  if msg_sender <> caller then
    raise exception 'only sender can rewrite envelopes';
  end if;

  if now() - msg_created > interval '24 hours' then
    raise exception 'envelope edit window (24h) expired';
  end if;

  return new;
end;
$$;

create trigger envelopes_update_guard_trigger
  before update on public.message_envelopes
  for each row execute function public.envelopes_update_guard();

-- Friendship update guard: only the non-requester can accept; either party can block.
create or replace function public.friendships_update_guard()
returns trigger language plpgsql security definer set search_path = public as $$
declare
  caller uuid := auth.uid();
begin
  -- status transition: pending -> accepted
  if old.status = 'pending' and new.status = 'accepted' then
    if caller = old.requested_by then
      raise exception 'requester cannot self-accept';
    end if;
    new.accepted_at := now();
    return new;
  end if;

  -- any -> blocked by either party: allow
  if new.status = 'blocked' then
    return new;
  end if;

  -- blocked -> anything: only blocker can unblock (requested_by holds blocker id
  -- after block). To keep simple: allow any involved party to unblock.
  if old.status = 'blocked' and new.status <> 'blocked' then
    return new;
  end if;

  -- everything else: freeze
  raise exception 'illegal friendship transition: % -> %', old.status, new.status;
end;
$$;

create trigger friendships_update_guard_trigger
  before update on public.friendships
  for each row execute function public.friendships_update_guard();

-- Signup trigger: consume invite, create profile row.
-- Expects raw_user_meta_data to contain: invite_code, username, display_name.
-- public_key is registered separately via devices table after login.
create or replace function public.handle_new_user()
returns trigger language plpgsql security definer set search_path = public as $$
declare
  v_invite_code  text;
  v_username     text;
  v_display_name text;
  v_invite       public.invites%rowtype;
begin
  v_invite_code  := new.raw_user_meta_data->>'invite_code';
  v_username     := lower(trim(new.raw_user_meta_data->>'username'));
  v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), '');
  if v_display_name is null then
    v_display_name := v_username;
  end if;

  if v_invite_code is null or length(v_invite_code) = 0 then
    raise exception 'invite_code required';
  end if;

  if v_username is null or v_username !~ '^[a-z0-9_]{3,32}$' then
    raise exception 'username invalid (lowercase alphanumeric + underscore, 3-32 chars)';
  end if;

  if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then
    raise exception 'invites globally disabled';
  end if;

  select * into v_invite from public.invites
   where code = v_invite_code
     for update;

  if not found then
    raise exception 'invalid invite';
  end if;

  if v_invite.disabled then
    raise exception 'invite disabled';
  end if;

  if v_invite.expires_at is not null and v_invite.expires_at < now() then
    raise exception 'invite expired';
  end if;

  if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then
    raise exception 'invite exhausted';
  end if;

  update public.invites
     set uses_count = uses_count + 1
   where code = v_invite.code;

  insert into public.profiles (user_id, username, display_name)
  values (new.id, v_username, v_display_name);

  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function public.handle_new_user();

-- ============================================================================
-- RPCs (SECURITY DEFINER) for flows too complex for RLS alone
-- ============================================================================

-- Create a DM with a target user. Returns conversation_id.
create or replace function public.create_dm(target_user_id uuid)
returns uuid language plpgsql security definer set search_path = public as $$
declare
  caller          uuid := auth.uid();
  existing_conv   uuid;
  new_conv        uuid;
  target_accepts  boolean;
  friends         boolean;
begin
  if caller is null then raise exception 'not authenticated'; end if;
  if caller = target_user_id then raise exception 'cannot DM self'; end if;

  -- Existing DM between these two?
  select c.id into existing_conv
  from public.conversations c
  join public.conversation_members m1 on m1.conversation_id = c.id and m1.user_id = caller
  join public.conversation_members m2 on m2.conversation_id = c.id and m2.user_id = target_user_id
  where c.type = 'dm'
  limit 1;

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

  friends := public.are_friends(caller, target_user_id);
  select allow_dms_from_strangers into target_accepts from public.profiles where user_id = target_user_id;

  if not friends and not coalesce(target_accepts, false) then
    raise exception 'target does not accept DMs from strangers';
  end if;

  insert into public.conversations (type, created_by) values ('dm', caller) returning id into new_conv;

  insert into public.conversation_members (conversation_id, user_id, role, accepted)
  values
    (new_conv, caller, 'member', true),
    (new_conv, target_user_id, 'member', friends);

  return new_conv;
end;
$$;

revoke all on function public.create_dm(uuid) from public;
grant execute on function public.create_dm(uuid) to authenticated;

-- Accept a pending DM request (flip own member row to accepted=true).
create or replace function public.accept_dm(conversation_id uuid)
returns void language plpgsql security definer set search_path = public as $$
begin
  update public.conversation_members
     set accepted = true
   where conversation_members.conversation_id = accept_dm.conversation_id
     and user_id = auth.uid();
  if not found then raise exception 'no pending DM found'; end if;
end;
$$;

revoke all on function public.accept_dm(uuid) from public;
grant execute on function public.accept_dm(uuid) to authenticated;

-- Redeem a group invite code.
create or replace function public.redeem_group_invite(code text)
returns uuid language plpgsql security definer set search_path = public as $$
declare
  v_invite public.group_invites%rowtype;
begin
  select * into v_invite from public.group_invites where group_invites.code = redeem_group_invite.code for update;
  if not found then raise exception 'invalid invite'; end if;
  if v_invite.disabled then raise exception 'invite disabled'; end if;
  if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'invite expired'; end if;
  if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'invite exhausted'; end if;

  insert into public.conversation_members (conversation_id, user_id, role, accepted)
  values (v_invite.conversation_id, auth.uid(), 'member', true)
  on conflict do nothing;

  update public.group_invites set uses_count = uses_count + 1 where group_invites.code = v_invite.code;

  return v_invite.conversation_id;
end;
$$;

revoke all on function public.redeem_group_invite(text) from public;
grant execute on function public.redeem_group_invite(text) to authenticated;

-- Send a friend request (normalises ordering).
create or replace function public.send_friend_request(target_user_id uuid)
returns void language plpgsql security definer set search_path = public as $$
declare
  lo uuid := least(auth.uid(), target_user_id);
  hi uuid := greatest(auth.uid(), target_user_id);
begin
  if auth.uid() is null then raise exception 'not authenticated'; end if;
  if auth.uid() = target_user_id then raise exception 'cannot befriend self'; end if;

  insert into public.friendships (user_lo, user_hi, requested_by, status)
  values (lo, hi, auth.uid(), 'pending')
  on conflict (user_lo, user_hi) do nothing;
end;
$$;

revoke all on function public.send_friend_request(uuid) from public;
grant execute on function public.send_friend_request(uuid) to authenticated;

-- ============================================================================
-- Realtime publications
-- ============================================================================
alter publication supabase_realtime add table public.messages;
alter publication supabase_realtime add table public.message_envelopes;
alter publication supabase_realtime add table public.message_reactions;
alter publication supabase_realtime add table public.message_reads;
alter publication supabase_realtime add table public.conversation_members;
alter publication supabase_realtime add table public.conversations;
alter publication supabase_realtime add table public.profiles;
alter publication supabase_realtime add table public.friendships;
