-- Migration 010: full-text search — tsvector columns + GIN indexes + search function
--
-- Search uses `simple` text-search config (no stemming) — the app is bilingual
-- (en + es) and a stemmer tuned to one language degrades the other. Clients can
-- still match partial words by appending `:*` to the query.
--
-- Each domain table (`shopping_items`, `tasks`, `notes`) gets a STORED GENERATED
-- `search` column backed by a GIN index. Generated-always means Postgres keeps
-- the vector in sync with every INSERT/UPDATE — no trigger needed.
--
-- `search_in_collective()` runs SECURITY INVOKER so RLS still applies. Trash is
-- excluded per RN-08 (notes via `deleted_at IS NULL`; items via the parent list's
-- `deleted_at`; tasks have no trash).

-- ── Generated tsvector columns ───────────────────────────────────────────────

ALTER TABLE public.shopping_items
    ADD COLUMN search tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED;

CREATE INDEX shopping_items_search_idx ON public.shopping_items USING GIN (search);

ALTER TABLE public.tasks
    ADD COLUMN search tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, ''))) STORED;

CREATE INDEX tasks_search_idx ON public.tasks USING GIN (search);

ALTER TABLE public.notes
    ADD COLUMN search tsvector
    GENERATED ALWAYS AS (
        to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(content, ''))
    ) STORED;

CREATE INDEX notes_search_idx ON public.notes USING GIN (search);

-- ── Search function ──────────────────────────────────────────────────────────

CREATE TYPE public.search_result_type AS ENUM ('shopping_item', 'task', 'note');

DROP FUNCTION IF EXISTS public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz);

CREATE OR REPLACE FUNCTION public.search_in_collective(
    p_collective_id uuid,
    p_query         text,
    p_types         public.search_result_type[] DEFAULT NULL,
    p_creator       uuid                         DEFAULT NULL,
    p_from          timestamptz                  DEFAULT NULL,
    p_to            timestamptz                  DEFAULT NULL
)
RETURNS TABLE (
    id            uuid,
    result_type   public.search_result_type,
    title         text,
    snippet       text,
    collective_id uuid,
    list_id       uuid,
    created_by    uuid,
    created_at    timestamptz,
    rank          real
)
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path = public
AS $$
    WITH q AS (
        SELECT websearch_to_tsquery('simple', coalesce(p_query, '')) AS query
    )
    -- shopping_items
    SELECT
        si.id,
        'shopping_item'::public.search_result_type,
        si.name                                   AS title,
        si.name                                   AS snippet,
        sl.collective_id,
        sl.id                                     AS list_id,
        si.created_by,
        si.created_at,
        ts_rank(si.search, q.query)::real         AS rank
    FROM       public.shopping_items si
    JOIN       public.shopping_lists sl ON sl.id = si.list_id
    CROSS JOIN q
    WHERE      sl.collective_id = p_collective_id
      AND      sl.deleted_at IS NULL
      AND      (p_types IS NULL OR 'shopping_item' = ANY (p_types))
      AND      (p_creator IS NULL OR si.created_by = p_creator)
      AND      (p_from IS NULL OR si.created_at >= p_from)
      AND      (p_to   IS NULL OR si.created_at <= p_to)
      AND      si.search @@ q.query

    UNION ALL

    -- tasks
    SELECT
        t.id,
        'task'::public.search_result_type,
        t.title                                   AS title,
        t.title                                   AS snippet,
        tl.collective_id,
        tl.id                                     AS list_id,
        t.created_by,
        t.created_at,
        ts_rank(t.search, q.query)::real          AS rank
    FROM       public.tasks t
    JOIN       public.task_lists tl ON tl.id = t.list_id
    CROSS JOIN q
    WHERE      tl.collective_id = p_collective_id
      AND      (p_types IS NULL OR 'task' = ANY (p_types))
      AND      (p_creator IS NULL OR t.created_by = p_creator)
      AND      (p_from IS NULL OR t.created_at >= p_from)
      AND      (p_to   IS NULL OR t.created_at <= p_to)
      AND      t.search @@ q.query

    UNION ALL

    -- notes (trash and permanently deleted excluded by deleted_at filter; per
    -- analysis RN-08 search does not return trashed content)
    SELECT
        n.id,
        'note'::public.search_result_type,
        n.title                                   AS title,
        left(coalesce(n.content, ''), 160)        AS snippet,
        n.collective_id,
        NULL::uuid                                AS list_id,
        n.created_by,
        n.created_at,
        ts_rank(n.search, q.query)::real          AS rank
    FROM       public.notes n
    CROSS JOIN q
    WHERE      n.collective_id = p_collective_id
      AND      n.deleted_at IS NULL
      AND      (p_types IS NULL OR 'note' = ANY (p_types))
      AND      (p_creator IS NULL OR n.created_by = p_creator)
      AND      (p_from IS NULL OR n.created_at >= p_from)
      AND      (p_to   IS NULL OR n.created_at <= p_to)
      AND      n.search @@ q.query

    ORDER BY rank DESC, created_at DESC
    LIMIT 200;
$$;

-- Allow the anon + authenticated roles to call the function (RLS still filters rows).
GRANT EXECUTE ON FUNCTION public.search_in_collective(uuid, text, public.search_result_type[], uuid, timestamptz, timestamptz)
    TO anon, authenticated;
