-- 022_knowledge_revisions.up.sql
-- Version history for knowledge_entities, so an edit can never be silently lost.
--
-- The concrete hazard this closes: the MCP tool `upsert_knowledge`
-- (internal/mcp/server.go) keys on title and does
-- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` —
-- unconditionally. Before this migration, an operator hand-editing a note in
-- the web UI would have that edit overwritten with no trace the next time
-- Nomos re-upserted a note with the same title. There was no history table
-- and no way to recover the prior body.
--
-- The snapshot is a BEFORE UPDATE **trigger** rather than application-level
-- code in the HTTP handler, specifically because there are two independent
-- writers: the web API (new in this change) and the MCP tool the agent uses.
-- App-level snapshotting would only cover whichever path remembered to call
-- it. A trigger covers both, plus any future writer and any manual psql fix.
--
-- Each row in knowledge_revisions is a *superseded* version: the state of the
-- note before the update that displaced it. The current version always lives
-- in knowledge_entities, never here, so "history" is
-- knowledge_entities + knowledge_revisions ordered by version_at DESC.

-- Who authored the version currently in knowledge_entities. Distinct from
-- `source`, which is overloaded: it holds either 'nomos-agent' (written via
-- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on
-- conflict, so a seeded doc later rewritten by the agent still reports its
-- original file path. edited_by answers the question the UI actually asks —
-- "did a human or the agent last touch this?" — without disturbing source,
-- which the seeding logic still relies on.
ALTER TABLE knowledge_entities
  ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT '';

-- Backfill: every existing row's last writer is whatever source says. For
-- agent-written notes that's exactly right; for seeded notes it records the
-- seed path, which is the honest answer (no human has edited them yet).
UPDATE knowledge_entities
SET edited_by = COALESCE(source, '')
WHERE edited_by = '';

-- Soft delete. A hard DELETE would cascade knowledge_revisions away with the
-- entity, which contradicts the point of this migration — removing a note is
-- exactly the moment its history matters most. Deleting sets deleted_at; all
-- read paths filter it out, the revision trail survives, and an accidental
-- delete is recoverable by clearing the column.
ALTER TABLE knowledge_entities
  ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;

-- Partial index: every list/search/read query carries `deleted_at IS NULL`,
-- and deleted notes are expected to stay a small minority.
CREATE INDEX IF NOT EXISTS idx_knowledge_live
  ON knowledge_entities (updated_at DESC)
  WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS knowledge_revisions (
    id         BIGSERIAL PRIMARY KEY,
    entity_id  UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
    title      TEXT NOT NULL,
    content    TEXT NOT NULL,
    source     TEXT,
    tags       TEXT[],
    edited_by  TEXT NOT NULL DEFAULT '',
    -- When this version was written (the superseded row's updated_at).
    version_at TIMESTAMPTZ NOT NULL,
    -- When it was replaced. version_at of revision N and revised_at of
    -- revision N-1 bracket how long that version was the live one.
    revised_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- The only access pattern: "show me the history of this note, newest first."
CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity
  ON knowledge_revisions (entity_id, version_at DESC);

-- Snapshot the outgoing row whenever the substance changes. Deliberately
-- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()`
-- on every call even when re-writing byte-identical content (it has no
-- change detection), and without this guard a re-run of the same agent task
-- would pile up identical revisions and bury the real edits.
--
-- `search` is a GENERATED column and is intentionally not carried into
-- revisions — it is derived from title+content and would be dead weight.
CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$
BEGIN
    IF OLD.title   IS DISTINCT FROM NEW.title
    OR OLD.content IS DISTINCT FROM NEW.content
    OR OLD.tags    IS DISTINCT FROM NEW.tags THEN
        INSERT INTO knowledge_revisions
            (entity_id, title, content, source, tags, edited_by, version_at)
        VALUES
            (OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags,
             OLD.edited_by, OLD.updated_at);
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no
-- CREATE OR REPLACE TRIGGER for this form, and the migration must stay
-- re-runnable.
DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities;

CREATE TRIGGER trg_knowledge_revision
    BEFORE UPDATE ON knowledge_entities
    FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision();

-- Trigram similarity, for the duplicate-detection view. The knowledge base
-- has already accumulated near-duplicates that exact matching cannot catch —
-- four separate "rclone backup live inspection — <date>" investigations, each
-- a fresh note where an update to the existing one was meant. upsert_knowledge
-- keys on exact title, so a date suffix is enough to fork a new note.
--
-- similarity() over titles is what lets the UI cluster those and offer a
-- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here
-- because these titles differ by whole appended words rather than typos, and
-- because it comes with a GIN index while levenshtein cannot be indexed.
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm
  ON knowledge_entities USING gin (title gin_trgm_ops)
  WHERE deleted_at IS NULL;
