-- Ogham MCP Schema (Supabase Docker / self-hosted Supabase)
-- Use this instead of schema.sql when running Supabase Docker (self-hosted).
-- Uses public schema for pgvector (no extensions schema) + RLS with anon role.

-- Enable pgvector extension
create extension if not exists vector with schema public;

-- Memories table
create table if not exists memories (
    id uuid primary key default gen_random_uuid(),
    content text not null,
    embedding vector(512),
    metadata jsonb default '{}'::jsonb,
    source text,
    profile text not null default 'default',
    tags text[] default '{}',
    created_at timestamptz not null default now(),
    updated_at timestamptz not null default now(),
    expires_at timestamptz,
    access_count integer not null default 0,
    last_accessed_at timestamptz,
    confidence float not null default 0.5,
    importance float not null default 0.5,
    surprise float not null default 0.5,
    compression_level integer not null default 0,
    original_content text,
    occurrence_period tstzrange,
    recurrence_days int[],
    fts tsvector generated always as (to_tsvector('english', content)) stored
);

-- lz4 TOAST compression for text columns (faster decompress than default pglz)
alter table memories alter column content set compression lz4;
alter table memories alter column original_content set compression lz4;
alter table memories alter column metadata set compression lz4;

-- Enable RLS (service role key bypasses; ready for multi-user later)
alter table memories enable row level security;
alter table memories force row level security;

-- Deny access via anon key by default
create policy "Deny anon access" on memories
    for all to anon using (false) with check (false);

-- No authenticated policy: service_role bypasses RLS.
-- Add scoped policies here when building multi-user support.

-- HNSW index for fast cosine similarity search
create index if not exists memories_embedding_idx
    on memories using hnsw ((embedding::halfvec(512)) halfvec_cosine_ops)
    with (m = 16, ef_construction = 64);

-- GIN indexes for filtering
create index if not exists memories_metadata_idx on memories using gin (metadata jsonb_path_ops);
create index if not exists memories_tags_idx on memories using gin (tags);

-- GIN index for full-text search
create index if not exists memories_fts_idx on memories using gin (fts);

-- B-tree indexes
create index if not exists memories_profile_created_at_idx on memories (profile, created_at desc);
create index if not exists memories_source_idx on memories (source);

-- Partial index for expiration queries
create index if not exists memories_expires_at_idx on memories (expires_at)
    where expires_at is not null;

-- Temporal indexes (calendar/timeline)
create index if not exists idx_memories_occurrence on memories using gist (occurrence_period)
    where occurrence_period is not null;
create index if not exists idx_memories_recurrence on memories using gin (recurrence_days)
    where recurrence_days is not null;

-- Profile settings table for TTL configuration
create table if not exists profile_settings (
    profile text primary key,
    ttl_days integer check (ttl_days is null or ttl_days >= 1),
    created_at timestamptz not null default now(),
    updated_at timestamptz not null default now()
);

-- RLS for profile_settings
alter table profile_settings enable row level security;
alter table profile_settings force row level security;

create policy "Deny anon access" on profile_settings
    for all to anon using (false) with check (false);

-- No authenticated policy: service_role bypasses RLS.
-- Add scoped policies here when building multi-user support.

-- Relationship type enum
CREATE TYPE relationship_type AS ENUM (
    'similar',
    'related',
    'contradicts',
    'supports',
    'follows',
    'derived_from'
);

-- Edge table for memory relationships (knowledge graph)
CREATE TABLE memory_relationships (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    source_id uuid NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
    target_id uuid NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
    relationship relationship_type NOT NULL,
    strength float NOT NULL DEFAULT 1.0 CHECK (strength >= 0.0 AND strength <= 1.0),
    metadata jsonb DEFAULT '{}'::jsonb,
    created_by text NOT NULL DEFAULT 'auto',
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT unique_relationship UNIQUE (source_id, target_id, relationship)
);

-- RLS for memory_relationships
ALTER TABLE memory_relationships ENABLE ROW LEVEL SECURITY;
ALTER TABLE memory_relationships FORCE ROW LEVEL SECURITY;

CREATE POLICY "Deny anon access" ON memory_relationships
    FOR ALL TO anon USING (false) WITH CHECK (false);

-- Indexes: FK columns with composite for common query patterns
CREATE INDEX idx_relationships_source
    ON memory_relationships (source_id, relationship);

CREATE INDEX idx_relationships_target
    ON memory_relationships (target_id, relationship);

-- Partial index: auto-linked edges for maintenance queries
CREATE INDEX idx_relationships_auto
    ON memory_relationships (created_at)
    WHERE created_by = 'auto';

