CREATE TABLE IF NOT EXISTS languages (
  code text PRIMARY KEY,
  name text NOT NULL,
  enabled boolean NOT NULL DEFAULT true,
  is_default boolean NOT NULL DEFAULT false,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  CHECK (code ~ '^[a-z]{2}(-[A-Z]{2})?$'),
  CHECK (length(name) BETWEEN 1 AND 80)
);

CREATE UNIQUE INDEX IF NOT EXISTS languages_single_default_idx
  ON languages (is_default)
  WHERE is_default = true;

INSERT INTO languages (code, name, enabled, is_default, sort_order)
VALUES
  ('en', 'English', true, true, 10),
  ('zh-CN', '简体中文', true, false, 20)
ON CONFLICT (code) DO NOTHING;

CREATE TABLE IF NOT EXISTS entity_translations (
  entity_type text NOT NULL CHECK (
    entity_type IN (
      'pokemon',
      'pokemon-types',
      'skills',
      'environments',
      'favorite-things',
      'acquisition-methods',
      'items',
      'ancient-artifacts',
      'maps',
      'habitats',
      'daily-checklist-items',
      'game-versions',
      'dish-categories',
      'dish-flavors',
      'dishes'
    )
  ),
  entity_id integer NOT NULL,
  locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
  field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')),
  value text NOT NULL,
  PRIMARY KEY (entity_type, entity_id, locale, field_name)
);

DELETE FROM entity_translations
WHERE entity_type = 'life-tags';

ALTER TABLE entity_translations
  DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check,
  ADD CONSTRAINT entity_translations_entity_type_check CHECK (
    entity_type IN (
      'pokemon',
      'pokemon-types',
      'skills',
      'environments',
      'favorite-things',
      'acquisition-methods',
      'items',
      'ancient-artifacts',
      'maps',
      'habitats',
      'daily-checklist-items',
      'game-versions',
      'dish-categories',
      'dish-flavors',
      'dishes'
    )
  );

CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
  ON entity_translations (entity_type, entity_id, field_name, locale);

CREATE TABLE IF NOT EXISTS users (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  email text NOT NULL UNIQUE,
  display_name text NOT NULL,
  password_hash text NOT NULL,
  referral_code text,
  referred_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  email_verified_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (email = lower(email)),
  CHECK (length(display_name) BETWEEN 1 AND 40),
  CHECK (referral_code IS NULL OR referral_code ~ '^[A-Z0-9]{8,16}$')
);

CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx
  ON users(referral_code)
  WHERE referral_code IS NOT NULL;

CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
  ON users(referred_by_user_id);

CREATE TABLE IF NOT EXISTS user_follows (
  follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (follower_user_id, followed_user_id),
  CHECK (follower_user_id <> followed_user_id)
);

CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
  ON user_follows(followed_user_id, created_at DESC, follower_user_id);

CREATE TABLE IF NOT EXISTS environments (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  description text NOT NULL DEFAULT '',
  opposite_environment_id integer REFERENCES environments(id) ON DELETE SET NULL,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id)
);

CREATE TABLE IF NOT EXISTS roles (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  key text NOT NULL UNIQUE,
  name text NOT NULL,
  description text NOT NULL DEFAULT '',
  level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
  enabled boolean NOT NULL DEFAULT true,
  system_role boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (key ~ '^[a-z][a-z0-9-]{1,63}$'),
  CHECK (length(name) BETWEEN 1 AND 80)
);

CREATE INDEX IF NOT EXISTS roles_level_idx
  ON roles(level DESC, id);

CREATE TABLE IF NOT EXISTS permissions (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  key text NOT NULL UNIQUE,
  name text NOT NULL,
  description text NOT NULL DEFAULT '',
  category text NOT NULL DEFAULT 'General',
  enabled boolean NOT NULL DEFAULT true,
  system_permission boolean NOT NULL DEFAULT false,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (key ~ '^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$'),
  CHECK (length(name) BETWEEN 1 AND 120),
  CHECK (length(category) BETWEEN 1 AND 80)
);

CREATE INDEX IF NOT EXISTS permissions_category_idx
  ON permissions(category, key);

CREATE TABLE IF NOT EXISTS role_permissions (
  role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  permission_id integer NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (role_id, permission_id)
);

CREATE INDEX IF NOT EXISTS role_permissions_permission_id_idx
  ON role_permissions(permission_id, role_id);

CREATE TABLE IF NOT EXISTS user_roles (
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role_id integer NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  assigned_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  assigned_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, role_id)
);

CREATE INDEX IF NOT EXISTS user_roles_role_id_idx
  ON user_roles(role_id, user_id);

CREATE TABLE IF NOT EXISTS ai_moderation_settings (
  id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
  enabled boolean NOT NULL DEFAULT true,
  api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')),
  auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token')),
  endpoint text NOT NULL DEFAULT 'https://ai.example.com/v1beta',
  api_key text NOT NULL DEFAULT '',
  model text NOT NULL DEFAULT 'gemini-2.0-flash-lite',
  requests_per_minute integer NOT NULL DEFAULT 10 CHECK (requests_per_minute BETWEEN 1 AND 60),
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (length(endpoint) BETWEEN 1 AND 300),
  CHECK (length(model) BETWEEN 1 AND 120)
);

