CREATE TABLE IF NOT EXISTS projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  path TEXT NOT NULL UNIQUE,
  added_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  last_session_id UUID
);

CREATE TABLE IF NOT EXISTS sessions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  model TEXT NOT NULL,
  system_prompt TEXT NOT NULL DEFAULT '',
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);

CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);

CREATE TABLE IF NOT EXISTS messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  role TEXT NOT NULL,
  content TEXT NOT NULL DEFAULT '',
  tool_calls JSONB,
  tool_results JSONB,
  status TEXT NOT NULL DEFAULT 'complete',
  last_seq INT NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);

CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);

-- v1.13.0: granular message parts table for AI SDK migration. Old
-- messages.content / tool_calls / tool_results columns stay authoritative
-- for reads in v1.13.0; this table is dual-written so the swap can happen
-- in a later dispatch without a backfill window. ON DELETE CASCADE means
-- removing a message removes its parts in one go.
CREATE TABLE IF NOT EXISTS message_parts (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
  sequence int NOT NULL,
  kind text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
  CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start')),
  CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
);
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);

ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS finished_at TIMESTAMPTZ;

ALTER TABLE sessions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp();

CREATE TABLE IF NOT EXISTS settings (
  key TEXT PRIMARY KEY,
  value JSONB NOT NULL
);

INSERT INTO settings (key, value) VALUES ('default_model', '"qwen3.6-35b-a3b-mxfp4"') ON CONFLICT (key) DO NOTHING;

-- v1.12.1: deprecated session_panes table removed. Workspace pane state now
-- lives in sessions.workspace_panes (jsonb), see below.
DROP TABLE IF EXISTS session_panes;

-- v1.12.1: server-side workspace pane layout, replaces localStorage so every
-- device sees the same panes for a given session. Shape matches
-- WorkspacePane[] from apps/server/src/types/api.ts.
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS workspace_panes JSONB NOT NULL DEFAULT '[]'::jsonb;

-- v1.2: sessions.status (open | archived)
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';

-- v1.2: chats table
CREATE TABLE IF NOT EXISTS chats (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id   UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  name         TEXT,
  status       TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'archived')),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS idx_chats_session_status ON chats (session_id, status, updated_at DESC);

-- v1.2: messages.chat_id + messages.kind
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'message';
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages (chat_id, created_at);

-- Backfill: one chat per existing session that has none yet
INSERT INTO chats (session_id, name, status, created_at, updated_at)
SELECT s.id, s.name, 'open', s.created_at, s.updated_at
FROM sessions s
WHERE NOT EXISTS (
  SELECT 1 FROM chats c WHERE c.session_id = s.id
);

-- Backfill: link orphaned messages to their session's first chat
UPDATE messages SET chat_id = (
  SELECT c.id FROM chats c WHERE c.session_id = messages.session_id ORDER BY c.created_at ASC LIMIT 1
)
WHERE chat_id IS NULL;

-- Enforce NOT NULL on chat_id once all rows are backfilled
DO $$
BEGIN
  IF EXISTS (
    SELECT 1 FROM information_schema.columns
    WHERE table_name = 'messages' AND column_name = 'chat_id' AND is_nullable = 'YES'
  ) AND NOT EXISTS (
    SELECT 1 FROM messages WHERE chat_id IS NULL
  ) THEN
    ALTER TABLE messages ALTER COLUMN chat_id SET NOT NULL;
  END IF;
END $$;

-- v1.2.1: CHECK constraints for sessions.status and messages (role, status)
-- KEEP IN SYNC: apps/server/src/types/api.ts (MESSAGE_ROLES, MESSAGE_STATUSES, SessionStatus)
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'sessions_status_chk') THEN
    ALTER TABLE sessions ADD CONSTRAINT sessions_status_chk
      CHECK (status IN ('open', 'archived'));
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_chk') THEN
    ALTER TABLE messages ADD CONSTRAINT messages_role_chk
      CHECK (role IN ('user', 'assistant', 'system', 'tool'));
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_chk') THEN
    ALTER TABLE messages ADD CONSTRAINT messages_status_chk
      CHECK (status IN ('streaming', 'complete', 'failed', 'cancelled'));
  END IF;