-- RPC: auto-link a new memory to similar existing memories
CREATE OR REPLACE FUNCTION auto_link_memory(
    new_memory_id uuid,
    new_embedding vector(512),
    link_threshold float DEFAULT 0.85,
    max_links int DEFAULT 5,
    filter_profile text DEFAULT 'default'
)
RETURNS integer
LANGUAGE sql
SECURITY INVOKER
SET search_path = public, extensions
AS $$
    WITH candidates AS (
        SELECT m.id, (1 - (m.embedding::halfvec(512) <=> new_embedding::halfvec(512)))::float AS similarity
        FROM memories m
        WHERE m.id != new_memory_id
          AND m.profile = filter_profile
          AND (m.expires_at IS NULL OR m.expires_at > now())
          AND 1 - (m.embedding::halfvec(512) <=> new_embedding::halfvec(512)) > link_threshold
        ORDER BY m.embedding::halfvec(512) <=> new_embedding::halfvec(512)
        LIMIT max_links
    ),
    inserted AS (
        INSERT INTO memory_relationships (source_id, target_id, relationship, strength, created_by)
        SELECT new_memory_id, c.id, 'similar', c.similarity, 'auto'
        FROM candidates c
        ON CONFLICT (source_id, target_id, relationship) DO NOTHING
        RETURNING 1
    )
    SELECT count(*)::integer FROM inserted;
$$;

-- RPC: bulk backfill auto-links for memories that have no outgoing auto edges
CREATE OR REPLACE FUNCTION link_unlinked_memories(
    filter_profile text DEFAULT 'default',
    link_threshold float DEFAULT 0.85,
    max_links int DEFAULT 5,
    batch_size int DEFAULT 100
)
RETURNS integer
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public, extensions
AS $$
DECLARE
    processed integer := 0;
    links integer;
    mem record;
BEGIN
    FOR mem IN
        SELECT m.id, m.embedding
        FROM memories m
        WHERE m.profile = filter_profile
          AND m.embedding IS NOT NULL
          AND (m.expires_at IS NULL OR m.expires_at > now())
          AND NOT EXISTS (
              SELECT 1 FROM memory_relationships mr
              WHERE mr.source_id = m.id AND mr.created_by = 'auto'
          )
        LIMIT batch_size
    LOOP
        SELECT auto_link_memory(mem.id, mem.embedding, link_threshold, max_links, filter_profile) INTO links;
        IF links > 0 THEN
            processed := processed + 1;
        END IF;
    END LOOP;
    RETURN processed;
END;
$$;

-- Auto-update updated_at trigger
create or replace function update_updated_at()
returns trigger
language plpgsql
security invoker
set search_path = public
as $$
begin
    new.updated_at = now();
    return new;
end;
$$;

drop trigger if exists memories_updated_at on memories;
create trigger memories_updated_at
    before update on memories
    for each row
    execute function update_updated_at();

drop trigger if exists profile_settings_updated_at on profile_settings;
create trigger profile_settings_updated_at
    before update on profile_settings
    for each row
    execute function update_updated_at();

-- RPC: clean up expired memories for a profile
create or replace function cleanup_expired_memories(target_profile text)
returns integer
language plpgsql
security invoker
set search_path = public
as $$
declare
    deleted_count integer;
begin
    delete from memories
    where profile = target_profile
      and expires_at is not null
      and expires_at < now();
    get diagnostics deleted_count = row_count;
    return deleted_count;
end;
$$;

-- RPC: count expired memories for a profile (preview before cleanup)
create or replace function count_expired_memories(target_profile text)
returns integer
language plpgsql
security invoker
set search_path = public
as $$
declare
    expired_count integer;
begin
    select count(*)::integer into expired_count
    from memories
    where profile = target_profile
      and expires_at is not null
      and expires_at < now();
    return expired_count;
end;
$$;