INSERT INTO ai_moderation_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;

CREATE TABLE IF NOT EXISTS ai_moderation_cache (
  content_hash text NOT NULL,
  model text NOT NULL,
  status text NOT NULL CHECK (status IN ('approved', 'rejected')),
  language_code text REFERENCES languages(code) ON DELETE SET NULL,
  reason text,
  checked_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (content_hash, model),
  CHECK (length(content_hash) BETWEEN 32 AND 128),
  CHECK (length(model) BETWEEN 1 AND 120)
);

CREATE TABLE IF NOT EXISTS rate_limit_settings (
  id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
  settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

INSERT INTO rate_limit_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;

INSERT INTO permissions (key, name, description, category, system_permission)
VALUES
  ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
  ('admin.users.read', 'View users', 'View user role assignments.', 'Users', true),
  ('admin.users.update', 'Manage user roles', 'Assign and remove roles from users.', 'Users', true),
  ('admin.users.assign-owner', 'Assign Owner role', 'Assign and remove the Owner role from users.', 'Users', true),
  ('admin.roles.read', 'View roles', 'View role configuration.', 'Roles', true),
  ('admin.roles.create', 'Create roles', 'Create configurable roles.', 'Roles', true),
  ('admin.roles.update', 'Update roles', 'Edit roles and role permission assignments.', 'Roles', true),
  ('admin.roles.delete', 'Delete roles', 'Delete configurable roles.', 'Roles', true),
  ('admin.permissions.read', 'View permissions', 'View permission configuration.', 'Permissions', true),
  ('admin.permissions.create', 'Create permissions', 'Create configurable permissions.', 'Permissions', true),
  ('admin.permissions.update', 'Update permissions', 'Edit permission metadata and enabled state.', 'Permissions', true),
  ('admin.permissions.delete', 'Delete permissions', 'Delete configurable permissions.', 'Permissions', true),
  ('admin.languages.read', 'View languages', 'View language settings.', 'Languages', true),
  ('admin.languages.create', 'Create languages', 'Create languages.', 'Languages', true),
  ('admin.languages.update', 'Update languages', 'Edit language settings.', 'Languages', true),
  ('admin.languages.delete', 'Delete languages', 'Delete languages.', 'Languages', true),
  ('admin.languages.order', 'Order languages', 'Reorder languages.', 'Languages', true),
  ('admin.wordings.read', 'View system wordings', 'View system wording values.', 'System wordings', true),
  ('admin.wordings.update', 'Update system wordings', 'Edit system wording values.', 'System wordings', true),
  ('admin.ai-moderation.read', 'View AI moderation settings', 'View AI moderation configuration.', 'AI moderation', true),
  ('admin.ai-moderation.update', 'Update AI moderation settings', 'Edit AI moderation configuration.', 'AI moderation', true),
  ('admin.rate-limits.read', 'View rate limits', 'View user rate limit settings.', 'Rate limits', true),
  ('admin.rate-limits.update', 'Update rate limits', 'Edit user rate limit settings.', 'Rate limits', true),
  ('admin.config.read', 'View system config', 'View management configuration records.', 'System config', true),
  ('admin.config.create', 'Create system config', 'Create management configuration records.', 'System config', true),
  ('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
  ('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true),
  ('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true),
  ('admin.data.export', 'Export data', 'Export content data bundles.', 'Data tools', true),
  ('admin.data.import', 'Import and wipe data', 'Import content data bundles and wipe content data.', 'Data tools', true),
  ('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true),
  ('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true),
  ('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true),
  ('checklist.order', 'Order checklist tasks', 'Reorder Daily CheckList tasks.', 'CheckList', true),
  ('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
  ('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
  ('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
  ('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
  ('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
  ('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
  ('habitats.update', 'Update habitats', 'Edit habitat records.', 'Habitats', true),
  ('habitats.delete', 'Delete habitats', 'Delete habitat records.', 'Habitats', true),
  ('habitats.order', 'Order habitats', 'Reorder habitat records.', 'Habitats', true),
  ('habitats.upload', 'Upload habitat images', 'Upload habitat images.', 'Habitats', true),
  ('items.create', 'Create items', 'Create item records.', 'Items', true),
  ('items.update', 'Update items', 'Edit item records.', 'Items', true),
  ('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
  ('items.order', 'Order items', 'Reorder item records.', 'Items', true),
  ('items.upload', 'Upload item images', 'Upload item images.', 'Items', true),
  ('ancient-artifacts.create', 'Create Ancient Artifacts', 'Create Ancient Artifact records.', 'Ancient Artifacts', true),
  ('ancient-artifacts.update', 'Update Ancient Artifacts', 'Edit Ancient Artifact records.', 'Ancient Artifacts', true),
  ('ancient-artifacts.delete', 'Delete Ancient Artifacts', 'Delete Ancient Artifact records.', 'Ancient Artifacts', true),
  ('ancient-artifacts.order', 'Order Ancient Artifacts', 'Reorder Ancient Artifact records.', 'Ancient Artifacts', true),
  ('ancient-artifacts.upload', 'Upload Ancient Artifact images', 'Upload Ancient Artifact images.', 'Ancient Artifacts', true),
  ('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
  ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
  ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
  ('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true),
  ('dish.create', 'Create Dish records', 'Create Dish categories and dish records.', 'Dish', true),
  ('dish.update', 'Update Dish records', 'Edit Dish categories and dish records.', 'Dish', true),
  ('dish.delete', 'Delete Dish records', 'Delete Dish categories and dish records.', 'Dish', true),
  ('dish.order', 'Order Dish records', 'Reorder Dish categories and dish records.', 'Dish', true),
  ('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true),
  ('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true),
  ('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true),
  ('life.posts.update-any', 'Update any Life post', 'Edit any Life post.', 'Life', true),
  ('life.posts.delete-any', 'Delete any Life post', 'Delete any Life post.', 'Life', true),
  ('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true),
  ('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
  ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
  ('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
  ('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
  ('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
  ('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
  ('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
  ('threads.reactions.set', 'Set Thread reactions', 'Set and remove Thread and Thread message reactions.', 'Threads', true),
  ('admin.threads.channels.read', 'View Thread channels', 'View Thread channel configuration.', 'Threads', true),
  ('admin.threads.channels.create', 'Create Thread channels', 'Create Thread channels.', 'Threads', true),
  ('admin.threads.channels.update', 'Update Thread channels', 'Edit Thread channel configuration.', 'Threads', true),
  ('admin.threads.channels.delete', 'Delete Thread channels', 'Delete Thread channels.', 'Threads', true),
  ('admin.threads.threads.delete', 'Delete any Thread', 'Delete any Thread.', 'Threads', true),
  ('admin.threads.threads.lock', 'Lock Threads', 'Lock and unlock Threads.', 'Threads', true),
  ('admin.threads.messages.delete', 'Delete any Thread message', 'Delete any Thread message.', 'Threads', true),
  ('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
  ('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
  ('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
  ('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true),
  ('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING;

DELETE FROM permissions
WHERE key = 'pokemon.order';

DELETE FROM permissions
WHERE key = 'life.ratings.set';

INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES
  ('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
  ('admin', 'Admin', 'System manager with content, configuration and user administration permissions.', 800, true, true),
  ('editor', 'Editor', 'Wiki editor with content creation, update, sorting and community permissions.', 500, true, true),
  ('member', 'Member', 'Community member with Life and discussion permissions.', 100, true, true),
  ('viewer', 'Viewer', 'Read-only role for explicit access grouping.', 0, true, true)
ON CONFLICT (key) DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
CROSS JOIN permissions p
WHERE r.key = 'owner'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'admin.access',
  'admin.users.read',
  'admin.users.update',
  'admin.roles.read',
  'admin.roles.create',
  'admin.roles.update',
  'admin.roles.delete',
  'admin.permissions.read',
  'admin.permissions.create',
  'admin.permissions.update',
  'admin.permissions.delete',
  'admin.languages.read',
  'admin.languages.create',
  'admin.languages.update',
  'admin.languages.delete',
  'admin.languages.order',
  'admin.wordings.read',
  'admin.wordings.update',
  'admin.ai-moderation.read',
  'admin.ai-moderation.update',
  'admin.rate-limits.read',
  'admin.rate-limits.update',
  'admin.config.read',
  'admin.config.create',
  'admin.config.update',
  'admin.config.delete',
  'admin.config.order',
  'checklist.create',
  'checklist.update',
  'checklist.delete',
  'checklist.order',
  'pokemon.create',
  'pokemon.update',
  'pokemon.delete',
  'pokemon.fetch',
  'pokemon.upload',
  'habitats.create',
  'habitats.update',
  'habitats.delete',
  'habitats.order',
  'habitats.upload',
  'items.create',
  'items.update',
  'items.delete',
  'items.order',
  'items.upload',
  'ancient-artifacts.create',
  'ancient-artifacts.update',
  'ancient-artifacts.delete',
  'ancient-artifacts.order',
  'ancient-artifacts.upload',
  'recipes.create',
  'recipes.update',
  'recipes.delete',
  'recipes.order',
  'dish.create',
  'dish.update',
  'dish.delete',
  'dish.order',
  'life.posts.create',
  'life.posts.update',
  'life.posts.delete',
  'life.posts.update-any',
  'life.posts.delete-any',
  'life.comments.create',
  'life.comments.delete',
  'life.comments.delete-any',
  'life.comments.like',
  'life.reactions.set',
  'threads.create',
  'threads.messages.create',
  'threads.follow',
  'threads.reactions.set',
  'admin.threads.channels.read',
  'admin.threads.channels.create',
  'admin.threads.channels.update',
  'admin.threads.channels.delete',
  'admin.threads.threads.delete',
  'admin.threads.threads.lock',
  'admin.threads.messages.delete',
  'users.follow',
  'discussions.comments.create',
  'discussions.comments.delete',
  'discussions.comments.delete-any',
  'discussions.comments.like'
])
WHERE r.key = 'admin'
  AND NOT EXISTS (
    SELECT 1
    FROM role_permissions existing_role_permission
    WHERE existing_role_permission.role_id = r.id
)
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'admin.ai-moderation.read',
  'admin.ai-moderation.update'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'admin.rate-limits.read',
  'admin.rate-limits.update'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'admin.access',
  'admin.config.read',
  'checklist.create',
  'checklist.update',
  'checklist.order',
  'pokemon.create',
  'pokemon.update',
  'pokemon.fetch',
  'pokemon.upload',
  'habitats.create',
  'habitats.update',
  'habitats.order',
  'habitats.upload',
  'items.create',
  'items.update',
  'items.order',
  'items.upload',
  'ancient-artifacts.create',
  'ancient-artifacts.update',
  'ancient-artifacts.order',
  'ancient-artifacts.upload',
  'recipes.create',
  'recipes.update',
  'recipes.order',
  'dish.create',
  'dish.update',
  'dish.order',
  'life.posts.create',
  'life.posts.update',
  'life.posts.delete',
  'life.comments.create',
  'life.comments.delete',
  'life.comments.like',
  'life.reactions.set',
  'threads.create',
  'threads.messages.create',
  'threads.follow',
  'threads.reactions.set',
  'users.follow',
  'discussions.comments.create',
  'discussions.comments.delete',
  'discussions.comments.like'
])
WHERE r.key = 'editor'
  AND NOT EXISTS (
    SELECT 1
    FROM role_permissions existing_role_permission
    WHERE existing_role_permission.role_id = r.id
  )
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'ancient-artifacts.create',
  'ancient-artifacts.update',
  'ancient-artifacts.delete',
  'ancient-artifacts.order',
  'ancient-artifacts.upload'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'ancient-artifacts.create',
  'ancient-artifacts.update',
  'ancient-artifacts.order',
  'ancient-artifacts.upload'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'dish.create',
  'dish.update',
  'dish.delete',
  'dish.order'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'dish.create',
  'dish.update',
  'dish.order'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'life.posts.create',
  'life.posts.update',
  'life.posts.delete',
  'life.comments.create',
  'life.comments.delete',
  'life.comments.like',
  'life.reactions.set',
  'threads.create',
  'threads.messages.create',
  'threads.follow',
  'threads.reactions.set',
  'users.follow',
  'discussions.comments.create',
  'discussions.comments.delete',
  'discussions.comments.like'
])
WHERE r.key = 'member'
  AND NOT EXISTS (
    SELECT 1
    FROM role_permissions existing_role_permission
    WHERE existing_role_permission.role_id = r.id
  )
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'discussions.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'users.follow'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'threads.create',
  'threads.messages.create',
  'threads.follow',
  'threads.reactions.set'
])
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;

INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
  'admin.threads.channels.read',
  'admin.threads.channels.create',
  'admin.threads.channels.update',
  'admin.threads.channels.delete',
  'admin.threads.threads.delete',
  'admin.threads.threads.lock',
  'admin.threads.messages.delete'
])
WHERE r.key = 'admin'
ON CONFLICT DO NOTHING;

WITH first_owner_user AS (
  SELECT u.id
  FROM users u
  WHERE u.email_verified_at IS NOT NULL
    AND NOT EXISTS (
      SELECT 1
      FROM user_roles ur
      JOIN roles existing_role ON existing_role.id = ur.role_id
      WHERE existing_role.key = 'owner'
    )
  ORDER BY u.email_verified_at ASC, u.id ASC
  LIMIT 1
)
INSERT INTO user_roles (user_id, role_id)
SELECT first_owner_user.id, r.id
FROM first_owner_user
CROSS JOIN roles r
WHERE r.key = 'owner'
ON CONFLICT DO NOTHING;

INSERT INTO user_roles (user_id, role_id)
SELECT u.id, r.id
FROM users u
CROSS JOIN roles r
WHERE u.email_verified_at IS NOT NULL
  AND r.key = 'editor'
  AND NOT EXISTS (
    SELECT 1
    FROM user_roles ur
    WHERE ur.user_id = u.id
  )
ON CONFLICT DO NOTHING;

CREATE TABLE IF NOT EXISTS system_wording_keys (
  key text PRIMARY KEY,
  module text NOT NULL,
  surface text NOT NULL CHECK (surface IN ('frontend', 'backend', 'email')),
  description text NOT NULL DEFAULT '',
  placeholders jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(placeholders) = 'array'),
  enabled boolean NOT NULL DEFAULT true,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (key ~ '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$'),
  CHECK (length(module) BETWEEN 1 AND 80)
);

CREATE INDEX IF NOT EXISTS system_wording_keys_module_idx
  ON system_wording_keys(module, key);

CREATE INDEX IF NOT EXISTS system_wording_keys_surface_idx
  ON system_wording_keys(surface, key);

CREATE TABLE IF NOT EXISTS system_wording_values (
  key text NOT NULL REFERENCES system_wording_keys(key) ON DELETE CASCADE,
  locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
  value text NOT NULL CHECK (length(value) > 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (key, locale)
);

CREATE INDEX IF NOT EXISTS system_wording_values_locale_idx
  ON system_wording_values(locale, key);

CREATE TABLE IF NOT EXISTS email_verification_tokens (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,
  expires_at timestamptz NOT NULL,
  used_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
  ON email_verification_tokens(user_id);

CREATE TABLE IF NOT EXISTS password_reset_tokens (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,
  expires_at timestamptz NOT NULL,
  used_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
  ON password_reset_tokens(user_id);

CREATE TABLE IF NOT EXISTS user_sessions (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,
  expires_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
  ON user_sessions(user_id);

CREATE TABLE IF NOT EXISTS daily_checklist_items (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  title text NOT NULL,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
  ON daily_checklist_items(sort_order, id);

CREATE TABLE IF NOT EXISTS game_versions (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  change_log text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
  ON game_versions(sort_order, id);

CREATE TABLE IF NOT EXISTS life_posts (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
  game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
  ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
  ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
  ai_moderation_reason text,
  ai_moderation_content_hash text,
  ai_moderation_checked_at timestamptz,
  ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
  ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
  ON life_posts(created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx
  ON life_posts(created_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx
  ON life_posts(created_by_user_id, created_at DESC, id DESC)
  WHERE deleted_at IS NULL;

DROP INDEX IF EXISTS life_posts_category_idx;
DROP TABLE IF EXISTS life_post_ratings;
DROP TABLE IF EXISTS life_post_tags;

ALTER TABLE life_posts
  DROP COLUMN IF EXISTS category_id;

DROP TABLE IF EXISTS life_tags;

CREATE TABLE IF NOT EXISTS life_post_comments (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
  parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
  body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
  ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
  ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
  ai_moderation_reason text,
  ai_moderation_content_hash text,
  ai_moderation_checked_at timestamptz,
  ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
  ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
  ON life_post_comments(post_id, created_at, id);

CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
  ON life_post_comments(parent_comment_id, created_at, id);

CREATE INDEX IF NOT EXISTS life_post_comments_user_idx
  ON life_post_comments(created_by_user_id, created_at DESC, id DESC);

CREATE TABLE IF NOT EXISTS life_comment_likes (
  comment_id integer NOT NULL REFERENCES life_post_comments(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (comment_id, user_id)
);

CREATE INDEX IF NOT EXISTS life_comment_likes_comment_idx
  ON life_comment_likes(comment_id);

CREATE INDEX IF NOT EXISTS life_comment_likes_user_idx
  ON life_comment_likes(user_id, created_at DESC, comment_id DESC);

CREATE TABLE IF NOT EXISTS life_post_reactions (
  post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (post_id, user_id)
);

CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
  ON life_post_reactions(post_id, reaction_type);

CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
  ON life_post_reactions(user_id, updated_at DESC, post_id DESC);

CREATE TABLE IF NOT EXISTS thread_channels (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  allow_user_threads boolean NOT NULL DEFAULT true,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (length(name) BETWEEN 1 AND 80)
);

CREATE INDEX IF NOT EXISTS thread_channels_sort_order_idx
  ON thread_channels(sort_order, id);

INSERT INTO thread_channels (name, allow_user_threads, sort_order)
VALUES
  ('General', true, 10),
  ('Questions', true, 20),
  ('Showcase', true, 30)
ON CONFLICT (name) DO NOTHING;

CREATE TABLE IF NOT EXISTS thread_channel_tags (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
  name text NOT NULL,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (channel_id, name),
  CHECK (length(name) BETWEEN 1 AND 40)
);

CREATE INDEX IF NOT EXISTS thread_channel_tags_channel_sort_idx
  ON thread_channel_tags(channel_id, sort_order, id);

CREATE TABLE IF NOT EXISTS thread_channel_languages (
  channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
  language_code text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  PRIMARY KEY (channel_id, language_code)
);

CREATE INDEX IF NOT EXISTS thread_channel_languages_sort_idx
  ON thread_channel_languages(channel_id, sort_order, language_code);

CREATE TABLE IF NOT EXISTS threads (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
  title text NOT NULL,
  language_code text NOT NULL REFERENCES languages(code) ON DELETE RESTRICT,
  locked boolean NOT NULL DEFAULT false,
  message_count integer NOT NULL DEFAULT 0 CHECK (message_count >= 0),
  last_message_id integer,
  last_active_at timestamptz NOT NULL DEFAULT now(),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (length(title) BETWEEN 1 AND 140)
);

CREATE INDEX IF NOT EXISTS threads_channel_last_active_idx
  ON threads(channel_id, last_active_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS threads_created_at_idx
  ON threads(created_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS threads_language_idx
  ON threads(language_code, last_active_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE TABLE IF NOT EXISTS thread_tag_links (
  thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
  tag_id integer NOT NULL REFERENCES thread_channel_tags(id) ON DELETE CASCADE,
  PRIMARY KEY (thread_id, tag_id)
);

CREATE INDEX IF NOT EXISTS thread_tag_links_tag_idx
  ON thread_tag_links(tag_id, thread_id);

CREATE TABLE IF NOT EXISTS thread_messages (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
  body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
  ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
  ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
  ai_moderation_reason text,
  ai_moderation_content_hash text,
  ai_moderation_checked_at timestamptz,
  ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
  ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1
    FROM pg_constraint
    WHERE conname = 'threads_last_message_fk'
  ) THEN
    ALTER TABLE threads
      ADD CONSTRAINT threads_last_message_fk
      FOREIGN KEY (last_message_id) REFERENCES thread_messages(id) ON DELETE SET NULL;
  END IF;
END $$;

CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
  ON thread_messages(thread_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS thread_messages_user_idx
  ON thread_messages(created_by_user_id, created_at DESC, id DESC);

CREATE TABLE IF NOT EXISTS thread_reactions (
  thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (thread_id, user_id, reaction_type)
);

ALTER TABLE thread_reactions
  DROP CONSTRAINT IF EXISTS thread_reactions_reaction_type_check,
  ADD CONSTRAINT thread_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
UPDATE thread_reactions
SET reaction_type = CASE reaction_type
  WHEN 'thumbs-up' THEN '👍'
  WHEN 'heart' THEN '❤️'
  WHEN 'laugh' THEN '😂'
  WHEN 'fire' THEN '🔥'
  WHEN 'eyes' THEN '👀'
  ELSE reaction_type
END
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');

CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx
  ON thread_reactions(thread_id, reaction_type);

CREATE TABLE IF NOT EXISTS thread_message_reactions (
  message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (message_id, user_id, reaction_type)
);

ALTER TABLE thread_message_reactions
  DROP CONSTRAINT IF EXISTS thread_message_reactions_reaction_type_check,
  ADD CONSTRAINT thread_message_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
UPDATE thread_message_reactions
SET reaction_type = CASE reaction_type
  WHEN 'thumbs-up' THEN '👍'
  WHEN 'heart' THEN '❤️'
  WHEN 'laugh' THEN '😂'
  WHEN 'fire' THEN '🔥'
  WHEN 'eyes' THEN '👀'
  ELSE reaction_type
END
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');

CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
  ON thread_message_reactions(message_id, reaction_type);

CREATE TABLE IF NOT EXISTS thread_follows (
  thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (thread_id, user_id)
);

CREATE INDEX IF NOT EXISTS thread_follows_user_idx
  ON thread_follows(user_id, created_at DESC, thread_id DESC);

CREATE TABLE IF NOT EXISTS thread_reads (
  thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  last_read_message_id integer REFERENCES thread_messages(id) ON DELETE SET NULL,
  last_read_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (thread_id, user_id)
);

CREATE INDEX IF NOT EXISTS thread_reads_user_idx
  ON thread_reads(user_id, thread_id);

CREATE TABLE IF NOT EXISTS thread_ws_tickets (
  ticket_hash text PRIMARY KEY,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (length(ticket_hash) BETWEEN 32 AND 128)
);

CREATE INDEX IF NOT EXISTS thread_ws_tickets_expires_idx
  ON thread_ws_tickets(expires_at);

CREATE TABLE IF NOT EXISTS skills (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  has_item_drop boolean NOT NULL DEFAULT false,
  has_trading boolean NOT NULL DEFAULT false,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS favorite_things (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  opposite_favorite_thing_id integer REFERENCES favorite_things(id) ON DELETE SET NULL,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id)
);

CREATE TABLE IF NOT EXISTS pokemon_types (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS pokemon (
  id integer PRIMARY KEY,
  data_id integer CHECK (data_id > 0),
  data_identifier text NOT NULL DEFAULT '',
  display_id integer NOT NULL CHECK (display_id > 0),
  name text NOT NULL UNIQUE,
  is_event_item boolean NOT NULL DEFAULT false,
  genus text NOT NULL DEFAULT '',
  details text NOT NULL DEFAULT '',
  height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0),
  weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0),
  environment_id integer NOT NULL REFERENCES environments(id),
  hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0),
  attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0),
  defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0),
  special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
  special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
  speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
  image_path text NOT NULL DEFAULT '',
  image_style text NOT NULL DEFAULT '',
  image_version text NOT NULL DEFAULT '',
  image_variant text NOT NULL DEFAULT '',
  image_description text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
  pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
  type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
  slot_order integer NOT NULL CHECK (slot_order BETWEEN 1 AND 2),
  PRIMARY KEY (pokemon_id, type_id),
  UNIQUE (pokemon_id, slot_order)
);

CREATE TABLE IF NOT EXISTS pokemon_skills (
  pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
  skill_id integer NOT NULL REFERENCES skills(id),
  PRIMARY KEY (pokemon_id, skill_id)
);

CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
  pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
  favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
  PRIMARY KEY (pokemon_id, favorite_thing_id)
);

CREATE TABLE IF NOT EXISTS acquisition_methods (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS items (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  details text NOT NULL DEFAULT '',
  base_price integer,
  ancient_artifact_category_key text,
  category_key text NOT NULL DEFAULT 'other',
  usage_key text,
  dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)),
  dyeable boolean NOT NULL DEFAULT false,
  dual_dyeable boolean NOT NULL DEFAULT false,
  pattern_editable boolean NOT NULL DEFAULT false,
  no_recipe boolean NOT NULL DEFAULT false,
  is_event_item boolean NOT NULL DEFAULT false,
  image_path text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (category_key IN (
    'furniture',
    'misc',
    'outdoor',
    'utilities',
    'buildings',
    'blocks',
    'kits',
    'nature',
    'food',
    'materials',
    'key-items',
    'other'
  )),
  CHECK (
    ancient_artifact_category_key IS NULL
    OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
  ),
  CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
);

CREATE TABLE IF NOT EXISTS recipes (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  item_id integer NOT NULL UNIQUE REFERENCES items(id),
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS recipe_acquisition_methods (
  recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
  acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
  PRIMARY KEY (recipe_id, acquisition_method_id)
);

CREATE TABLE IF NOT EXISTS item_acquisition_methods (
  item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
  acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id),
  PRIMARY KEY (item_id, acquisition_method_id)
);

CREATE TABLE IF NOT EXISTS item_favorite_things (
  item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
  favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
  PRIMARY KEY (item_id, favorite_thing_id)
);

CREATE TABLE IF NOT EXISTS pokemon_trading_items (
  pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
  item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
  preference text NOT NULL CHECK (preference IN ('like', 'neutral')),
  PRIMARY KEY (pokemon_id, item_id)
);

CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx
  ON pokemon_trading_items(item_id, preference, pokemon_id);

CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
  pokemon_id integer NOT NULL,
  skill_id integer NOT NULL,
  item_id integer NOT NULL REFERENCES items(id),
  PRIMARY KEY (pokemon_id, skill_id),
  FOREIGN KEY (pokemon_id, skill_id) REFERENCES pokemon_skills(pokemon_id, skill_id) ON DELETE CASCADE
);

CREATE TABLE IF NOT EXISTS recipe_materials (
  recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
  item_id integer NOT NULL REFERENCES items(id),
  quantity integer NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (recipe_id, item_id)
);

CREATE TABLE IF NOT EXISTS dish_categories (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  cookware_item_id integer NOT NULL REFERENCES items(id),
  main_material_item_id integer NOT NULL REFERENCES items(id),
  total_material_quantity integer NOT NULL DEFAULT 2 CHECK (total_material_quantity >= 2),
  effect text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS dish_flavors (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS dishes (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  category_id integer NOT NULL REFERENCES dish_categories(id) ON DELETE CASCADE,
  item_id integer NOT NULL UNIQUE REFERENCES items(id),
  flavor_id integer NOT NULL REFERENCES dish_flavors(id),
  secondary_material_1_item_id integer REFERENCES items(id),
  secondary_material_2_item_id integer REFERENCES items(id),
  pokemon_skill_id integer REFERENCES skills(id),
  mosslax_effect text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  CHECK (
    secondary_material_1_item_id IS NULL
    OR secondary_material_2_item_id IS NULL
    OR secondary_material_1_item_id <> secondary_material_2_item_id
  )
);

CREATE TABLE IF NOT EXISTS maps (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS habitats (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  name text NOT NULL UNIQUE,
  is_event_item boolean NOT NULL DEFAULT false,
  image_path text NOT NULL DEFAULT '',
  sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS habitat_recipe_items (
  habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
  item_id integer NOT NULL REFERENCES items(id),
  quantity integer NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (habitat_id, item_id)
);

CREATE TABLE IF NOT EXISTS habitat_pokemon (
  habitat_id integer NOT NULL REFERENCES habitats(id) ON DELETE CASCADE,
  pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
  map_id integer NOT NULL REFERENCES maps(id),
  time_of_day text NOT NULL CHECK (time_of_day IN ('早晨', '中午', '傍晚', '晚上')),
  weather text NOT NULL CHECK (weather IN ('晴天', '阴天', '雨天')),
  rarity integer NOT NULL CHECK (rarity BETWEEN 1 AND 3),
  PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
);

CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
DROP INDEX IF EXISTS pokemon_sort_order_idx;
CREATE INDEX IF NOT EXISTS pokemon_display_order_idx ON pokemon(is_event_item, display_id, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_category_sort_order_idx ON dishes(category_id, sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_sort_order_idx ON dishes(sort_order, id);
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);

CREATE TABLE IF NOT EXISTS wiki_edit_logs (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  entity_type text NOT NULL,
  entity_id integer NOT NULL,
  action text NOT NULL CHECK (action IN ('create', 'update', 'delete')),
  user_id integer REFERENCES users(id) ON DELETE SET NULL,
  changes jsonb NOT NULL DEFAULT '[]'::jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
  ON wiki_edit_logs(entity_type, entity_id, created_at DESC);

CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
  ON wiki_edit_logs(user_id);

CREATE TABLE IF NOT EXISTS entity_image_uploads (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')),
  entity_id integer,
  entity_name text NOT NULL,
  path text NOT NULL UNIQUE,
  original_filename text NOT NULL DEFAULT '',
  mime_type text NOT NULL CHECK (mime_type IN ('image/png', 'image/jpeg', 'image/webp', 'image/gif')),
  byte_size integer NOT NULL CHECK (byte_size > 0),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  CHECK (length(entity_name) BETWEEN 1 AND 120),
  CHECK (path !~ '(^/|\\.\\.)')
);

CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
  ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx
  ON entity_image_uploads(created_by_user_id);

CREATE TABLE IF NOT EXISTS entity_discussion_comments (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')),
  entity_id integer NOT NULL,
  parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
  body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
  ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
  ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
  ai_moderation_reason text,
  ai_moderation_content_hash text,
  ai_moderation_checked_at timestamptz,
  ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
  ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
  created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  deleted_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx
  ON entity_discussion_comments(entity_type, entity_id, created_at, id);

CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
  ON entity_discussion_comments(parent_comment_id, created_at, id);

CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
  ON entity_discussion_comments(created_by_user_id);

CREATE TABLE IF NOT EXISTS entity_discussion_comment_likes (
  comment_id integer NOT NULL REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (comment_id, user_id)
);

CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
  ON entity_discussion_comment_likes(comment_id);

CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
  ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);

CREATE TABLE IF NOT EXISTS notifications (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  recipient_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  actor_user_id integer REFERENCES users(id) ON DELETE SET NULL,
  type text NOT NULL CHECK (
    type IN (
      'life_post_comment',
      'life_comment_reply',
      'discussion_comment_reply',
      'life_post_reaction',
      'user_follow',
      'moderation_result'
    )
  ),
  life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
  profile_user_id integer REFERENCES users(id) ON DELETE CASCADE,
  life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
  parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
  discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
  parent_discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE SET NULL,
  entity_type text CHECK (
    entity_type IS NULL OR entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
  ),
  entity_id integer,
  reaction_type text CHECK (reaction_type IS NULL OR reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
  moderation_status text CHECK (moderation_status IS NULL OR moderation_status IN ('approved', 'rejected', 'failed')),
  moderation_reason text,
  read_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS notifications_recipient_created_idx
  ON notifications(recipient_user_id, created_at DESC, id DESC);

CREATE INDEX IF NOT EXISTS notifications_recipient_unread_idx
  ON notifications(recipient_user_id, created_at DESC, id DESC)
  WHERE read_at IS NULL;

CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_comment_unique_idx
  ON notifications(recipient_user_id, life_comment_id)
  WHERE type = 'life_post_comment' AND life_comment_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_comment_reply_unique_idx
  ON notifications(recipient_user_id, life_comment_id)
  WHERE type = 'life_comment_reply' AND life_comment_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS notifications_discussion_comment_reply_unique_idx
  ON notifications(recipient_user_id, discussion_comment_id)
  WHERE type = 'discussion_comment_reply' AND discussion_comment_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
  ON notifications(recipient_user_id, actor_user_id, life_post_id)
  WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
  ON notifications(recipient_user_id, actor_user_id, profile_user_id)
  WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;

CREATE TABLE IF NOT EXISTS notification_ws_tickets (
  id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash text NOT NULL UNIQUE,
  expires_at timestamptz NOT NULL,
  used_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
  ON notification_ws_tickets(user_id, expires_at DESC);

CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
  ON life_posts(game_version_id, created_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx
  ON life_posts(ai_moderation_status, ai_moderation_updated_at, id)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_language_idx
  ON life_posts(ai_moderation_language_code, created_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_status_idx
  ON life_post_comments(ai_moderation_status, ai_moderation_updated_at, id)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS life_post_comments_ai_moderation_language_idx
  ON life_post_comments(ai_moderation_language_code, created_at, id)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx
  ON entity_discussion_comments(ai_moderation_status, ai_moderation_updated_at, id)
  WHERE deleted_at IS NULL;

CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
  ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
  WHERE deleted_at IS NULL;

ALTER TABLE skills
  ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;

ALTER TABLE items
  ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));

ALTER TABLE environments
  ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';

ALTER TABLE environments
  ADD COLUMN IF NOT EXISTS opposite_environment_id integer;

ALTER TABLE favorite_things
  ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;

DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_fkey'
  ) THEN
    ALTER TABLE environments
      ADD CONSTRAINT environments_opposite_environment_id_fkey
      FOREIGN KEY (opposite_environment_id) REFERENCES environments(id) ON DELETE SET NULL;
  END IF;

  IF NOT EXISTS (
    SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_check'
  ) THEN
    ALTER TABLE environments
      ADD CONSTRAINT environments_opposite_environment_id_check
      CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id);
  END IF;

  IF NOT EXISTS (
    SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_fkey'
  ) THEN
    ALTER TABLE favorite_things
      ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_fkey
      FOREIGN KEY (opposite_favorite_thing_id) REFERENCES favorite_things(id) ON DELETE SET NULL;
  END IF;

  IF NOT EXISTS (
    SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_check'
  ) THEN
    ALTER TABLE favorite_things
      ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_check
      CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id);
  END IF;
END $$;

CREATE UNIQUE INDEX IF NOT EXISTS environments_opposite_environment_unique_idx
  ON environments(opposite_environment_id)
  WHERE opposite_environment_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS favorite_things_opposite_favorite_thing_unique_idx
  ON favorite_things(opposite_favorite_thing_id)
  WHERE opposite_favorite_thing_id IS NOT NULL;

UPDATE items
SET dyeability = CASE
  WHEN dual_dyeable THEN 2
  WHEN dyeable THEN 1
  ELSE 0
END
WHERE dyeability = 0
  AND (dual_dyeable OR dyeable);
