-- PostgreSQL schema for the model fine-tuning platform.
-- Recommended PostgreSQL version: 14+.

CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS citext;

CREATE SCHEMA IF NOT EXISTS ft_platform;
SET search_path TO ft_platform, public;

-- =========================
-- Common helpers
-- =========================

CREATE OR REPLACE FUNCTION ft_platform.set_updated_at()
RETURNS trigger AS $$
BEGIN
  NEW.updated_at = now();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION ft_platform.touch_updated_at(table_name regclass)
RETURNS void AS $$
BEGIN
  EXECUTE format('DROP TRIGGER IF EXISTS trg_set_updated_at ON %s', table_name);
  EXECUTE format(
    'CREATE TRIGGER trg_set_updated_at BEFORE UPDATE ON %s
     FOR EACH ROW EXECUTE FUNCTION ft_platform.set_updated_at()',
    table_name
  );
END;
$$ LANGUAGE plpgsql;

-- =========================
-- Enums
-- =========================

DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_role') THEN
    CREATE TYPE user_role AS ENUM ('admin', 'operator', 'viewer');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
    CREATE TYPE user_status AS ENUM ('active', 'disabled');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'task_status') THEN
    CREATE TYPE task_status AS ENUM ('pending', 'running', 'completed', 'failed', 'stopped');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_type') THEN
    CREATE TYPE model_type AS ENUM ('LLM', 'CV', 'NLP', 'Embedding', 'Other');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_purpose') THEN
    CREATE TYPE model_purpose AS ENUM ('training', 'inference', 'evaluation');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'model_source') THEN
    CREATE TYPE model_source AS ENUM ('local', 'api');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_type') THEN
    CREATE TYPE dataset_type AS ENUM ('train', 'test', 'eval', 'val', 'other');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_storage') THEN
    CREATE TYPE dataset_storage AS ENUM ('local', 'minio', 'cloud');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dataset_source') THEN
    CREATE TYPE dataset_source AS ENUM ('upload', 'task');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'train_type') THEN
    CREATE TYPE train_type AS ENUM ('SFT', 'DPO', 'CPT');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'train_method') THEN
    CREATE TYPE train_method AS ENUM ('lora', 'qlora', 'full', 'prefix', 'adapter', 'peft', 'adalora', 'longlora');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'process_type') THEN
    CREATE TYPE process_type AS ENUM ('structured', 'unstructured', 'external');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'eval_type') THEN
    CREATE TYPE eval_type AS ENUM ('custom', 'baseline');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'dimension_type') THEN
    CREATE TYPE dimension_type AS ENUM ('classification', 'metric', 'text_similarity');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'result_status') THEN
    CREATE TYPE result_status AS ENUM ('valid', 'modified', 'invalid');
  END IF;
END $$;

-- =========================
-- User center and RBAC
-- =========================

CREATE TABLE IF NOT EXISTS users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  username citext NOT NULL UNIQUE,
  display_name varchar(100) NOT NULL,
  password_hash text NOT NULL,
  role user_role NOT NULL DEFAULT 'viewer',
  status user_status NOT NULL DEFAULT 'active',
  protected boolean NOT NULL DEFAULT false,
  last_login_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('users');

CREATE TABLE IF NOT EXISTS permissions (
  code varchar(64) PRIMARY KEY,
  name varchar(100) NOT NULL,
  description text,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS role_permissions (
  role user_role NOT NULL,
  permission_code varchar(64) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
  PRIMARY KEY (role, permission_code)
);

CREATE TABLE IF NOT EXISTS user_permissions (
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  permission_code varchar(64) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
  allowed boolean NOT NULL DEFAULT true,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, permission_code)
);