-- RPC function for cosine similarity search with ACT-R temporal scoring
create or replace function match_memories(
    query_embedding vector(512),
    match_threshold float default 0.7,
    match_count int default 10,
    filter_tags text[] default null,
    filter_source text default null,
    filter_profile text default 'default'
)
returns table (
    id uuid,
    content text,
    metadata jsonb,
    source text,
    profile text,
    tags text[],
    similarity float,
    relevance float,
    access_count integer,
    last_accessed_at timestamptz,
    confidence float,
    created_at timestamptz,
    updated_at timestamptz
)
language plpgsql
security invoker
set search_path = public, extensions
as $$
begin
    return query
    select
        m.id,
        m.content,
        m.metadata,
        m.source,
        m.profile,
        m.tags,
        (1 - (m.embedding::halfvec(512) <=> query_embedding::halfvec(512)))::float as similarity,
        -- Relevance = similarity * softplus(ACT-R) * confidence * graph_boost
        -- ACT-R: B(M) = ln(n+1) - 0.5 * ln(ageDays / (n+1))
        -- softplus: ln(1 + exp(B)) keeps score positive
        -- graph_boost: (1 + sum(relationship_strength) * 0.2)
        (
            (1 - (m.embedding::halfvec(512) <=> query_embedding::halfvec(512))) *
            ln(1.0 + exp(
                ln(m.access_count + 1.0) -
                0.5 * ln(
                    greatest(
                        extract(epoch from now() - coalesce(m.last_accessed_at, m.created_at)) / 86400.0,
                        0.01
                    ) / (m.access_count + 1.0)
                )
            ))
        * m.confidence
        * (1.0 + g.graph_boost * 0.2)
        )::float as relevance,
        m.access_count,
        m.last_accessed_at,
        m.confidence,
        m.created_at,
        m.updated_at
    from public.memories m
    left join lateral (
        select coalesce(sum(r.strength), 0.0) as graph_boost
        from memory_relationships r
        where r.target_id = m.id or r.source_id = m.id
    ) g on true
    where
        1 - (m.embedding::halfvec(512) <=> query_embedding::halfvec(512)) > match_threshold
        and (filter_tags is null or m.tags && filter_tags)
        and (filter_source is null or m.source = filter_source)
        and m.profile = filter_profile
        and (m.expires_at is null or m.expires_at > now())
    order by relevance desc
    limit match_count;
end;
$$;

-- RPC: hybrid search combining semantic (pgvector) and keyword (tsvector) via RRF
create or replace function hybrid_search_memories(
    query_text text,
    query_embedding vector(512),
    match_count int default 10,
    filter_profile text default 'default',
    filter_tags text[] default null,
    filter_source text default null,
    full_text_weight float default 1.0,
    semantic_weight float default 1.0,
    rrf_k int default 60
)
returns table (
    id uuid,
    content text,
    metadata jsonb,
    source text,
    profile text,
    tags text[],
    similarity float,
    keyword_rank float,
    relevance float,
    access_count integer,
    last_accessed_at timestamptz,
    confidence float,
    created_at timestamptz,
    updated_at timestamptz
)
language sql
security invoker
set search_path = public, extensions
as $$
with semantic as (
    select
        m.id,
        row_number() over (order by m.embedding::halfvec(512) <=> query_embedding::halfvec(512)) as rank_ix,
        (1 - (m.embedding::halfvec(512) <=> query_embedding::halfvec(512)))::float as similarity
    from memories m
    where m.profile = filter_profile
      and (filter_tags is null or m.tags && filter_tags)
      and (filter_source is null or m.source = filter_source)
      and (m.expires_at is null or m.expires_at > now())
    order by m.embedding::halfvec(512) <=> query_embedding::halfvec(512)
    limit match_count * 2
),
keyword as (
    select
        m.id,
        row_number() over (order by ts_rank_cd(m.fts, websearch_to_tsquery(query_text), 34) desc) as rank_ix,
        ts_rank_cd(m.fts, websearch_to_tsquery(query_text), 34)::float as keyword_rank
    from memories m
    where m.profile = filter_profile
      and m.fts @@ websearch_to_tsquery(query_text)
      and (filter_tags is null or m.tags && filter_tags)
      and (filter_source is null or m.source = filter_source)
      and (m.expires_at is null or m.expires_at > now())
    order by keyword_rank desc
    limit match_count * 2
),
combined as (
    select id, rank_ix, similarity, 0.0::float as keyword_rank, 'semantic' as src
    from semantic
    union all
    select id, rank_ix, 0.0::float as similarity, keyword_rank, 'keyword' as src
    from keyword
),
fused as (
    select
        c.id,
        max(c.similarity) as similarity,
        max(c.keyword_rank) as keyword_rank,
        sum(
            case when c.src = 'semantic' then semantic_weight / (rrf_k + c.rank_ix)
                 when c.src = 'keyword'  then full_text_weight / (rrf_k + c.rank_ix)
                 else 0.0 end
        ) as rrf_score
    from combined c
    group by c.id
)
-- Relevance = rrf_score * softplus(ACT-R) * confidence * graph_boost
-- graph_boost: (1 + sum(relationship_strength) * 0.2)
select
    m.id,
    m.content,
    m.metadata,
    m.source,
    m.profile,
    m.tags,
    f.similarity,
    f.keyword_rank,
    (
        f.rrf_score
        * ln(1.0 + exp(
            ln(m.access_count + 1.0) -
            0.5 * ln(
                greatest(
                    extract(epoch from now() - coalesce(m.last_accessed_at, m.created_at)) / 86400.0,
                    0.01
                ) / (m.access_count + 1.0)
            )
        ))
        * m.confidence
        * (1.0 + g.graph_boost * 0.2)
    )::float as relevance,
    m.access_count,
    m.last_accessed_at,
    m.confidence,
    m.created_at,
    m.updated_at
