import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';

import 'tables.dart';

part 'app_database.g.dart';

@DriftDatabase(
  tables: [
    ActiveExerciseStepProgressStates,
    ActiveExerciseStepResults,
    ActiveRestStates,
    ActiveScoreStopwatchStates,
    ActiveSetTimerStates,
    ActiveSetResults,
    ActiveWorkoutSessions,
    ChangeLogEntries,
    Exercises,
    ExerciseImages,
    ExerciseSteps,
    LocalSeedMetadata,
    MediaAssets,
    OnlineAccountSessions,
    PendingShareActions,
    ProgramExercises,
    Programs,
    RemoteResourceMappings,
    ShareInboxItems,
    SyncMetadataEntries,
    WorkoutHistories,
    WorkoutHistorySetResults,
    WorkoutHistoryStepResults,
    WorkoutTemplateExerciseOverrides,
    WorkoutTemplatePrograms,
    WorkoutTemplates,
  ],
)
final class AppDatabase extends _$AppDatabase {
  AppDatabase(super.executor);

  factory AppDatabase.open() {
    return AppDatabase(
      driftDatabase(
        name: 'gametime',
        native: const DriftNativeOptions(shareAcrossIsolates: true),
      ),
    );
  }

  @override
  int get schemaVersion => 19;

  @override
  MigrationStrategy get migration {
    return MigrationStrategy(
      onCreate: (migrator) async {
        await migrator.createAll();
        await _migrateToSchema15();
        await _migrateToSchema16(migrator);
        await _createIndexes();
      },
      onUpgrade: (migrator, from, to) async {
        if (from < 2) {
          await customStatement(
            'ALTER TABLE active_set_results ADD COLUMN status TEXT NOT NULL '
            "DEFAULT 'completed' CHECK (status IN ('completed', 'skipped'))",
          );
          await customStatement(
            'ALTER TABLE workout_history_set_results ADD COLUMN status TEXT '
            "NOT NULL DEFAULT 'completed' CHECK (status IN ('completed', "
            "'skipped'))",
          );
        }
        if (from < 3) {
          await _migrateToSchema3(migrator);
        }
        if (from < 4) {
          await _migrateToSchema4();
        }
        if (from < 5) {
          await _migrateToSchema5(migrator);
        }
        if (from < 6) {
          await _migrateToSchema6();
        }
        if (from < 7) {
          await _migrateToSchema7();
        }
        if (from < 8) {
          await _migrateToSchema8(migrator);
        }
        if (from < 9) {
          await _migrateToSchema9(migrator);
        }
        if (from < 10) {
          await _migrateToSchema10(migrator);
        }
        if (from < 11) {
          await _migrateToSchema11(migrator);
        }
        if (from < 12) {
          await _migrateToSchema12(migrator);
        }
        if (from < 13) {
          await _migrateToSchema13();
        }
        if (from < 14) {
          await _migrateToSchema14();
        }
        if (from < 15) {
          await _migrateToSchema15();
        }
        if (from < 16) {
          await _migrateToSchema16(migrator);
        }
        if (from < 17) {
          await _migrateToSchema17();
        }
        if (from < 19) {
          await _migrateToSchema19();
        }
        await _createIndexes();
      },
      beforeOpen: (details) async {
        await customStatement('PRAGMA foreign_keys = ON');
      },
    );
  }

