const fs = require('fs');
const path = require('path');
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
const { dbPath } = require('../config');
const logger = require('../services/logger').child('DB');
const { errorToMeta } = require('../utils/errorMeta');
const { setLogRootDir, getJobLogDir } = require('../services/logPathService');

const schemaFilePath = path.resolve(__dirname, '../../../db/schema.sql');
const LEGACY_PROFILE_SETTING_MIGRATIONS = [
  {
    legacyKey: 'mediainfo_extra_args',
    profileKeys: ['mediainfo_extra_args_bluray', 'mediainfo_extra_args_dvd']
  },
  {
    legacyKey: 'makemkv_rip_mode',
    profileKeys: ['makemkv_rip_mode_bluray', 'makemkv_rip_mode_dvd']
  },
  {
    legacyKey: 'makemkv_analyze_extra_args',
    profileKeys: ['makemkv_analyze_extra_args_bluray', 'makemkv_analyze_extra_args_dvd']
  },
  {
    legacyKey: 'makemkv_rip_extra_args',
    profileKeys: ['makemkv_rip_extra_args_bluray', 'makemkv_rip_extra_args_dvd']
  },
  {
    legacyKey: 'handbrake_preset',
    profileKeys: ['handbrake_preset_bluray', 'handbrake_preset_dvd']
  },
  {
    legacyKey: 'handbrake_extra_args',
    profileKeys: ['handbrake_extra_args_bluray', 'handbrake_extra_args_dvd']
  },
  {
    legacyKey: 'output_extension',
    profileKeys: ['output_extension_bluray', 'output_extension_dvd']
  },
  {
    legacyKey: 'output_template',
    profileKeys: ['output_template_bluray', 'output_template_dvd']
  }
];
const INSTALL_PATH_SETTING_DEFAULTS = [
  {
    key: 'log_dir',
    pathParts: ['logs'],
    legacyDefaults: ['data/logs', './data/logs']
  }
];

let dbInstance;

function nowFileStamp() {
  return new Date().toISOString().replace(/[:.]/g, '-');
}

function isSqliteCorruptionError(error) {
  if (!error) {
    return false;
  }

  const code = String(error.code || '').toUpperCase();
  const msg = String(error.message || '').toLowerCase();

  return (
    code === 'SQLITE_CORRUPT' ||
    msg.includes('database disk image is malformed') ||
    msg.includes('file is not a database')
  );
}

function moveIfExists(sourcePath, targetPath) {
  if (!fs.existsSync(sourcePath)) {
    return false;
  }
  fs.renameSync(sourcePath, targetPath);
  return true;
}

function quarantineCorruptDatabaseFiles() {
  const dir = path.dirname(dbPath);
  const base = path.basename(dbPath);
  const stamp = nowFileStamp();
  const archiveDir = path.join(dir, 'corrupt-backups');

  fs.mkdirSync(archiveDir, { recursive: true });

  const moved = [];
  const candidates = [
    dbPath,
    `${dbPath}-wal`,
    `${dbPath}-shm`
  ];

  for (const sourcePath of candidates) {
    const fileName = path.basename(sourcePath);
    const targetPath = path.join(archiveDir, `${fileName}.${stamp}.corrupt`);
    if (moveIfExists(sourcePath, targetPath)) {
      moved.push({
        from: sourcePath,
        to: targetPath
      });
    }
  }

  logger.warn('recovery:quarantine-complete', {
    dbPath,
    base,
    movedCount: moved.length,
    moved
  });
}

function quoteIdentifier(identifier) {
  return `"${String(identifier || '').replace(/"/g, '""')}"`;
}

function normalizeSqlType(value) {
  return String(value || '').trim().replace(/\s+/g, ' ').toUpperCase();
}

function normalizeDefault(value) {
  if (value === null || value === undefined) {
    return '';
  }
  return String(value).trim().replace(/\s+/g, ' ').toUpperCase();
}

function sameTableShape(current = [], desired = []) {
  if (current.length !== desired.length) {
    return false;
  }
  for (let i = 0; i < current.length; i += 1) {
    const left = current[i];
    const right = desired[i];
    if (!left || !right) {
      return false;
    }
    if (String(left.name || '') !== String(right.name || '')) {
      return false;
    }
    if (normalizeSqlType(left.type) !== normalizeSqlType(right.type)) {
      return false;
    }
    if (Number(left.notnull || 0) !== Number(right.notnull || 0)) {
      return false;
    }
    if (Number(left.pk || 0) !== Number(right.pk || 0)) {
      return false;
    }
    if (normalizeDefault(left.dflt_value) !== normalizeDefault(right.dflt_value)) {
      return false;
    }
  }
  return true;
}

function sameForeignKeys(current = [], desired = []) {
  if (current.length !== desired.length) {
    return false;
  }
  for (let i = 0; i < current.length; i += 1) {
    const left = current[i];
    const right = desired[i];
    if (!left || !right) {
      return false;
    }
    if (Number(left.id || 0) !== Number(right.id || 0)) {
      return false;
    }
    if (Number(left.seq || 0) !== Number(right.seq || 0)) {
      return false;
    }
    if (String(left.table || '') !== String(right.table || '')) {
      return false;
    }
    if (String(left.from || '') !== String(right.from || '')) {
      return false;
    }
    if (String(left.to || '') !== String(right.to || '')) {
      return false;
    }
    if (String(left.on_update || '') !== String(right.on_update || '')) {
      return false;
    }
    if (String(left.on_delete || '') !== String(right.on_delete || '')) {
      return false;
    }
    if (String(left.match || '') !== String(right.match || '')) {
      return false;
    }
  }
  return true;
}