CREATE TABLE IF NOT EXISTS login_sessions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_jti uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
  ip inet,
  user_agent text,
  expires_at timestamptz NOT NULL,
  revoked_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_login_sessions_user_created ON login_sessions(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_sessions_expires ON login_sessions(expires_at) WHERE revoked_at IS NULL;

INSERT INTO permissions(code, name, description) VALUES
  ('dashboard', '服务看板', '访问服务看板'),
  ('fine-tune', '模型训练', '创建和管理微调任务'),
  ('model-eval', '模型评测', '创建和管理评测任务'),
  ('model-inference', '模型推理', '创建推理与模型对比任务'),
  ('model-manage', '模型管理', '登记、编辑、删除模型'),
  ('dataset', '数据集管理', '上传、编辑、下载数据集'),
  ('data-process', '数据处理', '创建和管理数据处理任务'),
  ('data-convert', '数据转换/工具', '使用数据转换和工具中心'),
  ('hardware', '平台性能', '查看硬件和系统监控'),
  ('logs', '查看日志', '查看系统与训练日志'),
  ('user-settings', '用户设置', '管理用户和权限')
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description;

INSERT INTO role_permissions(role, permission_code)
SELECT 'admin'::user_role, code FROM permissions
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions(role, permission_code) VALUES
  ('operator', 'dashboard'),
  ('operator', 'fine-tune'),
  ('operator', 'model-eval'),
  ('operator', 'model-inference'),
  ('operator', 'model-manage'),
  ('operator', 'dataset'),
  ('operator', 'data-process'),
  ('operator', 'data-convert'),
  ('operator', 'hardware'),
  ('operator', 'logs'),
  ('viewer', 'dashboard'),
  ('viewer', 'model-eval'),
  ('viewer', 'model-inference'),
  ('viewer', 'dataset'),
  ('viewer', 'hardware'),
  ('viewer', 'logs')
ON CONFLICT DO NOTHING;

-- Replace this password hash during deployment.
INSERT INTO users(username, display_name, password_hash, role, status, protected)
VALUES ('admin', '系统管理员', '$argon2id$replace-with-real-hash', 'admin', 'active', true)
ON CONFLICT (username) DO NOTHING;

-- =========================
-- Audit and operation logs
-- =========================

CREATE TABLE IF NOT EXISTS audit_logs (
  id bigserial PRIMARY KEY,
  user_id uuid REFERENCES users(id) ON DELETE SET NULL,
  username citext,
  action varchar(100) NOT NULL,
  resource_type varchar(80) NOT NULL,
  resource_id text,
  request_method varchar(12),
  request_path text,
  ip inet,
  user_agent text,
  success boolean NOT NULL DEFAULT true,
  error_message text,
  before_data jsonb,
  after_data jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_created ON audit_logs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC);

CREATE TABLE IF NOT EXISTS web_logs (
  id bigserial PRIMARY KEY,
  user_id uuid REFERENCES users(id) ON DELETE SET NULL,
  level varchar(20) NOT NULL,
  message text NOT NULL,
  page text,
  context jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_web_logs_created ON web_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_web_logs_level_created ON web_logs(level, created_at DESC);

-- =========================
-- Files and object storage metadata
-- =========================

CREATE TABLE IF NOT EXISTS storage_objects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  storage_type dataset_storage NOT NULL DEFAULT 'local',
  bucket varchar(128),
  object_key text NOT NULL,
  original_name text,
  mime_type varchar(200),
  byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0),
  checksum_sha256 char(64),
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_object_location
  ON storage_objects(storage_type, COALESCE(bucket, ''), object_key);
CREATE INDEX IF NOT EXISTS idx_storage_objects_checksum ON storage_objects(checksum_sha256);

-- =========================
-- Model registry
-- =========================

CREATE TABLE IF NOT EXISTS models (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  type model_type NOT NULL DEFAULT 'LLM',
  purpose model_purpose NOT NULL,
  model_source model_source NOT NULL DEFAULT 'local',
  description text,
  path text,
  api_url text,
  api_key_encrypted text,
  online_model_name varchar(200),
  config jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz,
  CONSTRAINT ck_model_local_or_api CHECK (
    (model_source = 'local' AND path IS NOT NULL)
    OR
    (model_source = 'api' AND api_url IS NOT NULL AND online_model_name IS NOT NULL)
  )
);
SELECT touch_updated_at('models');
CREATE UNIQUE INDEX IF NOT EXISTS uq_models_name_alive ON models(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_models_purpose ON models(purpose) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_models_type_source ON models(type, model_source) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS trained_models (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  base_model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  fine_tune_task_id uuid,
  train_method train_method,
  adapter_path text,
  merged boolean NOT NULL DEFAULT false,
  merging boolean NOT NULL DEFAULT false,
  merged_path text,
  export_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('trained_models');
CREATE UNIQUE INDEX IF NOT EXISTS uq_trained_models_name_alive ON trained_models(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_trained_models_task ON trained_models(fine_tune_task_id);
CREATE INDEX IF NOT EXISTS idx_trained_models_base ON trained_models(base_model_id);

-- =========================
-- Datasets and versions
-- =========================

CREATE TABLE IF NOT EXISTS datasets (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  type dataset_type NOT NULL,
  storage_type dataset_storage NOT NULL DEFAULT 'local',
  source dataset_source NOT NULL DEFAULT 'upload',
  source_task_id uuid,
  size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
  record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0),
  description text,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('datasets');
CREATE UNIQUE INDEX IF NOT EXISTS uq_datasets_name_alive ON datasets(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_datasets_type_created ON datasets(type, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_datasets_source_task ON datasets(source_task_id) WHERE source = 'task';

CREATE TABLE IF NOT EXISTS dataset_files (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  dataset_id uuid NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
  name text NOT NULL,
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  current_version_id uuid,
  size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
  record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0),
  file_format varchar(40),
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('dataset_files');
CREATE UNIQUE INDEX IF NOT EXISTS uq_dataset_files_name_alive
  ON dataset_files(dataset_id, name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_dataset_files_dataset ON dataset_files(dataset_id) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS dataset_file_versions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  dataset_file_id uuid NOT NULL REFERENCES dataset_files(id) ON DELETE CASCADE,
  version_no integer NOT NULL CHECK (version_no > 0),
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  content_preview text,
  description text,
  base_version_id uuid REFERENCES dataset_file_versions(id) ON DELETE SET NULL,
  size_bytes bigint NOT NULL DEFAULT 0 CHECK (size_bytes >= 0),
  record_count bigint NOT NULL DEFAULT 0 CHECK (record_count >= 0),
  checksum_sha256 char(64),
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_dataset_file_versions_no
  ON dataset_file_versions(dataset_file_id, version_no);
CREATE INDEX IF NOT EXISTS idx_dataset_file_versions_file_created
  ON dataset_file_versions(dataset_file_id, created_at DESC);

ALTER TABLE dataset_files
  DROP CONSTRAINT IF EXISTS fk_dataset_files_current_version;
ALTER TABLE dataset_files
  ADD CONSTRAINT fk_dataset_files_current_version
  FOREIGN KEY (current_version_id) REFERENCES dataset_file_versions(id) ON DELETE SET NULL;

CREATE TABLE IF NOT EXISTS dataset_records (
  id bigserial PRIMARY KEY,
  dataset_id uuid NOT NULL REFERENCES datasets(id) ON DELETE CASCADE,
  dataset_file_id uuid REFERENCES dataset_files(id) ON DELETE CASCADE,
  version_id uuid REFERENCES dataset_file_versions(id) ON DELETE CASCADE,
  line_no integer,
  split varchar(20),
  instruction text,
  input text,
  output text,
  raw jsonb NOT NULL DEFAULT '{}'::jsonb,
  status result_status NOT NULL DEFAULT 'valid',
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dataset_records_dataset_id ON dataset_records(dataset_id, id);
CREATE INDEX IF NOT EXISTS idx_dataset_records_file_version ON dataset_records(dataset_file_id, version_id, line_no);
CREATE INDEX IF NOT EXISTS idx_dataset_records_split ON dataset_records(dataset_id, split);
CREATE INDEX IF NOT EXISTS idx_dataset_records_raw_gin ON dataset_records USING gin(raw);

-- =========================
-- Data processing
-- =========================

CREATE TABLE IF NOT EXISTS data_process_tasks (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  description text,
  status task_status NOT NULL DEFAULT 'pending',
  process_type process_type NOT NULL,
  source_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL,
  output_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL,
  config jsonb NOT NULL DEFAULT '{}'::jsonb,
  progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
  input_count bigint NOT NULL DEFAULT 0,
  output_count bigint NOT NULL DEFAULT 0,
  filtered_count bigint NOT NULL DEFAULT 0,
  duplicate_count bigint NOT NULL DEFAULT 0,
  error_count bigint NOT NULL DEFAULT 0,
  failure_reason text,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('data_process_tasks');
CREATE UNIQUE INDEX IF NOT EXISTS uq_data_process_tasks_name_alive
  ON data_process_tasks(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_data_process_tasks_status_created
  ON data_process_tasks(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_data_process_tasks_creator_created
  ON data_process_tasks(created_by, created_at DESC) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS data_process_source_files (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE,
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  name text NOT NULL,
  size_bytes bigint NOT NULL DEFAULT 0,
  record_count bigint NOT NULL DEFAULT 0,
  content_preview text,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_data_process_source_files_task ON data_process_source_files(task_id);

CREATE TABLE IF NOT EXISTS data_process_preview_items (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE,
  source_file_id uuid REFERENCES data_process_source_files(id) ON DELETE CASCADE,
  original_content text NOT NULL DEFAULT '',
  edited_content text NOT NULL DEFAULT '',
  source_start integer,
  source_end integer,
  source_start_line integer,
  source_end_line integer,
  token_count integer NOT NULL DEFAULT 0,
  status varchar(20) NOT NULL DEFAULT 'original',
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('data_process_preview_items');
CREATE INDEX IF NOT EXISTS idx_data_process_preview_task_file
  ON data_process_preview_items(task_id, source_file_id, created_at);

CREATE TABLE IF NOT EXISTS data_process_results (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id uuid NOT NULL REFERENCES data_process_tasks(id) ON DELETE CASCADE,
  preview_item_id uuid REFERENCES data_process_preview_items(id) ON DELETE SET NULL,
  instruction text NOT NULL,
  input text NOT NULL DEFAULT '',
  output text NOT NULL,
  original_instruction text,
  original_input text,
  original_output text,
  status result_status NOT NULL DEFAULT 'valid',
  error text,
  split varchar(20),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('data_process_results');
CREATE INDEX IF NOT EXISTS idx_data_process_results_task_status
  ON data_process_results(task_id, status, id);
CREATE INDEX IF NOT EXISTS idx_data_process_results_task_split
  ON data_process_results(task_id, split);

-- =========================
-- Fine-tune tasks
-- =========================

CREATE TABLE IF NOT EXISTS fine_tune_tasks (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  description text,
  status task_status NOT NULL DEFAULT 'pending',
  train_type train_type NOT NULL,
  train_method train_method NOT NULL DEFAULT 'lora',
  template varchar(80) NOT NULL DEFAULT 'qwen',
  base_model_id uuid NOT NULL REFERENCES models(id) ON DELETE RESTRICT,
  train_dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL,
  auto_merge boolean NOT NULL DEFAULT false,
  output_model_name varchar(150),
  gpus integer[] NOT NULL DEFAULT '{}',
  params jsonb NOT NULL DEFAULT '{}'::jsonb,
  progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
  process_id integer,
  command text,
  output_dir text,
  log_file text,
  train_duration_seconds integer,
  failure_reason text,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('fine_tune_tasks');
CREATE UNIQUE INDEX IF NOT EXISTS uq_fine_tune_tasks_name_alive
  ON fine_tune_tasks(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_status_created
  ON fine_tune_tasks(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_dataset ON fine_tune_tasks(train_dataset_id);
CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_base_model ON fine_tune_tasks(base_model_id);

ALTER TABLE trained_models
  DROP CONSTRAINT IF EXISTS fk_trained_models_fine_tune_task;
ALTER TABLE trained_models
  ADD CONSTRAINT fk_trained_models_fine_tune_task
  FOREIGN KEY (fine_tune_task_id) REFERENCES fine_tune_tasks(id) ON DELETE SET NULL;

CREATE TABLE IF NOT EXISTS fine_tune_metrics (
  id bigserial PRIMARY KEY,
  task_id uuid NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE,
  step integer,
  epoch numeric(10,4),
  loss numeric(18,8),
  learning_rate numeric(18,12),
  grad_norm numeric(18,8),
  metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_fine_tune_metrics_task_step ON fine_tune_metrics(task_id, step);
CREATE INDEX IF NOT EXISTS idx_fine_tune_metrics_task_created ON fine_tune_metrics(task_id, created_at);

-- =========================
-- Inference and compare
-- =========================

CREATE TABLE IF NOT EXISTS inference_tasks (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  description text,
  status task_status NOT NULL DEFAULT 'pending',
  load_status jsonb NOT NULL DEFAULT '{"loaded_models":[]}'::jsonb,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('inference_tasks');
CREATE INDEX IF NOT EXISTS idx_inference_tasks_status_created
  ON inference_tasks(status, created_at DESC) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS inference_task_models (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  task_id uuid NOT NULL REFERENCES inference_tasks(id) ON DELETE CASCADE,
  model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL,
  model_name varchar(150) NOT NULL,
  model_path text,
  gpu_id integer,
  source varchar(40),
  port integer,
  pid integer,
  status varchar(40) NOT NULL DEFAULT 'pending',
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('inference_task_models');
CREATE INDEX IF NOT EXISTS idx_inference_task_models_task ON inference_task_models(task_id);
CREATE INDEX IF NOT EXISTS idx_inference_task_models_pid ON inference_task_models(pid) WHERE pid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_inference_task_models_port_alive
  ON inference_task_models(port) WHERE port IS NOT NULL AND status IN ('loading', 'ready');

CREATE TABLE IF NOT EXISTS chat_sessions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  inference_task_id uuid REFERENCES inference_tasks(id) ON DELETE SET NULL,
  title varchar(200),
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('chat_sessions');
CREATE INDEX IF NOT EXISTS idx_chat_sessions_task_created ON chat_sessions(inference_task_id, created_at DESC);

CREATE TABLE IF NOT EXISTS chat_messages (
  id bigserial PRIMARY KEY,
  session_id uuid NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
  model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL,
  role varchar(20) NOT NULL,
  content text NOT NULL,
  latency_ms integer,
  token_usage jsonb NOT NULL DEFAULT '{}'::jsonb,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_id ON chat_messages(session_id, id);

-- =========================
-- Evaluation
-- =========================

CREATE TABLE IF NOT EXISTS eval_dimensions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(150) NOT NULL,
  type dimension_type NOT NULL,
  description text,
  eval_model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  eval_method jsonb NOT NULL DEFAULT '[]'::jsonb,
  eval_prompt text,
  is_active boolean NOT NULL DEFAULT true,
  is_default boolean NOT NULL DEFAULT false,
  bleu_n integer,
  output_precision integer NOT NULL DEFAULT 3,
  score_min numeric(12,4),
  score_max numeric(12,4),
  pass_threshold numeric(12,4),
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('eval_dimensions');
CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_dimensions_name_alive
  ON eval_dimensions(name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_eval_dimensions_active ON eval_dimensions(is_active) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS eval_tasks (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  eval_task_name varchar(150) NOT NULL,
  eval_type eval_type NOT NULL DEFAULT 'custom',
  model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL,
  dataset_id uuid REFERENCES datasets(id) ON DELETE SET NULL,
  dimension_id uuid REFERENCES eval_dimensions(id) ON DELETE SET NULL,
  gpu_id integer,
  data_source varchar(40) NOT NULL DEFAULT 'dataset',
  leaderboard boolean NOT NULL DEFAULT false,
  basic_metrics jsonb NOT NULL DEFAULT '{}'::jsonb,
  status task_status NOT NULL DEFAULT 'pending',
  metric varchar(80),
  score numeric(12,4),
  overall_score numeric(12,4),
  overall_score_max numeric(12,4),
  overall_evaluation text,
  improvement_suggestions jsonb NOT NULL DEFAULT '[]'::jsonb,
  sample_count integer NOT NULL DEFAULT 0,
  completed_count integer NOT NULL DEFAULT 0,
  passed_count integer NOT NULL DEFAULT 0,
  failure_reason text,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('eval_tasks');
CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_tasks_name_alive
  ON eval_tasks(eval_task_name) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_eval_tasks_status_created ON eval_tasks(status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_eval_tasks_model_dataset ON eval_tasks(model_id, dataset_id);

CREATE TABLE IF NOT EXISTS eval_dimension_summaries (
  id bigserial PRIMARY KEY,
  eval_task_id uuid NOT NULL REFERENCES eval_tasks(id) ON DELETE CASCADE,
  name varchar(150) NOT NULL,
  score numeric(12,4),
  max_score numeric(12,4),
  pass_rate numeric(6,2),
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_eval_dimension_summaries_task ON eval_dimension_summaries(eval_task_id);

CREATE TABLE IF NOT EXISTS eval_sample_results (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  eval_task_id uuid NOT NULL REFERENCES eval_tasks(id) ON DELETE CASCADE,
  sample_index integer NOT NULL,
  input text NOT NULL,
  reference_answer text,
  model_output text NOT NULL DEFAULT '',
  score numeric(12,4),
  max_score numeric(12,4),
  passed boolean,
  status task_status NOT NULL DEFAULT 'pending',
  judgement varchar(40),
  evaluation_reason text,
  error_type varchar(80),
  dimension_scores jsonb NOT NULL DEFAULT '[]'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('eval_sample_results');
CREATE UNIQUE INDEX IF NOT EXISTS uq_eval_sample_results_task_index
  ON eval_sample_results(eval_task_id, sample_index);
CREATE INDEX IF NOT EXISTS idx_eval_sample_results_task_status
  ON eval_sample_results(eval_task_id, status);

-- =========================
-- Data convert and custom tools
-- =========================

CREATE TABLE IF NOT EXISTS data_convert_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  convert_type varchar(40) NOT NULL DEFAULT 'json_to_jsonl',
  status task_status NOT NULL DEFAULT 'pending',
  output_name varchar(200) NOT NULL,
  encoding varchar(40) NOT NULL DEFAULT 'UTF-8',
  source_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  result_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  error_message text,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('data_convert_jobs');
CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_user_created ON data_convert_jobs(created_by, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_status ON data_convert_jobs(status, created_at DESC);

CREATE TABLE IF NOT EXISTS custom_tools (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name varchar(100) NOT NULL,
  description text,
  url text NOT NULL,
  icon varchar(80) NOT NULL DEFAULT 'fa-cog',
  visibility varchar(20) NOT NULL DEFAULT 'private',
  owner_id uuid REFERENCES users(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('custom_tools');
CREATE INDEX IF NOT EXISTS idx_custom_tools_owner ON custom_tools(owner_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_custom_tools_visibility ON custom_tools(visibility) WHERE deleted_at IS NULL;

-- =========================
-- Metrics snapshots
-- =========================

CREATE TABLE IF NOT EXISTS system_metric_snapshots (
  id bigserial PRIMARY KEY,
  cpu jsonb NOT NULL DEFAULT '{}'::jsonb,
  memory jsonb NOT NULL DEFAULT '{}'::jsonb,
  disk jsonb NOT NULL DEFAULT '{}'::jsonb,
  gpu jsonb NOT NULL DEFAULT '[]'::jsonb,
  network jsonb NOT NULL DEFAULT '{}'::jsonb,
  system jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_system_metric_snapshots_created ON system_metric_snapshots(created_at DESC);

CREATE TABLE IF NOT EXISTS service_status_snapshots (
  id bigserial PRIMARY KEY,
  service_name varchar(100) NOT NULL,
  state varchar(30) NOT NULL,
  instances_online integer NOT NULL DEFAULT 0,
  instances_total integer NOT NULL DEFAULT 0,
  detail jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_service_status_snapshots_name_created
  ON service_status_snapshots(service_name, created_at DESC);

-- =========================
-- Useful views
-- =========================

CREATE OR REPLACE VIEW v_user_effective_permissions AS
SELECT
  u.id AS user_id,
  u.username,
  p.code AS permission_code,
  COALESCE(up.allowed, rp.permission_code IS NOT NULL, false) AS allowed
FROM users u
CROSS JOIN permissions p
LEFT JOIN role_permissions rp
  ON rp.role = u.role AND rp.permission_code = p.code
LEFT JOIN user_permissions up
  ON up.user_id = u.id AND up.permission_code = p.code
WHERE u.deleted_at IS NULL;

CREATE OR REPLACE VIEW v_dataset_summary AS
SELECT
  d.id,
  d.name,
  d.type,
  d.storage_type,
  d.source,
  d.source_task_id,
  d.size_bytes,
  d.record_count,
  d.description,
  count(df.id) FILTER (WHERE df.deleted_at IS NULL) AS file_count,
  d.created_at,
  d.updated_at
FROM datasets d
LEFT JOIN dataset_files df ON df.dataset_id = d.id
WHERE d.deleted_at IS NULL
GROUP BY d.id;

-- =========================
-- Maintenance notes
-- =========================

-- 1. For very large installations, convert audit_logs, web_logs,
--    system_metric_snapshots, fine_tune_metrics and eval_sample_results to
--    monthly/range partitions.
-- 2. Keep large file bodies in storage_objects, not in relational rows.
-- 3. Encrypt api_key_encrypted and external source secrets at the application layer
--    with KMS or a deployment secret.
-- 4. Use soft delete for user-facing resources to preserve audit trails.

-- =========================
-- Enterprise governance and compute extension
-- =========================

DO $$
BEGIN
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'tenant_status') THEN
    CREATE TYPE tenant_status AS ENUM ('active', 'disabled');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'project_status') THEN
    CREATE TYPE project_status AS ENUM ('active', 'archived', 'disabled');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'project_role') THEN
    CREATE TYPE project_role AS ENUM ('owner', 'maintainer', 'developer', 'reviewer', 'viewer');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'approval_status') THEN
    CREATE TYPE approval_status AS ENUM ('not_required', 'pending', 'approved', 'rejected', 'cancelled', 'expired');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'compute_job_type') THEN
    CREATE TYPE compute_job_type AS ENUM ('fine_tune', 'eval', 'data_process', 'inference', 'merge', 'convert', 'import');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_priority') THEN
    CREATE TYPE job_priority AS ENUM ('low', 'normal', 'high', 'urgent');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'gpu_status') THEN
    CREATE TYPE gpu_status AS ENUM ('idle', 'reserved', 'running', 'draining', 'offline', 'error');
  END IF;
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'service_level') THEN
    CREATE TYPE service_level AS ENUM ('test', 'production');
  END IF;
END $$;

CREATE TABLE IF NOT EXISTS tenants (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code citext NOT NULL UNIQUE,
  name varchar(150) NOT NULL,
  status tenant_status NOT NULL DEFAULT 'active',
  owner_id uuid REFERENCES users(id) ON DELETE SET NULL,
  quota_config jsonb NOT NULL DEFAULT '{}'::jsonb,
  retention_config jsonb NOT NULL DEFAULT '{}'::jsonb,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('tenants');
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS tenant_users (
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role user_role NOT NULL DEFAULT 'viewer',
  is_tenant_admin boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_tenant_users_user ON tenant_users(user_id);

CREATE TABLE IF NOT EXISTS projects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  code citext NOT NULL,
  name varchar(150) NOT NULL,
  description text,
  status project_status NOT NULL DEFAULT 'active',
  owner_id uuid REFERENCES users(id) ON DELETE SET NULL,
  quota_config jsonb NOT NULL DEFAULT '{}'::jsonb,
  default_acl jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  archived_at timestamptz,
  deleted_at timestamptz,
  UNIQUE (tenant_id, code)
);
SELECT touch_updated_at('projects');
CREATE INDEX IF NOT EXISTS idx_projects_tenant_status ON projects(tenant_id, status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS project_members (
  project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role project_role NOT NULL DEFAULT 'viewer',
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (project_id, user_id)
);
SELECT touch_updated_at('project_members');
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);

CREATE TABLE IF NOT EXISTS storage_nodes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code citext NOT NULL UNIQUE,
  name varchar(150) NOT NULL,
  node_type varchar(40) NOT NULL DEFAULT 'compute_local',
  base_path text NOT NULL,
  total_bytes bigint,
  used_bytes bigint NOT NULL DEFAULT 0,
  status varchar(40) NOT NULL DEFAULT 'online',
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('storage_nodes');

CREATE TABLE IF NOT EXISTS training_engines (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code citext NOT NULL UNIQUE,
  name varchar(150) NOT NULL,
  version varchar(80),
  engine_type varchar(60) NOT NULL DEFAULT 'llama_factory',
  executable_path text,
  python_env_path text,
  supported_task_types jsonb NOT NULL DEFAULT '[]'::jsonb,
  supported_methods jsonb NOT NULL DEFAULT '[]'::jsonb,
  supported_formats jsonb NOT NULL DEFAULT '[]'::jsonb,
  schema jsonb NOT NULL DEFAULT '{}'::jsonb,
  status varchar(40) NOT NULL DEFAULT 'enabled',
  last_health_check_at timestamptz,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('training_engines');

INSERT INTO training_engines(
  code, name, engine_type, supported_task_types, supported_methods, supported_formats, status
) VALUES (
  'llama_factory',
  'LLaMA-Factory',
  'llama_factory',
  '["SFT", "DPO", "CPT"]'::jsonb,
  '["lora", "qlora", "full"]'::jsonb,
  '["alpaca", "sharegpt", "dpo_pair", "pretrain_text"]'::jsonb,
  'enabled'
) ON CONFLICT (code) DO NOTHING;

CREATE TABLE IF NOT EXISTS quotas (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  subject_type varchar(40) NOT NULL,
  subject_id uuid,
  gpu_concurrency integer NOT NULL DEFAULT 0,
  storage_bytes bigint NOT NULL DEFAULT 0,
  max_running_jobs integer NOT NULL DEFAULT 0,
  max_projects integer NOT NULL DEFAULT 0,
  max_upload_file_bytes bigint NOT NULL DEFAULT 0,
  config jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (subject_type, subject_id)
);
SELECT touch_updated_at('quotas');
CREATE INDEX IF NOT EXISTS idx_quotas_tenant_project ON quotas(tenant_id, project_id);

CREATE TABLE IF NOT EXISTS quota_usage (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  subject_type varchar(40) NOT NULL,
  subject_id uuid,
  gpu_running integer NOT NULL DEFAULT 0,
  storage_used_bytes bigint NOT NULL DEFAULT 0,
  running_jobs integer NOT NULL DEFAULT 0,
  usage_detail jsonb NOT NULL DEFAULT '{}'::jsonb,
  measured_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (subject_type, subject_id)
);
CREATE INDEX IF NOT EXISTS idx_quota_usage_tenant_project ON quota_usage(tenant_id, project_id);

CREATE TABLE IF NOT EXISTS retention_policies (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  audit_days integer NOT NULL DEFAULT 180,
  login_log_days integer NOT NULL DEFAULT 180,
  training_log_days integer NOT NULL DEFAULT 90,
  metric_raw_days integer NOT NULL DEFAULT 30,
  temp_file_days integer NOT NULL DEFAULT 1,
  failed_job_workspace_days integer NOT NULL DEFAULT 14,
  checkpoint_policy jsonb NOT NULL DEFAULT '{"keep_last":3,"keep_best":2,"failed_job_days":14}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, project_id)
);
SELECT touch_updated_at('retention_policies');

CREATE TABLE IF NOT EXISTS approval_templates (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  action varchar(80) NOT NULL,
  name varchar(150) NOT NULL,
  enabled boolean NOT NULL DEFAULT true,
  approver_rules jsonb NOT NULL DEFAULT '[]'::jsonb,
  risk_level varchar(40) NOT NULL DEFAULT 'medium',
  expire_hours integer NOT NULL DEFAULT 24,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('approval_templates');
CREATE INDEX IF NOT EXISTS idx_approval_templates_scope_action
  ON approval_templates(tenant_id, project_id, action);

CREATE TABLE IF NOT EXISTS approval_instances (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  template_id uuid REFERENCES approval_templates(id) ON DELETE SET NULL,
  action varchar(80) NOT NULL,
  resource_type varchar(80) NOT NULL,
  resource_id text NOT NULL,
  reason text,
  payload jsonb NOT NULL DEFAULT '{}'::jsonb,
  status approval_status NOT NULL DEFAULT 'pending',
  requested_by uuid REFERENCES users(id) ON DELETE SET NULL,
  decided_by uuid REFERENCES users(id) ON DELETE SET NULL,
  decided_at timestamptz,
  expires_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('approval_instances');
CREATE INDEX IF NOT EXISTS idx_approval_instances_status_created
  ON approval_instances(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_approval_instances_scope
  ON approval_instances(tenant_id, project_id, action, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_approval_instances_resource
  ON approval_instances(resource_type, resource_id);

CREATE TABLE IF NOT EXISTS approval_steps (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  approval_id uuid NOT NULL REFERENCES approval_instances(id) ON DELETE CASCADE,
  step_no integer NOT NULL,
  approver_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
  approver_role varchar(80),
  status approval_status NOT NULL DEFAULT 'pending',
  comment text,
  decided_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (approval_id, step_no)
);
CREATE INDEX IF NOT EXISTS idx_approval_steps_approver
  ON approval_steps(approver_user_id, status);

CREATE TABLE IF NOT EXISTS compute_nodes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  code citext NOT NULL UNIQUE,
  name varchar(150) NOT NULL,
  host varchar(200) NOT NULL,
  api_base_url text NOT NULL,
  storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL,
  status varchar(40) NOT NULL DEFAULT 'online',
  agent_version varchar(80),
  gpu_count integer NOT NULL DEFAULT 0,
  last_heartbeat_at timestamptz,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('compute_nodes');
CREATE INDEX IF NOT EXISTS idx_compute_nodes_status ON compute_nodes(status);

CREATE TABLE IF NOT EXISTS gpu_devices (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
  gpu_index integer NOT NULL,
  uuid varchar(120) NOT NULL,
  name varchar(150) NOT NULL,
  status gpu_status NOT NULL DEFAULT 'idle',
  memory_total_mb integer NOT NULL DEFAULT 0,
  memory_used_mb integer NOT NULL DEFAULT 0,
  utilization_percent numeric(5,2) NOT NULL DEFAULT 0,
  temperature numeric(5,2),
  power_w numeric(8,2),
  driver_version varchar(80),
  partition_type varchar(40) NOT NULL DEFAULT 'full',
  parent_gpu_uuid varchar(120),
  current_job_id uuid,
  last_seen_at timestamptz,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (compute_node_id, gpu_index),
  UNIQUE (uuid)
);
SELECT touch_updated_at('gpu_devices');
CREATE INDEX IF NOT EXISTS idx_gpu_devices_node_status ON gpu_devices(compute_node_id, status);

CREATE TABLE IF NOT EXISTS compute_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  app_task_type varchar(80) NOT NULL,
  app_task_id uuid,
  tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
  project_id uuid REFERENCES projects(id) ON DELETE SET NULL,
  compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL,
  engine_id uuid REFERENCES training_engines(id) ON DELETE SET NULL,
  job_type compute_job_type NOT NULL,
  status task_status NOT NULL DEFAULT 'pending',
  priority job_priority NOT NULL DEFAULT 'normal',
  resource_request jsonb NOT NULL DEFAULT '{}'::jsonb,
  workspace_root text,
  payload jsonb NOT NULL DEFAULT '{}'::jsonb,
  result jsonb NOT NULL DEFAULT '{}'::jsonb,
  progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
  pid integer,
  port integer,
  failure_reason text,
  requested_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('compute_jobs');
CREATE INDEX IF NOT EXISTS idx_compute_jobs_scope_status
  ON compute_jobs(tenant_id, project_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_compute_jobs_node_status
  ON compute_jobs(compute_node_id, status, priority, created_at);
CREATE INDEX IF NOT EXISTS idx_compute_jobs_app_task
  ON compute_jobs(app_task_type, app_task_id);

CREATE TABLE IF NOT EXISTS gpu_allocations (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  compute_job_id uuid NOT NULL REFERENCES compute_jobs(id) ON DELETE CASCADE,
  gpu_device_id uuid NOT NULL REFERENCES gpu_devices(id) ON DELETE RESTRICT,
  tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
  project_id uuid REFERENCES projects(id) ON DELETE SET NULL,
  status varchar(40) NOT NULL DEFAULT 'reserved',
  allocated_at timestamptz NOT NULL DEFAULT now(),
  released_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_gpu_allocations_job ON gpu_allocations(compute_job_id);
CREATE INDEX IF NOT EXISTS idx_gpu_allocations_scope ON gpu_allocations(tenant_id, project_id, allocated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS uq_gpu_allocations_active_gpu
  ON gpu_allocations(gpu_device_id) WHERE released_at IS NULL;

-- Multi compute-node scheduling and local-cache metadata.
-- Each GPU server is modeled as one compute node. Nodes do not call each other;
-- the application platform schedules jobs and syncs resources through each node's File Gateway.
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS file_gateway_url text;
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS scheduler_status varchar(40) NOT NULL DEFAULT 'online';
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS scheduler_weight integer NOT NULL DEFAULT 100 CHECK (scheduler_weight >= 0);
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}';
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS service_token_encrypted text;
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS data_root text NOT NULL DEFAULT '/data/yg-ft';
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS model_root text NOT NULL DEFAULT '/data/yg-ft/models';
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS log_root text NOT NULL DEFAULT '/opt/yg-ft/logs/compute';
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS max_parallel_jobs integer NOT NULL DEFAULT 1 CHECK (max_parallel_jobs >= 0);
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS current_running_jobs integer NOT NULL DEFAULT 0 CHECK (current_running_jobs >= 0);
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS last_health_check_at timestamptz;
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS health_detail jsonb NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE compute_nodes ADD COLUMN IF NOT EXISTS drain_reason text;
CREATE INDEX IF NOT EXISTS idx_compute_nodes_scheduler
  ON compute_nodes(enabled, scheduler_status, scheduler_weight DESC, last_health_check_at DESC);
CREATE INDEX IF NOT EXISTS idx_compute_nodes_tags_gin
  ON compute_nodes USING gin(tags);

ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS scheduler_mode varchar(40) NOT NULL DEFAULT 'auto';
ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS requested_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL;
ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS assigned_at timestamptz;
ALTER TABLE compute_jobs ADD COLUMN IF NOT EXISTS scheduler_reason text;
CREATE INDEX IF NOT EXISTS idx_compute_jobs_requested_node
  ON compute_jobs(requested_node_id, status, created_at DESC);

CREATE TABLE IF NOT EXISTS compute_node_engines (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
  engine_id uuid REFERENCES training_engines(id) ON DELETE SET NULL,
  engine_code varchar(80) NOT NULL,
  engine_version varchar(80),
  home_path text,
  status varchar(40) NOT NULL DEFAULT 'available',
  capability jsonb NOT NULL DEFAULT '{}'::jsonb,
  last_health_check_at timestamptz,
  health_detail jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (compute_node_id, engine_code)
);
SELECT touch_updated_at('compute_node_engines');
CREATE INDEX IF NOT EXISTS idx_compute_node_engines_node_status
  ON compute_node_engines(compute_node_id, status, engine_code);

CREATE TABLE IF NOT EXISTS resource_replicas (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
  project_id uuid REFERENCES projects(id) ON DELETE SET NULL,
  resource_type varchar(80) NOT NULL,
  resource_id uuid NOT NULL,
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
  local_path text NOT NULL,
  status varchar(40) NOT NULL DEFAULT 'available',
  sync_status varchar(40) NOT NULL DEFAULT 'synced',
  checksum_sha256 char(64),
  byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0),
  version varchar(120),
  pinned boolean NOT NULL DEFAULT false,
  last_verified_at timestamptz,
  expires_at timestamptz,
  failure_reason text,
  metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (resource_type, resource_id, compute_node_id)
);
SELECT touch_updated_at('resource_replicas');
CREATE INDEX IF NOT EXISTS idx_resource_replicas_resource
  ON resource_replicas(resource_type, resource_id, status);
CREATE INDEX IF NOT EXISTS idx_resource_replicas_node_status
  ON resource_replicas(compute_node_id, status, sync_status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_replicas_scope
  ON resource_replicas(tenant_id, project_id, resource_type, updated_at DESC);

CREATE TABLE IF NOT EXISTS resource_sync_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
  project_id uuid REFERENCES projects(id) ON DELETE SET NULL,
  target_compute_node_id uuid NOT NULL REFERENCES compute_nodes(id) ON DELETE CASCADE,
  source_compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL,
  resource_type varchar(80) NOT NULL,
  resource_id uuid NOT NULL,
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  status task_status NOT NULL DEFAULT 'pending',
  transfer_mode varchar(40) NOT NULL DEFAULT 'app_proxy',
  source_uri text,
  target_path text NOT NULL,
  byte_size bigint NOT NULL DEFAULT 0 CHECK (byte_size >= 0),
  checksum_sha256 char(64),
  progress numeric(5,2) NOT NULL DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
  failure_reason text,
  requested_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  completed_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('resource_sync_jobs');
CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_status
  ON resource_sync_jobs(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_target
  ON resource_sync_jobs(target_compute_node_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_sync_jobs_resource
  ON resource_sync_jobs(resource_type, resource_id, status);

CREATE TABLE IF NOT EXISTS resource_acl (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  resource_type varchar(80) NOT NULL,
  resource_id text NOT NULL,
  subject_type varchar(40) NOT NULL,
  subject_id text NOT NULL,
  permissions text[] NOT NULL DEFAULT '{}',
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (resource_type, resource_id, subject_type, subject_id)
);
SELECT touch_updated_at('resource_acl');
CREATE INDEX IF NOT EXISTS idx_resource_acl_resource
  ON resource_acl(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_acl_subject
  ON resource_acl(subject_type, subject_id);

CREATE TABLE IF NOT EXISTS file_upload_sessions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL,
  original_name text NOT NULL,
  byte_size bigint NOT NULL DEFAULT 0,
  checksum_sha256 char(64),
  part_size_bytes integer NOT NULL DEFAULT 8388608,
  uploaded_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
  status varchar(40) NOT NULL DEFAULT 'uploading',
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  expires_at timestamptz NOT NULL DEFAULT now() + interval '1 day',
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('file_upload_sessions');
CREATE INDEX IF NOT EXISTS idx_file_upload_sessions_scope_status
  ON file_upload_sessions(tenant_id, project_id, status, created_at DESC);

CREATE TABLE IF NOT EXISTS local_import_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  compute_node_id uuid REFERENCES compute_nodes(id) ON DELETE SET NULL,
  import_type varchar(40) NOT NULL,
  source_path text NOT NULL,
  target_resource_id uuid,
  status task_status NOT NULL DEFAULT 'pending',
  scan_result jsonb NOT NULL DEFAULT '{}'::jsonb,
  failure_reason text,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
SELECT touch_updated_at('local_import_jobs');
CREATE INDEX IF NOT EXISTS idx_local_import_jobs_scope_status
  ON local_import_jobs(tenant_id, project_id, status, created_at DESC);

CREATE TABLE IF NOT EXISTS fine_tune_checkpoints (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  fine_tune_task_id uuid NOT NULL REFERENCES fine_tune_tasks(id) ON DELETE CASCADE,
  storage_object_id uuid REFERENCES storage_objects(id) ON DELETE SET NULL,
  checkpoint_name varchar(200) NOT NULL,
  step integer,
  metric_name varchar(80),
  metric_value numeric(18,8),
  is_best boolean NOT NULL DEFAULT false,
  protected boolean NOT NULL DEFAULT false,
  size_bytes bigint NOT NULL DEFAULT 0,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_fine_tune_checkpoints_task_step
  ON fine_tune_checkpoints(fine_tune_task_id, step DESC);
CREATE INDEX IF NOT EXISTS idx_fine_tune_checkpoints_best
  ON fine_tune_checkpoints(fine_tune_task_id, is_best) WHERE is_best;

CREATE TABLE IF NOT EXISTS model_services (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  model_id uuid REFERENCES models(id) ON DELETE SET NULL,
  trained_model_id uuid REFERENCES trained_models(id) ON DELETE SET NULL,
  compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL,
  name varchar(150) NOT NULL,
  service_level service_level NOT NULL DEFAULT 'test',
  status task_status NOT NULL DEFAULT 'pending',
  gpu_device_id uuid REFERENCES gpu_devices(id) ON DELETE SET NULL,
  port integer,
  max_concurrency integer NOT NULL DEFAULT 1,
  timeout_seconds integer NOT NULL DEFAULT 120,
  max_context_tokens integer,
  approval_id uuid REFERENCES approval_instances(id) ON DELETE SET NULL,
  created_by uuid REFERENCES users(id) ON DELETE SET NULL,
  started_at timestamptz,
  stopped_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  deleted_at timestamptz
);
SELECT touch_updated_at('model_services');
CREATE INDEX IF NOT EXISTS idx_model_services_scope_status
  ON model_services(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS cleanup_jobs (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
  project_id uuid REFERENCES projects(id) ON DELETE CASCADE,
  cleanup_type varchar(80) NOT NULL,
  status task_status NOT NULL DEFAULT 'pending',
  target jsonb NOT NULL DEFAULT '{}'::jsonb,
  result jsonb NOT NULL DEFAULT '{}'::jsonb,
  failure_reason text,
  created_at timestamptz NOT NULL DEFAULT now(),
  started_at timestamptz,
  completed_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_cleanup_jobs_status_created ON cleanup_jobs(status, created_at DESC);

-- User identity provider extension. First release uses local accounts;
-- OIDC/LDAP can be enabled later without changing resource ownership tables.
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider varchar(40) NOT NULL DEFAULT 'local';
ALTER TABLE users ADD COLUMN IF NOT EXISTS external_id text;
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_users_external_provider ON users(auth_provider, external_id);
CREATE INDEX IF NOT EXISTS idx_users_default_tenant ON users(default_tenant_id);

-- Add tenant/project/resource governance columns to existing resource tables.
ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS storage_node_id uuid REFERENCES storage_nodes(id) ON DELETE SET NULL;
ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project';
ALTER TABLE storage_objects ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
CREATE INDEX IF NOT EXISTS idx_storage_objects_scope
  ON storage_objects(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE models ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE models ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE models ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE models ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project';
ALTER TABLE models ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
CREATE INDEX IF NOT EXISTS idx_models_scope_status
  ON models(tenant_id, project_id, approval_status, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project';
ALTER TABLE trained_models ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
CREATE INDEX IF NOT EXISTS idx_trained_models_scope
  ON trained_models(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE datasets ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS visibility varchar(40) NOT NULL DEFAULT 'project';
ALTER TABLE datasets ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
CREATE INDEX IF NOT EXISTS idx_datasets_scope_status
  ON datasets(tenant_id, project_id, type, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE dataset_files ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE dataset_files ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_dataset_files_scope
  ON dataset_files(tenant_id, project_id, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
ALTER TABLE data_process_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_data_process_tasks_scope_status
  ON data_process_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL;
ALTER TABLE fine_tune_tasks ADD COLUMN IF NOT EXISTS resume_checkpoint_id uuid REFERENCES fine_tune_checkpoints(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_fine_tune_tasks_scope_status
  ON fine_tune_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
ALTER TABLE inference_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_inference_tasks_scope_status
  ON inference_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS approval_status approval_status NOT NULL DEFAULT 'not_required';
ALTER TABLE eval_tasks ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_eval_tasks_scope_status
  ON eval_tasks(tenant_id, project_id, status, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS owner_id uuid REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE data_convert_jobs ADD COLUMN IF NOT EXISTS compute_job_id uuid REFERENCES compute_jobs(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_data_convert_jobs_scope_status
  ON data_convert_jobs(tenant_id, project_id, status, created_at DESC);

ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE custom_tools ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_custom_tools_scope
  ON custom_tools(tenant_id, project_id, visibility, created_at DESC) WHERE deleted_at IS NULL;

ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS approval_id uuid REFERENCES approval_instances(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_audit_logs_scope_created
  ON audit_logs(tenant_id, project_id, created_at DESC);

ALTER TABLE web_logs ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL;
ALTER TABLE web_logs ADD COLUMN IF NOT EXISTS project_id uuid REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_web_logs_scope_created
  ON web_logs(tenant_id, project_id, created_at DESC);

CREATE OR REPLACE VIEW v_project_member_permissions AS
SELECT
  p.tenant_id,
  pm.project_id,
  pm.user_id,
  pm.role,
  CASE pm.role
    WHEN 'owner' THEN ARRAY['read','write','execute','download','delete','manage_acl']
    WHEN 'maintainer' THEN ARRAY['read','write','execute','download','delete']
    WHEN 'developer' THEN ARRAY['read','write','execute','download']
    WHEN 'reviewer' THEN ARRAY['read','download']
    ELSE ARRAY['read']
  END AS permissions
FROM project_members pm
JOIN projects p ON p.id = pm.project_id
WHERE p.deleted_at IS NULL;