  Future<void> _createIndexes() async {
    for (final tableName in _syncableTableNames) {
      await customStatement(
        'CREATE INDEX IF NOT EXISTS idx_${tableName}_deleted_at '
        'ON $tableName (deleted_at)',
      );
      await customStatement(
        'CREATE INDEX IF NOT EXISTS idx_${tableName}_updated_at '
        'ON $tableName (updated_at)',
      );
      await customStatement(
        'CREATE INDEX IF NOT EXISTS idx_${tableName}_sync_state '
        'ON $tableName (sync_state)',
      );
      await customStatement(
        'CREATE INDEX IF NOT EXISTS idx_${tableName}_local_revision '
        'ON $tableName (local_revision)',
      );
    }

    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_program_exercises_program_id '
      'ON program_exercises (program_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id '
      'ON exercise_images (exercise_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position '
      'ON exercise_steps (exercise_id, position)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_exercises_category '
      'ON exercises (category)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_exercises_is_example '
      'ON exercises (is_example)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_programs_is_example '
      'ON programs (is_example)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_templates_is_example '
      'ON workout_templates (is_example)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in '
      'ON online_account_sessions (is_logged_in, updated_at)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource '
      'ON remote_resource_mappings (resource_type, client_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_share_inbox_items_created_at '
      'ON share_inbox_items (created_at)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_pending_share_actions_status '
      'ON pending_share_actions (status, created_at)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
      'ON workout_template_programs (workout_template_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_active_set_results_session_id '
      'ON active_set_results (active_workout_session_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_active_score_stopwatch_states_session_id '
      'ON active_score_stopwatch_states (active_workout_session_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_active_set_timer_states_session_id '
      'ON active_set_timer_states (active_workout_session_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_active_rest_states_session_id '
      'ON active_rest_states (active_workout_session_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS '
      'idx_active_exercise_step_progress_states_session_id '
      'ON active_exercise_step_progress_states (active_workout_session_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_active_exercise_step_results_session_id '
      'ON active_exercise_step_results (active_workout_session_id)',
    );
    await customStatement(
      'CREATE UNIQUE INDEX IF NOT EXISTS '
      'idx_active_workout_sessions_single_open '
      'ON active_workout_sessions ((1)) '
      'WHERE deleted_at IS NULL '
      "AND status IN ('running', 'paused', 'savedExit')",
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_history_started_at '
      'ON workout_history (started_at)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_history_completed_started '
      'ON workout_history (completed, started_at) '
      'WHERE deleted_at IS NULL',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_history_set_results_history_id '
      'ON workout_history_set_results (workout_history_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_history_set_progression_exercise '
      'ON workout_history_set_results (source_exercise_id_snapshot, '
      'exercise_snapshot_id, workout_history_id, status) '
      'WHERE deleted_at IS NULL',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS '
      'idx_workout_history_set_results_source_exercise '
      'ON workout_history_set_results (source_exercise_id_snapshot, '
      'set_index) WHERE deleted_at IS NULL AND '
      'source_exercise_id_snapshot IS NOT NULL',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_workout_history_step_results_history_id '
      'ON workout_history_step_results (workout_history_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_history_step_progression_exercise '
      'ON workout_history_step_results (source_exercise_id_snapshot, '
      'exercise_snapshot_id, workout_history_id, status) '
      'WHERE deleted_at IS NULL',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS '
      'idx_workout_history_step_results_source_exercise '
      'ON workout_history_step_results (source_exercise_id_snapshot, '
      'set_index, step_index) WHERE deleted_at IS NULL AND '
      'source_exercise_id_snapshot IS NOT NULL',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_change_log_entity '
      'ON change_log (entity_type, entity_id)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_change_log_local_revision '
      'ON change_log (local_revision)',
    );
    await customStatement(
      'CREATE INDEX IF NOT EXISTS idx_change_log_synced_at '
      'ON change_log (synced_at)',
    );
  }
}

const _syncableTableNames = [
  'active_exercise_step_progress_states',
  'active_exercise_step_results',
  'active_rest_states',
  'active_score_stopwatch_states',
  'active_set_timer_states',
  'active_set_results',
  'active_workout_sessions',
  'exercises',
  'exercise_images',
  'exercise_steps',
  'media_assets',
  'program_exercises',
  'programs',
  'workout_history',
  'workout_history_set_results',
  'workout_history_step_results',
  'workout_template_exercise_overrides',
  'workout_template_programs',
  'workout_templates',
];