async function tableExists(db, tableName) {
  const row = await db.get(
    `SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`,
    [tableName]
  );
  return Boolean(row);
}

async function getTableInfo(db, tableName) {
  return db.all(`PRAGMA table_info(${quoteIdentifier(tableName)})`);
}

async function getForeignKeyInfo(db, tableName) {
  return db.all(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`);
}

async function readConfiguredLogDirSetting(db) {
  const hasSchemaTable = await tableExists(db, 'settings_schema');
  const hasValuesTable = await tableExists(db, 'settings_values');
  if (!hasSchemaTable || !hasValuesTable) {
    return null;
  }

  try {
    const row = await db.get(
      `
        SELECT
          COALESCE(v.value, s.default_value, '') AS value
        FROM settings_schema s
        LEFT JOIN settings_values v ON v.key = s.key
        WHERE s.key = ?
        LIMIT 1
      `,
      ['log_dir']
    );
    const value = String(row?.value || '').trim();
    return value || null;
  } catch (error) {
    logger.warn('log-root:read-setting-failed', {
      error: error?.message || String(error)
    });
    return null;
  }
}

async function configureRuntimeLogRootFromSettings(db, options = {}) {
  const ensure = Boolean(options.ensure);
  const configured = await readConfiguredLogDirSetting(db);
  let resolved = setLogRootDir(configured);
  if (ensure) {
    try {
      fs.mkdirSync(resolved, { recursive: true });
    } catch (error) {
      const fallbackResolved = setLogRootDir(null);
      try {
        fs.mkdirSync(fallbackResolved, { recursive: true });
      } catch (_fallbackError) {
        // ignored: logger itself is hardened and may still write to console only
      }
      logger.warn('log-root:ensure-failed', {
        configured: configured || null,
        resolved,
        fallbackResolved,
        error: error?.message || String(error)
      });
      resolved = fallbackResolved;
    }
  }
  return {
    configured,
    resolved
  };
}

async function loadSchemaModel() {
  if (!fs.existsSync(schemaFilePath)) {
    const error = new Error(`Schema-Datei fehlt: ${schemaFilePath}`);
    error.code = 'SCHEMA_FILE_MISSING';
    throw error;
  }

  const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
  const memDb = await open({
    filename: ':memory:',
    driver: sqlite3.Database
  });

  try {
    await memDb.exec(schemaSql);
    const tables = await memDb.all(`
      SELECT name, sql
      FROM sqlite_master
      WHERE type = 'table'
        AND name NOT LIKE 'sqlite_%'
      ORDER BY rowid ASC
    `);
    const indexes = await memDb.all(`
      SELECT name, tbl_name AS tableName, sql
      FROM sqlite_master
      WHERE type = 'index'
        AND name NOT LIKE 'sqlite_%'
        AND sql IS NOT NULL
      ORDER BY rowid ASC
    `);
    const tableInfos = {};
    const tableForeignKeys = {};
    for (const table of tables) {
      tableInfos[table.name] = await getTableInfo(memDb, table.name);
      tableForeignKeys[table.name] = await getForeignKeyInfo(memDb, table.name);
    }

    return {
      schemaSql,
      tables,
      indexes,
      tableInfos,
      tableForeignKeys
    };
  } finally {
    await memDb.close();
  }
}

async function rebuildTable(db, tableName, createSql) {
  const oldName = `${tableName}__old_${Date.now()}`;
  const tableNameQuoted = quoteIdentifier(tableName);
  const oldNameQuoted = quoteIdentifier(oldName);
  const beforeInfo = await getTableInfo(db, tableName);

  await db.exec(`ALTER TABLE ${tableNameQuoted} RENAME TO ${oldNameQuoted}`);
  await db.exec(createSql);

  const afterInfo = await getTableInfo(db, tableName);
  const beforeColumns = new Set(beforeInfo.map((column) => String(column.name)));
  const commonColumns = afterInfo
    .map((column) => String(column.name))
    .filter((name) => beforeColumns.has(name));

  if (commonColumns.length > 0) {
    const columnList = commonColumns.map((name) => quoteIdentifier(name)).join(', ');
    await db.exec(`
      INSERT INTO ${tableNameQuoted} (${columnList})
      SELECT ${columnList}
      FROM ${oldNameQuoted}
    `);
  }

  await db.exec(`DROP TABLE ${oldNameQuoted}`);
}

async function syncSchemaToModel(db, model) {
  const desiredTables = Array.isArray(model?.tables) ? model.tables : [];
  const desiredIndexes = Array.isArray(model?.indexes) ? model.indexes : [];
  const desiredTableInfo = model?.tableInfos && typeof model.tableInfos === 'object'
    ? model.tableInfos
    : {};
  const desiredTableForeignKeys = model?.tableForeignKeys && typeof model.tableForeignKeys === 'object'
    ? model.tableForeignKeys
    : {};

  const currentTables = await db.all(`
    SELECT name, sql
    FROM sqlite_master
    WHERE type = 'table'
      AND name NOT LIKE 'sqlite_%'
    ORDER BY rowid ASC
  `);
  const currentByName = new Map(currentTables.map((table) => [table.name, table]));
  const desiredTableNameSet = new Set(desiredTables.map((table) => table.name));

  for (const table of desiredTables) {
    const tableName = String(table.name || '');
    const createSql = String(table.sql || '').trim();
    if (!tableName || !createSql) {
      continue;
    }

    if (!currentByName.has(tableName)) {
      await db.exec(createSql);
      logger.info('schema:create-table', { table: tableName });
      continue;
    }

    const currentInfo = await getTableInfo(db, tableName);
    const wantedInfo = Array.isArray(desiredTableInfo[tableName]) ? desiredTableInfo[tableName] : [];
    const currentFks = await getForeignKeyInfo(db, tableName);
    const wantedFks = Array.isArray(desiredTableForeignKeys[tableName]) ? desiredTableForeignKeys[tableName] : [];
    const shapeMatches = sameTableShape(currentInfo, wantedInfo);
    const foreignKeysMatch = sameForeignKeys(currentFks, wantedFks);
    if (!shapeMatches || !foreignKeysMatch) {
      await rebuildTable(db, tableName, createSql);
      logger.warn('schema:rebuild-table', {
        table: tableName,
        reason: !shapeMatches ? 'shape-mismatch' : 'foreign-key-mismatch'
      });
    }
  }

  for (const table of currentTables) {
    if (desiredTableNameSet.has(table.name)) {
      continue;
    }
    await db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(table.name)}`);
    logger.warn('schema:drop-table', { table: table.name });
  }

  const currentIndexes = await db.all(`
    SELECT name, tbl_name AS tableName, sql
    FROM sqlite_master
    WHERE type = 'index'
      AND name NOT LIKE 'sqlite_%'
      AND sql IS NOT NULL
    ORDER BY rowid ASC
  `);
  const desiredIndexNameSet = new Set(desiredIndexes.map((index) => index.name));

  for (const index of currentIndexes) {
    if (desiredIndexNameSet.has(index.name)) {
      continue;
    }
    await db.exec(`DROP INDEX IF EXISTS ${quoteIdentifier(index.name)}`);
    logger.warn('schema:drop-index', { index: index.name, table: index.tableName });
  }

  for (const index of desiredIndexes) {
    let sql = String(index.sql || '').trim();
    if (!sql) {
      continue;
    }
    if (/^CREATE\s+UNIQUE\s+INDEX\s+/i.test(sql)) {
      sql = sql.replace(/^CREATE\s+UNIQUE\s+INDEX\s+/i, 'CREATE UNIQUE INDEX IF NOT EXISTS ');
    } else if (/^CREATE\s+INDEX\s+/i.test(sql)) {
      sql = sql.replace(/^CREATE\s+INDEX\s+/i, 'CREATE INDEX IF NOT EXISTS ');
    }
    await db.exec(sql);
  }
}