from fused f
join memories m on m.id = f.id
left join lateral (
    select coalesce(sum(r.strength), 0.0) as graph_boost
    from memory_relationships r
    where r.target_id = m.id or r.source_id = m.id
) g on true
order by relevance desc
limit match_count;
$$;

-- RPC: record access for memories returned by search
create or replace function record_access(memory_ids uuid[])
returns void
language plpgsql
security invoker
set search_path = public
as $$
begin
    update memories
    set access_count = access_count + 1,
        last_accessed_at = now()
    where id = any(memory_ids);
end;
$$;

-- RPC: Bayesian confidence update
-- signal: 0.85 = reinforce, 0.15 = contradict, 0.5 = neutral
create or replace function update_confidence(
    memory_id uuid,
    signal float,
    memory_profile text
)
returns float
language plpgsql
security invoker
set search_path = public
as $$
declare
    current_conf float;
    posterior float;
    new_conf float;
begin
    select confidence into current_conf
    from memories
    where id = memory_id and profile = memory_profile;

    if not found then
        raise exception 'Memory % not found in profile %', memory_id, memory_profile;
    end if;

    posterior := (current_conf * signal) /
                (current_conf * signal + (1.0 - current_conf) * (1.0 - signal));
    new_conf := 0.95 * posterior + 0.025;

    update memories
    set confidence = new_conf
    where id = memory_id and profile = memory_profile;

    return new_conf;
end;
$$;

-- RPC: batch update embeddings (reduces N+1 round trips in re_embed_all)
create or replace function batch_update_embeddings(
    memory_ids uuid[],
    new_embeddings vector(512)[]
)
returns integer
language plpgsql
security invoker
set search_path = public, extensions
as $$
declare
    updated_count integer;
begin
    update memories m
    set embedding = u.emb,
        updated_at = now()
    from unnest(memory_ids, new_embeddings) as u(id, emb)
    where m.id = u.id;

    get diagnostics updated_count = row_count;
    return updated_count;
end;
$$;

-- Batch duplicate checking for import dedup.
-- Accepts an array of embeddings and returns a boolean array
-- indicating whether each embedding has a match above threshold.
-- Uses a simple cosine similarity check (no ACT-R scoring needed for dedup).
create or replace function batch_check_duplicates(
    query_embeddings vector(512)[],
    match_threshold float default 0.8,
    filter_profile text default 'default'
)
returns boolean[]
language plpgsql
security invoker
set search_path = public, extensions
as $$
declare
    results boolean[];
    i integer;
    found boolean;
begin
    -- Lower ef_search for dedup: we only need "is there a match?", not high recall
    perform set_config('hnsw.ef_search', '40', true);
    results := array[]::boolean[];
    for i in 1..array_length(query_embeddings, 1) loop
        select exists(
            select 1 from memories m
            where m.profile = filter_profile
              and (m.expires_at is null or m.expires_at > now())
              and 1 - (m.embedding::halfvec(512) <=> query_embeddings[i]::halfvec(512)) > match_threshold
            limit 1
        ) into found;
        results := array_append(results, found);
    end loop;
    return results;
end;
$$;

-- RPC function for profile counts (replaces Python-side counting)
create or replace function get_profile_counts()
returns table (profile text, count bigint)
language plpgsql
security invoker
set search_path = public
as $$
begin
    return query
    select m.profile, count(*) as count
    from public.memories m
    where m.expires_at is null or m.expires_at > now()
    group by m.profile
    order by m.profile;
end;
$$;

-- RPC function for memory stats (replaces 3 queries + Python counting)
create or replace function get_memory_stats_sql(filter_profile text default 'default')
returns jsonb
language plpgsql
security invoker
set search_path = public
as $$
declare
    result jsonb;
begin
    select jsonb_build_object(
        'profile', filter_profile,
        'total', (
            select count(*) from memories
            where profile = filter_profile
              and (expires_at is null or expires_at > now())
        ),
        'sources', (
            select jsonb_object_agg(source, cnt)
            from (
                select coalesce(source, 'unknown') as source, count(*) as cnt
                from memories
                where profile = filter_profile
                  and (expires_at is null or expires_at > now())
                group by source
            ) s
        ),
        'top_tags', (
            select jsonb_agg(jsonb_build_object('tag', tag, 'count', cnt))
            from (
                select tag, count(*) as cnt
                from memories, unnest(tags) as tag
                where profile = filter_profile
                  and (expires_at is null or expires_at > now())
                group by tag
                order by cnt desc
                limit 20
            ) t
        )
    ) into result;

    return result;