END $$;

-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
-- named *_chk variants above. messages_status_check missed 'cancelled' and
-- messages_role_check missed 'system' — both narrower than what's in use.
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
    ALTER TABLE messages DROP CONSTRAINT messages_status_check;
  END IF;
  IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
    ALTER TABLE messages DROP CONSTRAINT messages_role_check;
  END IF;
END $$;

-- v1.2-project-ux: projects.status + projects.gitea_remote
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS gitea_remote TEXT;
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'projects_status_chk') THEN
    ALTER TABLE projects ADD CONSTRAINT projects_status_chk
      CHECK (status IN ('open', 'archived'));
  END IF;
END $$;

-- v1.3-tab-close-chat-archive: align chats.status vocabulary with projects ('archived' not 'closed')
-- KEEP IN SYNC: apps/server/src/types/api.ts CHAT_STATUSES
-- Order matters: (1) drop the OLD inline CHECK that only allowed ('open','closed');
-- (2) migrate existing rows; (3) add new named CHECK allowing ('open','archived').
ALTER TABLE chats DROP CONSTRAINT IF EXISTS chats_status_check;
UPDATE chats SET status = 'archived' WHERE status = 'closed';
DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chats_status_chk') THEN
    ALTER TABLE chats ADD CONSTRAINT chats_status_chk
      CHECK (status IN ('open', 'archived'));
  END IF;
END $$;

-- v1.x-batch9: per-session agent reference. Agent definitions are not stored in
-- the DB; they live in builtins (services/agents.ts) and a per-project AGENTS.md.
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;

-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
-- reasons. JSONB so future kinds can extend without further schema churn.
-- Shape for cap_hit:  { kind: 'cap_hit', used: number, limit: number,
--                       agent_name: string|null, can_continue: boolean }
-- Shape for errors:   { error_reason: 'llm_provider_error'|..., error_text: string }
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;

-- themes-v1: idempotent seeds for the two theme preference keys. The settings
-- table is a key/value store (see line 43) so theme prefs live as two rows,
-- not new columns. Defaults match docs/themes_v1.md: obsidian (dark).
INSERT INTO settings (key, value) VALUES ('theme_id', '"obsidian"') ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value) VALUES ('theme_mode', '"dark"') ON CONFLICT (key) DO NOTHING;

-- v1.9: per-project defaults that new sessions inherit, plus a per-session
-- web-search override. Empty string on either prompt column means "inherit"
-- (resolved in services/system-prompt.ts buildSystemPrompt). web_search_enabled is the
-- only tri-state field: null on session = inherit from project default.
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_system_prompt TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS default_web_search_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS web_search_enabled BOOLEAN;

-- v1.11: anchored rolling compaction.
--   compacted_at  — marks rows that are "behind the curtain" of the latest
--                   summary. Inference assembly filters compacted_at IS NULL;
--                   the API GET still returns all rows so the UI can show
--                   history with the summary card inline.
--   summary       — true on the assistant row that IS the anchored summary.
--                   Exactly one row per chat is the "current" summary
--                   (every prior summary row is itself compacted_at-stamped
--                   when superseded, leaving one live anchor).
--   tail_start_id — points at the first preserved message that the summary
--                   covers up to (exclusive). Lets the UI/debug reason about
--                   the boundary without re-deriving from compacted_at.
--   needs_compaction — flag on chats (not sessions) because chat history is
--                   per-chat; sessions have 1:N chats. Set true post-overflow,
--                   cleared by compaction.process at the start of the next
--                   inference turn.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS compacted_at TIMESTAMPTZ;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS summary BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tail_start_id UUID REFERENCES messages(id) ON DELETE SET NULL;
ALTER TABLE chats ADD COLUMN IF NOT EXISTS needs_compaction BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_messages_chat_compacted ON messages (chat_id, compacted_at);