async function exportLegacyJobLogsToFiles(db) {
  const hasJobLogsTable = await tableExists(db, 'job_logs');
  if (!hasJobLogsTable) {
    return;
  }

  const rows = await db.all(`
    SELECT job_id, source, message, timestamp
    FROM job_logs
    ORDER BY job_id ASC, id ASC
  `);
  if (!Array.isArray(rows) || rows.length === 0) {
    logger.info('legacy-job-logs:export:skip-empty');
    return;
  }

  const targetDir = getJobLogDir();
  fs.mkdirSync(targetDir, { recursive: true });
  const streams = new Map();

  try {
    for (const row of rows) {
      const jobId = Number(row?.job_id);
      if (!Number.isFinite(jobId) || jobId <= 0) {
        continue;
      }
      const key = String(Math.trunc(jobId));
      if (!streams.has(key)) {
        const filePath = path.join(targetDir, `job-${key}.process.log`);
        const stream = fs.createWriteStream(filePath, {
          flags: 'w',
          encoding: 'utf-8'
        });
        streams.set(key, stream);
      }
      const line = `[${String(row?.timestamp || '')}] [${String(row?.source || 'SYSTEM')}] ${String(row?.message || '')}\n`;
      streams.get(key).write(line);
    }
  } finally {
    await Promise.all(
      [...streams.values()].map(
        (stream) =>
          new Promise((resolve) => {
            stream.end(resolve);
          })
      )
    );
  }

  logger.warn('legacy-job-logs:exported', {
    lines: rows.length,
    jobs: streams.size,
    targetDir
  });
}

async function applySchemaModel(db, model) {
  await db.exec('PRAGMA foreign_keys = OFF;');
  await db.exec('BEGIN');
  try {
    await syncSchemaToModel(db, model);
    await db.exec('COMMIT');
  } catch (error) {
    await db.exec('ROLLBACK');
    throw error;
  } finally {
    await db.exec('PRAGMA foreign_keys = ON;');
  }
}