end;
$$;

-- RPC: explore knowledge graph — hybrid search seeds + relationship traversal
CREATE OR REPLACE FUNCTION explore_memory_graph(
    query_text text,
    query_embedding vector(512),
    filter_profile text DEFAULT 'default',
    match_count int DEFAULT 5,
    traversal_depth int DEFAULT 1,
    min_strength float DEFAULT 0.5,
    filter_tags text[] DEFAULT NULL,
    filter_source text DEFAULT NULL
)
RETURNS TABLE (
    id uuid,
    content text,
    metadata jsonb,
    source text,
    tags text[],
    relevance float,
    depth int,
    relationship text,
    edge_strength float,
    connected_from uuid
)
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public, extensions
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE
    seeds AS (
        SELECT h.id, h.relevance
        FROM hybrid_search_memories(
            query_text, query_embedding, match_count,
            filter_profile, filter_tags, filter_source
        ) h
    ),
    graph AS (
        SELECT s.id, 0 AS depth, NULL::relationship_type AS rel,
               NULL::float AS edge_strength, NULL::uuid AS connected_from,
               s.relevance
        FROM seeds s
        UNION ALL
        SELECT
            CASE WHEN mr.source_id = g.id THEN mr.target_id ELSE mr.source_id END,
            g.depth + 1,
            mr.relationship,
            mr.strength,
            g.id,
            (g.relevance * mr.strength)::float
        FROM graph g
        JOIN memory_relationships mr
            ON (mr.source_id = g.id OR mr.target_id = g.id)
        WHERE g.depth < traversal_depth
          AND mr.strength >= min_strength
    ),
    deduped AS (
        SELECT DISTINCT ON (g.id)
            g.id, g.depth, g.rel, g.edge_strength, g.connected_from, g.relevance
        FROM graph g
        ORDER BY g.id, g.relevance DESC
    )
    SELECT
        m.id, m.content, m.metadata, m.source, m.tags,
        d.relevance::float,
        d.depth,
        d.rel::text AS relationship,
        d.edge_strength,
        d.connected_from
    FROM deduped d
    JOIN memories m ON m.id = d.id
    WHERE (m.expires_at IS NULL OR m.expires_at > now())
    ORDER BY d.depth ASC, d.relevance DESC;
END;
$$;

-- RPC: traverse relationship graph from a known memory ID
CREATE OR REPLACE FUNCTION get_related_memories(
    start_id uuid,
    max_depth int DEFAULT 1,
    min_strength float DEFAULT 0.5,
    filter_types relationship_type[] DEFAULT NULL,
    result_limit int DEFAULT 20
)
RETURNS TABLE (
    id uuid,
    content text,
    metadata jsonb,
    source text,
    tags text[],
    confidence float,
    depth int,
    relationship text,
    edge_strength float,
    connected_from uuid
)
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
BEGIN
    RETURN QUERY
    WITH RECURSIVE graph AS (
        SELECT start_id AS id, 0 AS depth,
               NULL::relationship_type AS rel,
               NULL::float AS edge_strength,
               NULL::uuid AS connected_from
        UNION ALL
        SELECT
            CASE WHEN mr.source_id = g.id THEN mr.target_id ELSE mr.source_id END,
            g.depth + 1,
            mr.relationship,
            mr.strength,
            g.id
        FROM graph g
        JOIN memory_relationships mr
            ON (mr.source_id = g.id OR mr.target_id = g.id)
        WHERE g.depth < max_depth
          AND mr.strength >= min_strength
          AND (filter_types IS NULL OR mr.relationship = ANY(filter_types))
    ),
    deduped AS (
        SELECT DISTINCT ON (g.id) g.*
        FROM graph g
        WHERE g.id != start_id
        ORDER BY g.id, g.depth ASC, g.edge_strength DESC NULLS LAST
    )
    SELECT
        m.id, m.content, m.metadata, m.source, m.tags, m.confidence,
        d.depth, d.rel::text, d.edge_strength, d.connected_from
    FROM deduped d
    JOIN memories m ON m.id = d.id
    WHERE (m.expires_at IS NULL OR m.expires_at > now())
    ORDER BY d.depth ASC, d.edge_strength DESC
    LIMIT result_limit;
END;
$$;