extension on AppDatabase {
  Future<void> _migrateToSchema3(Migrator migrator) async {
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN score_input_mode TEXT NOT NULL '
      "DEFAULT 'manual' CHECK (score_input_mode IN ('manual', 'stopwatch'))",
    );
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN score_input_mode_snapshot TEXT '
      "NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
      "('manual', 'stopwatch'))",
    );
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN target_score_time_ms INTEGER '
      'CHECK (target_score_time_ms IS NULL OR target_score_time_ms > 0)',
    );
    await customStatement(
      'ALTER TABLE workout_template_exercise_overrides ADD COLUMN '
      'target_score_time_ms_override INTEGER CHECK '
      '(target_score_time_ms_override IS NULL OR '
      'target_score_time_ms_override > 0)',
    );
    await customStatement(
      'ALTER TABLE active_set_results ADD COLUMN actual_score_time_ms INTEGER '
      'CHECK (actual_score_time_ms IS NULL OR actual_score_time_ms >= 0)',
    );
    await customStatement(
      'ALTER TABLE active_set_results ADD COLUMN score_input_mode_snapshot '
      "TEXT NOT NULL DEFAULT 'manual' CHECK (score_input_mode_snapshot IN "
      "('manual', 'stopwatch'))",
    );
    await customStatement(
      'ALTER TABLE workout_history_set_results ADD COLUMN '
      'score_input_mode_snapshot TEXT NOT NULL DEFAULT '
      "'manual' CHECK (score_input_mode_snapshot IN ('manual', 'stopwatch'))",
    );
    await customStatement(
      'ALTER TABLE workout_history_set_results ADD COLUMN '
      'target_score_time_ms_snapshot INTEGER CHECK '
      '(target_score_time_ms_snapshot IS NULL OR '
      'target_score_time_ms_snapshot > 0)',
    );
    await customStatement(
      'ALTER TABLE workout_history_set_results ADD COLUMN '
      'actual_score_time_ms INTEGER CHECK (actual_score_time_ms IS NULL OR '
      'actual_score_time_ms >= 0)',
    );
    await migrator.createTable(activeScoreStopwatchStates);
  }

  Future<void> _migrateToSchema4() async {
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN default_target_time_seconds INTEGER '
      'CHECK (default_target_time_seconds IS NULL OR '
      'default_target_time_seconds > 0)',
    );
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN default_target_reps INTEGER '
      'CHECK (default_target_reps IS NULL OR default_target_reps > 0)',
    );
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN default_target_score REAL '
      'CHECK (default_target_score IS NULL OR default_target_score > 0)',
    );
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN default_target_score_time_ms INTEGER '
      'CHECK (default_target_score_time_ms IS NULL OR '
      'default_target_score_time_ms > 0)',
    );
  }

  Future<void> _migrateToSchema5(Migrator migrator) async {
    await migrator.createTable(exerciseImages);
    await customStatement(
      'INSERT OR IGNORE INTO exercise_images (id, created_at, updated_at, '
      'deleted_at, schema_version, sync_state, local_revision, '
      'origin_device_id, future_owner_profile_id, last_synced_at, '
      'remote_revision, exercise_id, media_asset_id, position) '
      "SELECT 'exercise-image:' || id || ':0', created_at, updated_at, "
      'deleted_at, schema_version, sync_state, local_revision, '
      'origin_device_id, future_owner_profile_id, last_synced_at, '
      'remote_revision, id, image_media_id, 0 FROM exercises '
      'WHERE image_media_id IS NOT NULL',
    );
  }

  Future<void> _migrateToSchema6() async {
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN '
      'exercise_image_media_ids_snapshot_json TEXT',
    );
  }

  Future<void> _migrateToSchema7() async {
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN icon_media_id TEXT '
      'REFERENCES media_assets(id)',
    );
  }

  Future<void> _migrateToSchema8(Migrator migrator) async {
    await migrator.createTable(exerciseSteps);
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN '
      'exercise_steps_snapshot_json TEXT',
    );
  }

  Future<void> _migrateToSchema9(Migrator migrator) async {
    await migrator.createTable(activeExerciseStepProgressStates);
    await migrator.createTable(activeExerciseStepResults);
    await migrator.createTable(workoutHistoryStepResults);
  }

  Future<void> _migrateToSchema10(Migrator migrator) async {
    await migrator.createTable(onlineAccountSessions);
  }

  Future<void> _migrateToSchema11(Migrator migrator) async {
    await migrator.createTable(syncMetadataEntries);
    await migrator.createTable(remoteResourceMappings);
  }

  Future<void> _migrateToSchema12(Migrator migrator) async {
    await migrator.createTable(shareInboxItems);
    await migrator.createTable(pendingShareActions);
  }

  Future<void> _migrateToSchema13() async {
    await customStatement('PRAGMA foreign_keys = OFF');
    await customStatement('''
CREATE TABLE exercises_new (
  id TEXT NOT NULL PRIMARY KEY,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  deleted_at INTEGER,
  schema_version INTEGER NOT NULL DEFAULT 1,
  sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')),
  local_revision INTEGER NOT NULL CHECK (local_revision >= 0),
  origin_device_id TEXT NOT NULL,
  future_owner_profile_id TEXT,
  last_synced_at INTEGER,
  remote_revision TEXT,
  name TEXT NOT NULL,
  description TEXT,
  icon_media_id TEXT REFERENCES media_assets(id),
  video_media_id TEXT REFERENCES media_assets(id),
  has_time_measure INTEGER NOT NULL CHECK (has_time_measure IN (0, 1)),
  has_reps_measure INTEGER NOT NULL CHECK (has_reps_measure IN (0, 1)),
  has_score_measure INTEGER NOT NULL CHECK (has_score_measure IN (0, 1)),
  score_input_mode TEXT NOT NULL DEFAULT 'manual',
  score_label TEXT,
  score_unit TEXT,
  default_target_time_seconds INTEGER,
  default_target_reps INTEGER,
  default_target_score REAL,
  default_target_score_time_ms INTEGER,
  archived_at INTEGER,
  CHECK (length(trim(origin_device_id)) > 0),
  CHECK (length(trim(name)) > 0),
  CHECK (has_time_measure OR has_reps_measure OR has_score_measure),
  CHECK (score_input_mode IN ('manual', 'stopwatch')),
  CHECK (has_score_measure OR score_input_mode = 'manual'),
  CHECK (score_input_mode != 'manual' OR NOT has_score_measure OR (score_label IS NOT NULL AND length(trim(score_label)) > 0 AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0)),
  CHECK (default_target_time_seconds IS NULL OR default_target_time_seconds > 0),
  CHECK (default_target_reps IS NULL OR default_target_reps > 0),
  CHECK (default_target_score IS NULL OR default_target_score >= 0),
  CHECK (default_target_score_time_ms IS NULL OR default_target_score_time_ms > 0),
  CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0),
  CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0)
)
''');
    await customStatement('''
INSERT INTO exercises_new (
  id,
  created_at,
  updated_at,
  deleted_at,
  schema_version,
  sync_state,
  local_revision,
  origin_device_id,
  future_owner_profile_id,
  last_synced_at,
  remote_revision,
  name,
  description,
  icon_media_id,
  video_media_id,
  has_time_measure,
  has_reps_measure,
  has_score_measure,
  score_input_mode,
  score_label,
  score_unit,
  default_target_time_seconds,
  default_target_reps,
  default_target_score,
  default_target_score_time_ms,
  archived_at
)
SELECT
  id,
  created_at,
  updated_at,
  deleted_at,
  schema_version,
  sync_state,
  local_revision,
  origin_device_id,
  future_owner_profile_id,
  last_synced_at,
  remote_revision,
  name,
  description,
  icon_media_id,
  video_media_id,
  has_time_measure,
  has_reps_measure,
  has_score_measure,
  score_input_mode,
  score_label,
  score_unit,
  default_target_time_seconds,
  default_target_reps,
  default_target_score,
  default_target_score_time_ms,
  archived_at
FROM exercises
''');
    await customStatement('DROP TABLE exercises');
    await customStatement('ALTER TABLE exercises_new RENAME TO exercises');
    await customStatement('PRAGMA foreign_keys = ON');
  }

  Future<void> _migrateToSchema14() async {
    await customStatement(
      'ALTER TABLE exercises ADD COLUMN auto_start_next_timed_step '
      'INTEGER NOT NULL DEFAULT 1 CHECK (auto_start_next_timed_step IN (0, 1))',
    );
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN '
      'auto_start_next_timed_step_snapshot INTEGER NOT NULL DEFAULT 1 '
      'CHECK (auto_start_next_timed_step_snapshot IN (0, 1))',
    );
    await customStatement(
      'ALTER TABLE program_exercises ADD COLUMN '
      'auto_start_next_timed_step_override INTEGER '
      'CHECK (auto_start_next_timed_step_override IN (0, 1))',
    );
    await customStatement(
      'ALTER TABLE workout_template_exercise_overrides ADD COLUMN '
      'auto_start_next_timed_step_override INTEGER '
      'CHECK (auto_start_next_timed_step_override IN (0, 1))',
    );
  }

  Future<void> _migrateToSchema15() async {
    await customStatement('PRAGMA foreign_keys = OFF');
    await customStatement('''
CREATE TABLE active_score_stopwatch_states_new (
  id TEXT NOT NULL PRIMARY KEY,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  deleted_at INTEGER,
  schema_version INTEGER NOT NULL DEFAULT 1,
  sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')),
  local_revision INTEGER NOT NULL CHECK (local_revision >= 0),
  origin_device_id TEXT NOT NULL,
  future_owner_profile_id TEXT,
  last_synced_at INTEGER,
  remote_revision TEXT,
  active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id),
  program_index INTEGER NOT NULL,
  exercise_index INTEGER NOT NULL,
  set_index INTEGER NOT NULL,
  status TEXT NOT NULL,
  started_at INTEGER NOT NULL,
  accumulated_ms INTEGER NOT NULL,
  stopped_at INTEGER,
  UNIQUE (active_workout_session_id, program_index, exercise_index, set_index),
  CHECK (length(trim(origin_device_id)) > 0),
  CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0),
  CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0),
  CHECK (program_index >= 0),
  CHECK (exercise_index >= 0),
  CHECK (set_index >= 0),
  CHECK (status IN ('running', 'paused', 'stopped')),
  CHECK (accumulated_ms >= 0)
)
''');
    await customStatement('''
INSERT INTO active_score_stopwatch_states_new (
  id,
  created_at,
  updated_at,
  deleted_at,
  schema_version,
  sync_state,
  local_revision,
  origin_device_id,
  future_owner_profile_id,
  last_synced_at,
  remote_revision,
  active_workout_session_id,
  program_index,
  exercise_index,
  set_index,
  status,
  started_at,
  accumulated_ms,
  stopped_at
)
SELECT
  id,
  created_at,
  updated_at,
  deleted_at,
  schema_version,
  sync_state,
  local_revision,
  origin_device_id,
  future_owner_profile_id,
  last_synced_at,
  remote_revision,
  active_workout_session_id,
  program_index,
  exercise_index,
  set_index,
  status,
  started_at,
  accumulated_ms,
  stopped_at
FROM active_score_stopwatch_states
''');
    await customStatement('DROP TABLE active_score_stopwatch_states');
    await customStatement(
      'ALTER TABLE active_score_stopwatch_states_new '
      'RENAME TO active_score_stopwatch_states',
    );
    await customStatement('PRAGMA foreign_keys = ON');
    await customStatement('''
CREATE TABLE IF NOT EXISTS active_set_timer_states (
  id TEXT NOT NULL PRIMARY KEY,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  deleted_at INTEGER,
  schema_version INTEGER NOT NULL DEFAULT 1,
  sync_state TEXT NOT NULL CHECK (sync_state IN ('localOnly', 'dirty', 'synced', 'deleted')),
  local_revision INTEGER NOT NULL CHECK (local_revision >= 0),
  origin_device_id TEXT NOT NULL,
  future_owner_profile_id TEXT,
  last_synced_at INTEGER,
  remote_revision TEXT,
  active_workout_session_id TEXT NOT NULL REFERENCES active_workout_sessions(id),
  program_index INTEGER NOT NULL,
  exercise_index INTEGER NOT NULL,
  set_index INTEGER NOT NULL,
  status TEXT NOT NULL,
  started_at INTEGER,
  accumulated_ms INTEGER NOT NULL,
  stopped_at INTEGER,
  skipped_at INTEGER,
  UNIQUE (active_workout_session_id, program_index, exercise_index, set_index),
  CHECK (length(trim(origin_device_id)) > 0),
  CHECK (future_owner_profile_id IS NULL OR length(trim(future_owner_profile_id)) > 0),
  CHECK (remote_revision IS NULL OR length(trim(remote_revision)) > 0),
  CHECK (program_index >= 0),
  CHECK (exercise_index >= 0),
  CHECK (set_index >= 0),
  CHECK (status IN ('running', 'paused', 'stopped', 'skipped')),
  CHECK (status != 'running' OR started_at IS NOT NULL),
  CHECK (accumulated_ms >= 0),
  CHECK (stopped_at IS NULL OR skipped_at IS NULL)
)
''');
    if (!await _hasColumn('active_rest_states', 'paused_at')) {
      await customStatement(
        'ALTER TABLE active_rest_states ADD COLUMN paused_at INTEGER',
      );
    }
    if (!await _hasColumn('active_rest_states', 'accumulated_paused_ms')) {
      await customStatement(
        'ALTER TABLE active_rest_states ADD COLUMN accumulated_paused_ms '
        'INTEGER NOT NULL DEFAULT 0 CHECK (accumulated_paused_ms >= 0)',
      );
    }
  }

  Future<void> _migrateToSchema16(Migrator migrator) async {
    await _addColumnIfMissing(
      tableName: 'exercises',
      columnName: 'category',
      definition:
          "category TEXT NOT NULL DEFAULT 'uncategorized' CHECK "
          "(category IN ('shoot', 'freeThrows', 'dribble', 'finishing', "
          "'conditioning', 'defense', 'mobility', 'uncategorized'))",
    );
    await _addColumnIfMissing(
      tableName: 'exercises',
      columnName: 'is_example',
      definition:
          'is_example INTEGER NOT NULL DEFAULT 0 '
          'CHECK (is_example IN (0, 1))',
    );
    await _addColumnIfMissing(
      tableName: 'programs',
      columnName: 'is_example',
      definition:
          'is_example INTEGER NOT NULL DEFAULT 0 '
          'CHECK (is_example IN (0, 1))',
    );
    await _addColumnIfMissing(
      tableName: 'workout_templates',
      columnName: 'is_example',
      definition:
          'is_example INTEGER NOT NULL DEFAULT 0 '
          'CHECK (is_example IN (0, 1))',
    );
    await customStatement(
      'CREATE TABLE IF NOT EXISTS local_seed_metadata ('
      'key TEXT NOT NULL PRIMARY KEY, '
      'version INTEGER NOT NULL CHECK (version >= 0), '
      'applied_at INTEGER NOT NULL'
      ')',
    );
  }

  Future<void> _migrateToSchema17() async {
    await _addColumnIfMissing(
      tableName: 'workout_history_set_results',
      columnName: 'source_exercise_id_snapshot',
      definition: 'source_exercise_id_snapshot TEXT',
    );
    await _addColumnIfMissing(
      tableName: 'workout_history_step_results',
      columnName: 'source_exercise_id_snapshot',
      definition: 'source_exercise_id_snapshot TEXT',
    );
    await _backfillWorkoutHistorySetSourceExerciseIds();
    await _backfillWorkoutHistoryStepSourceExerciseIds();
  }

  Future<void> _migrateToSchema19() async {
    await _addColumnIfMissing(
      tableName: 'exercises',
      columnName: 'tags_json',
      definition:
          "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))",
    );
    await _addColumnIfMissing(
      tableName: 'programs',
      columnName: 'tags_json',
      definition:
          "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))",
    );
    await _addColumnIfMissing(
      tableName: 'workout_templates',
      columnName: 'tags_json',
      definition:
          "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))",
    );
  }

  Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
    await customStatement(r'''
UPDATE workout_history_set_results AS result
SET source_exercise_id_snapshot = (
  SELECT json_extract(exercise.value, '$.sourceExerciseId')
  FROM workout_history AS history,
       json_each(
         COALESCE(
           json_extract(
             history.history_snapshot_json,
             '$.resolvedTemplateSnapshotJson'
           ),
           history.history_snapshot_json
         ),
         '$.programs'
       ) AS program,
       json_each(
         json_extract(program.value, '$.programSnapshotJson'),
         '$.exercises'
       ) AS exercise
  WHERE history.id = result.workout_history_id
    AND json_extract(program.value, '$.id') = result.program_snapshot_id
    AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id
  LIMIT 1
)
WHERE result.source_exercise_id_snapshot IS NULL
''');
  }

  Future<void> _backfillWorkoutHistoryStepSourceExerciseIds() async {
    await customStatement(r'''
UPDATE workout_history_step_results AS result
SET source_exercise_id_snapshot = (
  SELECT json_extract(exercise.value, '$.sourceExerciseId')
  FROM workout_history AS history,
       json_each(
         COALESCE(
           json_extract(
             history.history_snapshot_json,
             '$.resolvedTemplateSnapshotJson'
           ),
           history.history_snapshot_json
         ),
         '$.programs'
       ) AS program,
       json_each(
         json_extract(program.value, '$.programSnapshotJson'),
         '$.exercises'
       ) AS exercise
  WHERE history.id = result.workout_history_id
    AND json_extract(program.value, '$.id') = result.program_snapshot_id
    AND json_extract(exercise.value, '$.id') = result.exercise_snapshot_id
  LIMIT 1
)
WHERE result.source_exercise_id_snapshot IS NULL
''');
  }

  Future<void> _addColumnIfMissing({
    required String tableName,
    required String columnName,
    required String definition,
  }) async {
    final columns = await customSelect('PRAGMA table_info($tableName)').get();
    final exists = columns.any((row) => row.data['name'] == columnName);
    if (!exists) {
      await customStatement('ALTER TABLE $tableName ADD COLUMN $definition');
    }
  }

  Future<bool> _hasColumn(String tableName, String columnName) async {
    final rows = await customSelect('PRAGMA table_info($tableName)').get();
    return rows.any((row) => row.data['name'] == columnName);
  }
}