async function openAndPrepareDatabase() {
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  logger.info('init:open', { dbPath });

  dbInstance = await open({
    filename: dbPath,
    driver: sqlite3.Database
  });

  await dbInstance.exec('PRAGMA journal_mode = WAL;');
  await dbInstance.exec('PRAGMA foreign_keys = ON;');
  const initialLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
  logger.info('log-root:initialized', {
    configured: initialLogRoot.configured || null,
    resolved: initialLogRoot.resolved
  });
  await exportLegacyJobLogsToFiles(dbInstance);
  const schemaModel = await loadSchemaModel();
  await applySchemaModel(dbInstance, schemaModel);

  await seedFromSchemaFile(dbInstance);
  await syncInstallPathSettingDefaults(dbInstance);
  await migrateLegacyProfiledToolSettings(dbInstance);
  await migrateOutputTemplates(dbInstance);
  await removeDeprecatedSettings(dbInstance);
  await migrateSettingsSchemaMetadata(dbInstance);
  await ensurePipelineStateRow(dbInstance);
  await backfillJobOutputFolders(dbInstance);
  const syncedLogRoot = await configureRuntimeLogRootFromSettings(dbInstance, { ensure: true });
  logger.info('log-root:synced', {
    configured: syncedLogRoot.configured || null,
    resolved: syncedLogRoot.resolved
  });
  logger.info('init:done');
  return dbInstance;
}

async function initDatabase({ allowRecovery = true } = {}) {
  if (dbInstance) {
    return dbInstance;
  }

  try {
    return await openAndPrepareDatabase();
  } catch (error) {
    logger.error('init:failed', { error: errorToMeta(error), allowRecovery });

    if (dbInstance) {
      try {
        await dbInstance.close();
      } catch (_closeError) {
        // ignore close errors during failed init
      }
      dbInstance = undefined;
    }

    if (allowRecovery && isSqliteCorruptionError(error)) {
      logger.warn('recovery:corrupt-db-detected', { dbPath });
      quarantineCorruptDatabaseFiles();
      return initDatabase({ allowRecovery: false });
    }

    throw error;
  }

}

async function seedFromSchemaFile(db) {
  const schemaSql = fs.readFileSync(schemaFilePath, 'utf-8');
  // Kommentarzeilen vor dem Split entfernen, damit der erste INSERT-Block nicht
  // mit vorangehenden Kommentaren in einem Chunk landet und durch den
  // /^INSERT\b/-Filter herausfällt.
  const strippedSql = schemaSql.replace(/^--[^\n]*$/gm, '');
  const statements = strippedSql
    .split(/;\s*\n/)
    .map((s) => s.trim())
    .filter((s) => /^INSERT\b/i.test(s));
  for (const stmt of statements) {
    await db.run(stmt);
  }
  logger.info('seed:settings', { count: statements.length });
}

async function syncInstallPathSettingDefaults(db) {
  const dataDir = path.dirname(dbPath);
  const updates = INSTALL_PATH_SETTING_DEFAULTS.map((item) => ({
    key: item.key,
    value: path.join(dataDir, ...item.pathParts),
    legacyDefaults: Array.isArray(item.legacyDefaults) ? item.legacyDefaults : []
  }));

  await db.exec('BEGIN');
  try {
    for (const update of updates) {
      const placeholders = update.legacyDefaults.map(() => '?').join(', ');

      await db.run(
        `
          UPDATE settings_schema
          SET default_value = ?, updated_at = CURRENT_TIMESTAMP
          WHERE key = ?
            AND (
              default_value IS NULL
              OR TRIM(default_value) = ''
              OR default_value IN (${placeholders})
            )
        `,
        [update.value, update.key, ...update.legacyDefaults]
      );

      await db.run(
        `
          INSERT INTO settings_values (key, value, updated_at)
          VALUES (?, ?, CURRENT_TIMESTAMP)
          ON CONFLICT(key) DO NOTHING
        `,
        [update.key, update.value]
      );

      await db.run(
        `
          UPDATE settings_values
          SET value = ?, updated_at = CURRENT_TIMESTAMP
          WHERE key = ?
            AND (
              value IS NULL
              OR TRIM(value) = ''
              OR value IN (${placeholders})
            )
        `,
        [update.value, update.key, ...update.legacyDefaults]
      );
    }
    await db.exec('COMMIT');
  } catch (error) {
    await db.exec('ROLLBACK');
    throw error;
  }

  logger.info('seed:path-defaults-synced', {
    dataDir,
    settings: updates.map((item) => ({ key: item.key, value: item.value }))
  });
}

async function readCurrentOrDefaultSettingValue(db, key) {
  if (!key) {
    return null;
  }
  return db.get(
    `
      SELECT
        s.default_value AS defaultValue,
        v.value AS currentValue,
        COALESCE(v.value, s.default_value) AS effectiveValue
      FROM settings_schema s
      LEFT JOIN settings_values v ON v.key = s.key
      WHERE s.key = ?
      LIMIT 1
    `,
    [key]
  );
}

async function migrateLegacyProfiledToolSettings(db) {
  let copiedCount = 0;
  for (const migration of LEGACY_PROFILE_SETTING_MIGRATIONS) {
    const legacyRow = await readCurrentOrDefaultSettingValue(db, migration.legacyKey);
    if (!legacyRow) {
      continue;
    }

    for (const targetKey of migration.profileKeys || []) {
      const targetRow = await readCurrentOrDefaultSettingValue(db, targetKey);
      if (!targetRow) {
        continue;
      }

      const currentValue = targetRow.currentValue;
      const defaultValue = targetRow.defaultValue;
      const shouldCopy = (
        currentValue === null
        || currentValue === undefined
        || String(currentValue) === String(defaultValue ?? '')
      );
      if (!shouldCopy) {
        continue;
      }

      await db.run(
        `
          INSERT INTO settings_values (key, value, updated_at)
          VALUES (?, ?, CURRENT_TIMESTAMP)
          ON CONFLICT(key) DO UPDATE SET
            value = excluded.value,
            updated_at = CURRENT_TIMESTAMP
        `,
        [targetKey, legacyRow.effectiveValue ?? null]
      );
      copiedCount += 1;
      logger.info('migrate:legacy-tool-setting-copied', {
        from: migration.legacyKey,
        to: targetKey
      });
    }
  }
  if (copiedCount > 0) {
    logger.info('migrate:legacy-tool-settings:done', { copiedCount });
  }
}

