-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
-- Applied on startup by apps/coder/src/db.ts:applySchema().
-- Lives in the same 'boochat' database as BooChat's tables.

CREATE TABLE IF NOT EXISTS pending_changes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id UUID NOT NULL,
  task_id UUID,
  file_path TEXT NOT NULL,
  operation TEXT NOT NULL,
  diff TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
  CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
);

CREATE TABLE IF NOT EXISTS tasks (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL,
  parent_task_id UUID REFERENCES tasks(id),
  state TEXT NOT NULL DEFAULT 'pending',
  input TEXT NOT NULL,
  output_summary TEXT,
  agent TEXT,
  model TEXT,
  execution_path TEXT,
  cost_tokens INTEGER,
  started_at TIMESTAMPTZ,
  ended_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
  CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'))
);

CREATE TABLE IF NOT EXISTS available_agents (
  name TEXT PRIMARY KEY,
  install_path TEXT,
  version TEXT,
  supports_acp BOOLEAN NOT NULL DEFAULT false,
  last_probed_at TIMESTAMPTZ
);
ALTER TABLE available_agents DROP COLUMN IF EXISTS supports_mcp_client;

-- v2.0.0 Phase 4: link tasks to their inference sessions.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);

-- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column.
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk;
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN
    ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk
      CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'));
  END IF;
END $$;

-- v2.0.5: arena support — group tasks into competitive arenas.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;

-- Human inbox: tasks needing attention
CREATE OR REPLACE VIEW human_inbox AS
  SELECT * FROM tasks WHERE state IN ('blocked', 'failed');

-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
-- an agent's live command set survives the tier-2 probe skip and shows without a
-- dispatch.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;

-- v2.2.0: Paseo-style session config on tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
-- tasks.feature_values and tasks.worktree_path were never read or written by any
-- code path; drop them from existing DBs (fresh DBs never had them in the CREATE).
-- human_inbox is `SELECT *` over tasks, so it pins every task column — dropping a
-- column while the view exists fails (2BP01). Drop the view, drop the columns, then
-- recreate it with the current column set (idempotent on fresh + existing DBs).
DROP VIEW IF EXISTS human_inbox;
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values;
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path;
CREATE OR REPLACE VIEW human_inbox AS
  SELECT * FROM tasks WHERE state IN ('blocked', 'failed');

-- v2.6: one backend session per (session, agent); resumed on switch-back.
CREATE TABLE IF NOT EXISTS agent_sessions (
  session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  agent TEXT NOT NULL,
  backend TEXT NOT NULL,
  agent_session_id TEXT,
  server_port INTEGER,
  status TEXT NOT NULL DEFAULT 'idle',
  last_active_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  PRIMARY KEY (session_id, agent),
  CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')),
  CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
);

-- Migrate existing agent_sessions FK to CASCADE.
DO $$ BEGIN
  IF EXISTS (
    SELECT 1 FROM pg_constraint
    WHERE conname = 'agent_sessions_session_id_fkey'
      AND confdeltype <> 'c'
  ) THEN
    ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
      FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
  END IF;
END $$;

-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;

-- v2.6 Phase 1-UX (U.6): opencode token/cost usage, ACCUMULATED per (chat_id, agent).
-- opencode's warm server emits `session.next.step.ended` once per LLM step (several
-- per multi-tool turn) carrying {tokens{input,output,reasoning,cache},cost}. We sum
-- each step's normalized {input,output,cost} onto the session row — running totals
-- for the whole conversation context, not last-step. Backend-only; no route/UI yet.
-- input_tokens folds in cache read+write; output_tokens folds in reasoning (see
-- backends/opencode-usage.ts). Defaults 0 so accumulation (col + delta) is well-defined.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS input_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS output_tokens BIGINT NOT NULL DEFAULT 0;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION NOT NULL DEFAULT 0;

-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
-- independent contexts sharing one worktree. So agent_sessions keys on
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
-- `worktrees` table is one-per-session (selectable later) and only referenced
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
--
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
-- gate below RAISEs and aborts — delete the offending session first.