async function ensurePipelineStateRow(db) {
  await db.run(
    `
      INSERT INTO pipeline_state (id, state, active_job_id, progress, eta, status_text, context_json)
      VALUES (1, 'IDLE', NULL, 0, NULL, NULL, '{}')
      ON CONFLICT(id) DO NOTHING
    `
  );
}

async function migrateOutputTemplates(db) {
  // Combine legacy filename_template_X + output_folder_template_X into output_template_X.
  // Only sets the new key if it has no user value yet (preserves any existing value).
  // The last "/" in the combined template separates folder from filename.
  for (const profile of ['bluray', 'dvd']) {
    const newKey = `output_template_${profile}`;
    const filenameKey = `filename_template_${profile}`;
    const folderKey = `output_folder_template_${profile}`;

    const existing = await db.get(
      `SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
      [newKey]
    );
    if (existing) {
      continue; // already set, don't overwrite
    }

    const filenameRow = await db.get(
      `SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
      [filenameKey]
    );
    const folderRow = await db.get(
      `SELECT sv.value FROM settings_values sv WHERE sv.key = ? AND sv.value IS NOT NULL`,
      [folderKey]
    );

    const filenameVal = filenameRow ? String(filenameRow.value || '').trim() : '';
    const folderVal = folderRow ? String(folderRow.value || '').trim() : '';

    if (!filenameVal) {
      continue; // nothing to migrate
    }

    const combined = folderVal ? `${folderVal}/${filenameVal}` : `${filenameVal}/${filenameVal}`;
    await db.run(
      `INSERT INTO settings_values (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
       ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`,
      [newKey, combined]
    );
    logger.info('migrate:output-template-combined', { profile, combined });
  }
}

async function removeDeprecatedSettings(db) {
  const deprecatedKeys = [
    'pushover_notify_disc_detected',
    'mediainfo_extra_args',
    'makemkv_rip_mode',
    'makemkv_analyze_extra_args',
    'makemkv_rip_extra_args',
    'handbrake_preset',
    'handbrake_extra_args',
    'output_extension',
    'filename_template',
    'output_folder_template',
    'makemkv_backup_mode',
    'raw_dir',
    'movie_dir',
    'raw_dir_other',
    'raw_dir_other_owner',
    'movie_dir_other',
    'movie_dir_other_owner',
    'filename_template_bluray',
    'filename_template_dvd',
    'output_folder_template_bluray',
    'output_folder_template_dvd',
    'output_extension_audiobook',
    'use_plugin_architecture',
    'use_plugin_architecture_bluray',
    'use_plugin_architecture_bluray_analyze',
    'use_plugin_architecture_bluray_rip',
    'use_plugin_architecture_bluray_review',
    'use_plugin_architecture_bluray_encode',
    'use_plugin_architecture_dvd',
    'use_plugin_architecture_dvd_analyze',
    'use_plugin_architecture_dvd_rip',
    'use_plugin_architecture_dvd_review',
    'use_plugin_architecture_dvd_encode',
    'use_plugin_architecture_cd',
    'use_plugin_architecture_cd_analyze',
    'use_plugin_architecture_cd_rip',
    'use_plugin_architecture_audiobook',
    'use_plugin_architecture_audiobook_analyze',
    'use_plugin_architecture_audiobook_encode'
  ];
  for (const key of deprecatedKeys) {
    const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
    const valuesResult = await db.run('DELETE FROM settings_values WHERE key = ?', [key]);
    if (schemaResult?.changes > 0 || valuesResult?.changes > 0) {
      logger.info('migrate:remove-deprecated-setting', { key });
    }
  }

  // Reset raw_dir_cd if it still holds the old hardcoded absolute path from a prior install
  await db.run(
    `UPDATE settings_values SET value = NULL, updated_at = CURRENT_TIMESTAMP
     WHERE key = 'raw_dir_cd' AND value = '/opt/ripster/backend/data/output/cd'`
  );
}

// Aktualisiert settings_schema-Metadaten (required, description, validation_json)
// für bestehende Einträge, ohne user-konfigurierte Werte in settings_values anzutasten.
const SETTINGS_SCHEMA_METADATA_UPDATES = [
  {
    key: 'handbrake_preset_bluray',
    required: 0,
    description: 'Preset Name für -Z (Blu-ray). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
    validation_json: '{}'
  },
  {
    key: 'handbrake_preset_dvd',
    required: 0,
    description: 'Preset Name für -Z (DVD). Leer = kein Preset, nur CLI-Parameter werden verwendet.',
    validation_json: '{}'
  },
  {
    key: 'output_template_dvd_series_episode',
    required: 1,
    description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
    validation_json: '{"minLength":1}'
  },
  {
    key: 'output_template_dvd_series_multi_episode',
    required: 1,
    description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
    validation_json: '{"minLength":1}'
  }
];