-- worktree as a first-class entity; survives session delete (session_id SET NULL).
CREATE TABLE IF NOT EXISTS worktrees (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  session_id  UUID REFERENCES sessions(id) ON DELETE SET NULL,
  project_id  UUID,
  path        TEXT NOT NULL,
  branch      TEXT,
  base_commit TEXT,
  slug        TEXT,
  status      TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';

-- session_worktrees was superseded by worktrees (v2.6/P1.5-b); all rows migrated
-- before P2 cleanup. Drop the dead table; no-op on fresh DBs that never had it.
DROP TABLE IF EXISTS session_worktrees;

-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;

-- Re-key columns on agent_sessions.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;

-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
-- non-null on every row before the swap. With the test session deleted this is a
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
-- abort loudly rather than create a degenerate (NULL, agent) key.
DO $$
DECLARE n int;
BEGIN
  SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
  IF n > 0 THEN
    RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
  END IF;
END $$;

-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
    ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
    ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
    ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
    ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
      FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
      FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
      FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
  END IF;
END $$;

-- P1.5-b follow-up: converge agent_sessions.session_id FK CASCADE → SET NULL.
-- The re-key block above re-adds session_id_fkey as SET NULL, but it is guarded on
-- chat_id_fkey's ABSENCE — so a DB already re-keyed to (chat_id, agent) while
-- session_id_fkey was still ON DELETE CASCADE never re-enters that block and stays
-- 'c'. This standalone guard flips it to SET NULL ('n'), matching worktree_id.
-- Idempotent (mirrors the session_worktrees defang's confdeltype check): only fires
-- while the FK is still CASCADE — a no-op on a fresh deploy (already 'n' from the
-- re-key block) and on every re-run thereafter.
DO $$ BEGIN
  IF EXISTS (
    SELECT 1 FROM pg_constraint
    WHERE conname = 'agent_sessions_session_id_fkey'
      AND confdeltype = 'c'
  ) THEN
    ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
    ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
    ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
      FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
  END IF;
END $$;

-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;

-- write-edit-robustness #4: worktree checkpoints. A pre-turn shadow-commit of the
-- session worktree (tracked + untracked, captured without disturbing the real
-- index/working tree) stored in a private GC-safe ref refs/boocode/checkpoints/<id>.
-- Created best-effort before each external-agent turn (opencode / warm-ACP / one-shot
-- ACP+PTY); restore resets the worktree to commit_sha, trims the transcript from
-- message_id forward, and resets the backend session. chat_id CASCADEs from chats
-- (like agent_sessions); worktree_id SET NULL so a checkpoint outlives a reaped
-- worktree row. session_id / message_id are informational (no FK — message rows are
-- trimmed by a checkpoint restore and we must not block that on a dangling ref).
CREATE TABLE IF NOT EXISTS checkpoints (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  chat_id     UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
  session_id  UUID,
  worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
  message_id  UUID,            -- anchor: the assistant turn row this checkpoint precedes
  commit_sha  TEXT NOT NULL,   -- shadow-commit capturing the pre-turn worktree tree
  label       TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at);

-- claude-sdk-sessionstore #9 (Part 1): append-only mirror of Claude Agent SDK
-- session transcripts. The SDK's SessionStore adapter writes one JSONL line per
-- entry; PostgresSessionStore (services/backends/claude-session-store.ts) inserts
-- one row per entry and replays them ORDER BY id on resume. The store is generic
-- per the SDK's SessionKey (project_key, session_id, subpath) — chat↔session
-- ownership lives in agent_sessions, not here. subpath '' is the main transcript
-- (the SDK's undefined subpath maps to '' in the column).
CREATE TABLE IF NOT EXISTS claude_session_entries (
  id          BIGSERIAL PRIMARY KEY,
  project_key TEXT NOT NULL,
  session_id  TEXT NOT NULL,
  subpath     TEXT NOT NULL DEFAULT '',   -- '' = main transcript (SDK's undefined subpath maps here)
  entry       JSONB NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS claude_session_entries_key_idx ON claude_session_entries (project_key, session_id, subpath, id);

-- claude-sdk-sessionstore #9 (Part 2): the warm Claude-SDK backend persists its
-- agent_sessions rows with backend='claude_sdk'. Widen the named CHECK to accept
-- it. Idempotent: DROP the named constraint (the inline CREATE TABLE check above
-- carries this explicit name, so DROP IF EXISTS targets it) + re-ADD the widened
-- list. Re-runs/fresh deploys land on the same final constraint (the table-level
-- CREATE already includes only the old two values on a fresh DB; this block then
-- replaces it with the three-value list).
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_backend_chk;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_backend_chk
  CHECK (backend IN ('opencode_server', 'acp_warm', 'claude_sdk'));

-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no
-- app-code drift. Idempotent: re-applied on every startup.
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('tasks_new', '');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
CREATE TRIGGER tasks_notify_new
  AFTER INSERT ON tasks
  FOR EACH ROW
  EXECUTE FUNCTION notify_tasks_new();

-- v2.8.0: orchestrator flow_runs + flow_steps — DB-backed scheduler for multi-agent flows.
-- project_id carries no FK (matches tasks.project_id convention, schema.sql:19).
CREATE TABLE IF NOT EXISTS flow_runs (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  project_id UUID NOT NULL,
  flow_name  TEXT NOT NULL,
  band       TEXT NOT NULL,
  model      TEXT NOT NULL,
  status     TEXT NOT NULL DEFAULT 'running',
  input      JSONB NOT NULL,
  report     TEXT,
  error      TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  CONSTRAINT flow_runs_band_chk   CHECK (band   IN ('small', 'medium', 'large')),
  CONSTRAINT flow_runs_status_chk CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
  CONSTRAINT flow_runs_input_chk  CHECK (input ? 'question')
);

CREATE TABLE IF NOT EXISTS flow_steps (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  run_id     UUID NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE,
  step_id    TEXT NOT NULL,
  kind       TEXT NOT NULL,
  agent      TEXT,
  status     TEXT NOT NULL DEFAULT 'pending',
  task_id    UUID REFERENCES tasks(id) ON DELETE SET NULL,
  chat_id    UUID REFERENCES chats(id) ON DELETE SET NULL,
  input      TEXT,
  output     TEXT,
  error      TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
  CONSTRAINT flow_steps_kind_chk   CHECK (kind   IN ('agent', 'code')),
  CONSTRAINT flow_steps_status_chk CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled')),
  UNIQUE (run_id, step_id)
);

-- Runs history query (GET /api/runs?project_id=).
CREATE INDEX IF NOT EXISTS flow_runs_project_created_idx ON flow_runs(project_id, created_at DESC);

-- Ready-wave derivation + resume scans (flow_steps WHERE run_id = ? AND status = ?).
CREATE INDEX IF NOT EXISTS flow_steps_run_status_idx ON flow_steps(run_id, status);

-- onTaskTerminal callback: look up the step owning a completed task.
CREATE INDEX IF NOT EXISTS flow_steps_task_id_idx ON flow_steps(task_id);

-- v2.8.0 (Phase 4): widen the status CHECKs to include 'cancelled' so the run
-- pane's stop button has a terminal state to record (the plan Notes flagged this
-- gap). Phase 2 applied these tables with the narrower set, so the inline CHECK
-- edits above are no-ops on the existing DB (CREATE TABLE IF NOT EXISTS skips an
-- existing table) — widen via the repo's DROP-IF-EXISTS → guarded-ADD discipline.
-- Pure ADD of a new allowed value, so no row UPDATE is needed (no value renamed).
ALTER TABLE flow_runs DROP CONSTRAINT IF EXISTS flow_runs_status_chk;
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_runs_status_chk') THEN
    ALTER TABLE flow_runs ADD CONSTRAINT flow_runs_status_chk
      CHECK (status IN ('running', 'completed', 'failed', 'cancelled'));
  END IF;
END $$;

ALTER TABLE flow_steps DROP CONSTRAINT IF EXISTS flow_steps_status_chk;
DO $$ BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'flow_steps_status_chk') THEN
    ALTER TABLE flow_steps ADD CONSTRAINT flow_steps_status_chk
      CHECK (status IN ('pending', 'running', 'completed', 'failed', 'skipped', 'cancelled'));
  END IF;
END $$;