// Settings, die von einer Kategorie in eine andere verschoben werden
const SETTINGS_CATEGORY_MOVES = [
  { key: 'cd_output_template', category: 'Pfade' },
  { key: 'output_template_bluray', category: 'Pfade' },
  { key: 'output_template_dvd', category: 'Pfade' },
  { key: 'output_template_audiobook', category: 'Pfade' },
  { key: 'output_chapter_template_audiobook', category: 'Pfade' },
  { key: 'audiobook_raw_template', category: 'Pfade' },
  { key: 'converter_raw_dir', category: 'Pfade' },
  { key: 'converter_movie_dir', category: 'Pfade' },
  { key: 'converter_audio_dir', category: 'Pfade' },
  { key: 'converter_output_template_video', category: 'Pfade' },
  { key: 'converter_output_template_audio', category: 'Pfade' }
];

async function migrateSettingsSchemaMetadata(db) {
  for (const update of SETTINGS_SCHEMA_METADATA_UPDATES) {
    const result = await db.run(
      `UPDATE settings_schema
       SET required = ?, description = ?, validation_json = ?, updated_at = CURRENT_TIMESTAMP
       WHERE key = ? AND (required != ? OR description != ? OR validation_json != ?)`,
      [
        update.required, update.description, update.validation_json,
        update.key,
        update.required, update.description, update.validation_json
      ]
    );
    if (result?.changes > 0) {
      logger.info('migrate:settings-schema-metadata', { key: update.key });
    }
  }
  for (const move of SETTINGS_CATEGORY_MOVES) {
    const result = await db.run(
      `UPDATE settings_schema SET category = ?, updated_at = CURRENT_TIMESTAMP
       WHERE key = ? AND category != ?`,
      [move.category, move.key, move.category]
    );
    if (result?.changes > 0) {
      logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
    }
  }

  const rawDirCdLabel = 'CD RAW-Ordner';
  const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
  const rawDirCdResult = await db.run(
    `UPDATE settings_schema
     SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
     WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
    [rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
  );
  if (rawDirCdResult?.changes > 0) {
    logger.info('migrate:settings-schema-cd-raw-updated', {
      key: 'raw_dir_cd',
      label: rawDirCdLabel
    });
  }

  const externalStorageDescription = 'Wird links unter "Freie Speicher" angezeigt.';
  const externalStorageDescResult = await db.run(
    `UPDATE settings_schema
     SET description = ?, updated_at = CURRENT_TIMESTAMP
     WHERE key = 'external_storage_paths' AND description != ?`,
    [externalStorageDescription, externalStorageDescription]
  );
  if (externalStorageDescResult?.changes > 0) {
    logger.info('migrate:settings-schema-external-storage-description-updated', {
      key: 'external_storage_paths'
    });
  }

  // Migrate raw_dir_cd_owner label
  await db.run(
    `UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
     WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
  );

  // depends_on-Spalte zu settings_schema hinzufügen, bevor neue abhängige
  // Settings per INSERT OR IGNORE angelegt werden.
  {
    const cols = await db.all(`PRAGMA table_info(settings_schema)`);
    if (!cols.some((c) => c.name === 'depends_on')) {
      await db.run(`ALTER TABLE settings_schema ADD COLUMN depends_on TEXT DEFAULT NULL`);
      logger.info('migrate:settings-schema-add-depends_on');
    }
  }

  // Add movie_dir_cd if not already present
  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);

  // Add movie_dir_cd_owner if not already present
  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('ffmpeg_command', 'Tools', 'FFmpeg Kommando', 'string', 1, 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.', 'ffmpeg', '[]', '{"minLength":1}', 232)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffmpeg_command', 'ffmpeg')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('ffprobe_command', 'Tools', 'FFprobe Kommando', 'string', 1, 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.', 'ffprobe', '[]', '{"minLength":1}', 233)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);


  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('output_chapter_template_audiobook', 'Pfade', 'Kapitel Template (Audiobook)', 'string', 1, 'Template für kapitelweise Audiobook-Ausgaben (MP3/FLAC) ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}, {chapterNr}, {chapterNo}, {chapterTitle}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}', '[]', '{"minLength":1}', 7355)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_chapter_template_audiobook', '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('audiobook_raw_template', 'Pfade', 'Audiobook RAW Template', 'string', 1, 'Template für relative Audiobook-RAW-Ordner. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}.', '{author} - {title} ({year})', '[]', '{"minLength":1}', 736)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('audiobook_raw_template', '{author} - {title} ({year})')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('raw_dir_audiobook', 'Pfade', 'Audiobook RAW-Ordner', 'path', 0, 'Basisordner für hochgeladene AAX-Dateien. Leer = Standardpfad (data/output/audiobook-raw).', NULL, '[]', '{}', 105)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('raw_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook RAW-Ordner', 'string', 0, 'Eigentümer der Audiobook-RAW-Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1055)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_audiobook_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('movie_dir_audiobook', 'Pfade', 'Audiobook Output-Ordner', 'path', 0, 'Zielordner für encodierte Audiobook-Dateien. Leer = Standardpfad (data/output/audiobooks).', NULL, '[]', '{}', 115)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('raw_dir_dvd_series', 'Pfade', 'RAW-Ordner (DVD Serie)', 'path', 0, 'RAW-Zielpfad für DVD-Serien. Leer = gleicher Pfad wie RAW-Ordner (DVD).', NULL, '[]', '{}', 1021)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('raw_dir_dvd_series_owner', 'Pfade', 'Eigentümer RAW-Ordner (DVD Serie)', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe für DVD-Serien-RAW. Leer = Eigentümer Raw-Ordner (DVD).', NULL, '[]', '{}', 1022)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_dvd_series_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('series_dir_dvd', 'Pfade', 'Serien-Ordner (DVD)', 'path', 0, 'Zielordner für DVD-Serienfolgen. Leer = Standardpfad (data/output/series).', NULL, '[]', '{}', 113)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('series_dir_dvd_owner', 'Pfade', 'Eigentümer Serien-Ordner (DVD)', 'string', 0, 'Eigentümer der DVD-Serienausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1135)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('series_dir_dvd_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('dvd_series_plugin_enabled', 'Features', 'DVD Serien-Plugin aktiviert', 'boolean', 1, 'Aktiviert die additive Serienerkennung für DVDs. Wenn deaktiviert, bleibt der bestehende DVD-Film-Flow unverändert.', 'false', '[]', '{}', 145)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_plugin_enabled', 'false')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index, depends_on)
     VALUES ('dvd_series_prescan_enabled', 'Features', 'DVD HandBrake-Prescan aktiviert', 'boolean', 1, 'Führt nach der DVD-Erkennung einen zusätzlichen HandBrake-Prescan für die Serienheuristik aus.', 'true', '[]', '{}', 146, 'dvd_series_plugin_enabled')`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_prescan_enabled', 'true')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('tmdb_api_read_access_token', 'Metadaten', 'TMDb Read Access Token', 'string', 0, 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).', NULL, '[]', '{}', 401)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('tmdb_api_read_access_token', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('dvd_series_language', 'Metadaten', 'DVD Serien-Sprache', 'string', 1, 'Bevorzugte Sprache für Serien-Metadaten im TMDb-Format, z.B. de-DE oder en-US.', 'de-DE', '[]', '{"minLength":2}', 403)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_language', 'de-DE')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('dvd_series_fallback_languages', 'Metadaten', 'DVD Serien-Fallback-Sprachen', 'string', 1, 'Kommagetrennte TMDb-Sprachen für Fallback-Titel, z.B. de-DE,en-US,fr-FR.', 'de-DE,en-US', '[]', '{"minLength":2}', 404)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_fallback_languages', 'de-DE,en-US')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('dvd_series_order_preference', 'Metadaten', 'DVD Serien-Ordnung', 'select', 1, 'Bevorzugte Episodenordnung für DVD-Serien. Episode Groups nutzen TMDb Alternate Orders, falls vorhanden.', 'episode_group', '[{"label":"Episode Group","value":"episode_group"},{"label":"Aired / Season","value":"aired"}]', '{}', 405)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_order_preference', 'episode_group')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('dvd_series_reuse_disc_profiles', 'Metadaten', 'DVD Disc-Profile wiederverwenden', 'boolean', 1, 'Verwendet zuvor bestätigte Disc-Profile erneut, um Episoden automatischer zuzuordnen.', 'true', '[]', '{}', 406)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('dvd_series_reuse_disc_profiles', 'true')`);

  await db.run(`DELETE FROM settings_values WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);
  await db.run(`DELETE FROM settings_schema WHERE key IN ('tvdb_api_key', 'tvdb_subscriber_pin')`);

  // Converter-Pfade und Eigentümer
  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_raw_dir', 'Pfade', 'Converter RAW-Ordner', 'path', 0, 'Quellordner für Converter-Eingabedateien. Leer = Standardpfad.', NULL, '[]', '{}', 106)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_movie_dir', 'Pfade', 'Converter Video Output-Ordner', 'path', 0, 'Zielordner für konvertierte Video-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 116)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_audio_dir', 'Pfade', 'Converter Audio Output-Ordner', 'path', 0, 'Zielordner für konvertierte Audio-Dateien. Leer = Standardpfad.', NULL, '[]', '{}', 117)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_output_template_video', 'Pfade', 'Converter Video Output-Template', 'string', 0, 'Template für Video-Ausgabedateinamen, z.B. {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 738)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_video', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_output_template_audio', 'Pfade', 'Converter Audio Output-Template', 'string', 0, 'Template für Audio-Ausgabedateinamen, z.B. {artist} - {title}. Leer = Titel des Jobs.', NULL, '[]', '{}', 739)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_output_template_audio', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_raw_dir_owner', 'Pfade', 'Eigentümer Converter RAW-Ordner', 'string', 0, 'Eigentümer der Converter-Eingabedateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1065)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_raw_dir_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_movie_dir_owner', 'Pfade', 'Eigentümer Converter Video Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Video-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1165)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_movie_dir_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('converter_audio_dir_owner', 'Pfade', 'Eigentümer Converter Audio Output-Ordner', 'string', 0, 'Eigentümer der konvertierten Audio-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1166)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('converter_audio_dir_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('external_storage_paths', 'Pfade', 'Externe Speicher', 'path', 0, 'Wird links unter "Freie Speicher" angezeigt.', '[]', '[]', '{}', 119)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('external_storage_paths', '[]')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('drive_devices', 'Laufwerk', 'Explizite Laufwerke', 'path', 0, 'Laufwerke für den expliziten Modus als JSON-Array mit Pfad und MakeMKV-Index, z.B. [{\"path\":\"/dev/sr0\",\"makemkvIndex\":0},{\"path\":\"/dev/sr1\",\"makemkvIndex\":1}]. Nur relevant wenn Modus = Explizites Device.', '[]', '[]', '{}', 15)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('drive_devices', '[]')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('auto_eject_after_rip', 'Laufwerk', 'Laufwerk nach Rip auswerfen', 'boolean', 1, 'Wenn aktiv, wird das Laufwerk nach erfolgreichem Abschluss eines Rip-Vorgangs automatisch ausgeworfen (eject).', 'false', '[]', '{}', 45)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('auto_eject_after_rip', 'false')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('coverart_recovery_enabled', 'Metadaten', 'Coverart-Nachladen aktiv', 'boolean', 1, 'Wenn aktiv, werden fehlende Coverarts automatisch in Intervallen geprüft und nachgeladen.', 'true', '[]', '{}', 430)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_enabled', 'true')`);

  await db.run(
    `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
     VALUES ('coverart_recovery_interval_hours', 'Metadaten', 'Coverart-Prüfintervall (Stunden)', 'number', 1, 'Intervall für automatische Prüfung fehlender Coverarts in Stunden.', '6', '[]', '{"min":1,"max":168}', 431)`
  );
  await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('coverart_recovery_interval_hours', '6')`);

  await db.run(`
    CREATE TABLE IF NOT EXISTS user_prefs (
      key TEXT PRIMARY KEY,
      value TEXT,
      updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `);

  await db.run(`
    CREATE TABLE IF NOT EXISTS dvd_series_disc_profiles (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      provider TEXT NOT NULL DEFAULT 'tmdb',
      provider_series_id TEXT,
      provider_series_name TEXT,
      season_number INTEGER,
      season_type TEXT NOT NULL DEFAULT 'dvd',
      disc_label TEXT,
      disc_serial TEXT,
      disc_number INTEGER,
      language TEXT,
      disc_signature TEXT NOT NULL,
      fingerprint_json TEXT,
      episode_map_json TEXT,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `);
  await db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_signature ON dvd_series_disc_profiles(disc_signature)`);
  await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_disc_profiles_series ON dvd_series_disc_profiles(provider, provider_series_id, season_number)`);
  await db.run(`UPDATE dvd_series_disc_profiles SET provider = 'tmdb' WHERE provider = 'tvdb'`);

  await db.run(`
    CREATE TABLE IF NOT EXISTS dvd_series_title_mappings (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      parent_job_id INTEGER NOT NULL,
      disc_profile_id INTEGER,
      title_index INTEGER NOT NULL,
      title_kind TEXT NOT NULL,
      confidence REAL NOT NULL DEFAULT 0,
      selected INTEGER NOT NULL DEFAULT 1,
      provider TEXT NOT NULL DEFAULT 'tmdb',
      provider_series_id TEXT,
      season_number INTEGER,
      episode_start INTEGER,
      episode_end INTEGER,
      episode_ids_json TEXT,
      mapping_json TEXT,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
      updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE CASCADE,
      FOREIGN KEY (disc_profile_id) REFERENCES dvd_series_disc_profiles(id) ON DELETE SET NULL
    )
  `);
  await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_parent_job ON dvd_series_title_mappings(parent_job_id, title_index)`);
  await db.run(`CREATE INDEX IF NOT EXISTS idx_dvd_series_title_mappings_series ON dvd_series_title_mappings(provider, provider_series_id, season_number)`);
  await db.run(`UPDATE dvd_series_title_mappings SET provider = 'tmdb' WHERE provider = 'tvdb'`);
}

async function backfillJobOutputFolders(db) {
  // Remove duplicate (job_id, output_path) rows before unique index is enforced.
  await db.run(`
    DELETE FROM job_output_folders
    WHERE id NOT IN (
      SELECT MIN(id) FROM job_output_folders GROUP BY job_id, output_path
    )
  `);

  // Populate job_output_folders from jobs.output_path for pre-feature jobs.
  // Only inserts rows where no entry exists for the job yet.
  const rows = await db.all(`
    SELECT j.id AS job_id, j.output_path
    FROM jobs j
    WHERE j.output_path IS NOT NULL
      AND j.output_path != ''
      AND NOT EXISTS (
        SELECT 1 FROM job_output_folders f WHERE f.job_id = j.id
      )
  `);
  if (!Array.isArray(rows) || rows.length === 0) return;
  let inserted = 0;
  for (const row of rows) {
    try {
      await db.run(
        'INSERT OR IGNORE INTO job_output_folders (job_id, output_path) VALUES (?, ?)',
        [row.job_id, row.output_path]
      );
      inserted += 1;
    } catch (err) {
      logger.warn('backfill:job-output-folders:insert-failed', { jobId: row.job_id, error: err?.message });
    }
  }
  if (inserted > 0) {
    logger.info('backfill:job-output-folders:done', { inserted });
  }
}

async function getDb() {
  return initDatabase();
}

module.exports = {
  initDatabase,
  getDb
};
