import 'dart:io'; import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; /// Servicio de persistencia de emisoras favoritas con SQLite. /// /// - Inicialización lazy: la BD se abre en el primer acceso. /// - Migration-ready: versión 3 añade agrupaciones de favoritos. class ServicioFavoritos { static const _dbName = 'pluriwave.db'; static const _dbVersion = 3; ServicioFavoritos({ DatabaseFactory? databaseFactory, Future Function()? databasePathProvider, String? databaseName, }) : _databaseFactory = databaseFactory, _databasePathProvider = databasePathProvider ?? getDatabasesPath, _databaseName = databaseName ?? _dbName; final DatabaseFactory? _databaseFactory; final Future Function() _databasePathProvider; final String _databaseName; Database? _db; Future get _database async { _db ??= await _initDb(); return _db!; } Future cerrar() async { await _db?.close(); _db = null; } Future _initDb() async { final dbPath = await _databasePathProvider(); await Directory(dbPath).create(recursive: true); final path = join(dbPath, _databaseName); final factory = _databaseFactory; if (factory != null) { return factory.openDatabase( path, options: OpenDatabaseOptions( version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, onOpen: _asegurarEsquema, ), ); } return openDatabase( path, version: _dbVersion, onCreate: _onCreate, onUpgrade: _onUpgrade, onOpen: _asegurarEsquema, ); } Future _onCreate(Database db, int version) async { await db.execute(''' CREATE TABLE favoritos ( id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, nombre TEXT NOT NULL, url TEXT NOT NULL, favicon TEXT, pais TEXT, codigo_pais TEXT, idioma TEXT, tags TEXT, codec TEXT, bitrate INTEGER, votes INTEGER NOT NULL DEFAULT 0, clickcount INTEGER NOT NULL DEFAULT 0, orden INTEGER NOT NULL DEFAULT 0, grupo_id TEXT NOT NULL DEFAULT 'sin_asignar' ) '''); await _crearTablaGrupos(db); await _asegurarGrupoSinAsignar(db); } Future _onUpgrade(Database db, int oldVersion, int newVersion) async { await _asegurarEsquema(db); } Future _asegurarEsquema(Database db) async { final tablas = await db.rawQuery( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'favoritos'", ); if (tablas.isEmpty) { await _onCreate(db, _dbVersion); return; } final columnas = await _columnas(db, 'favoritos'); Future addColumn(String nombre, String sql) async { if (!columnas.contains(nombre)) { await db.execute('ALTER TABLE favoritos ADD COLUMN $nombre $sql'); columnas.add(nombre); } } // Migración defensiva: algunas instalaciones antiguas pueden venir de // esquemas intermedios. No asumimos qué columna existe: la verificamos. await addColumn('favicon', 'TEXT'); await addColumn('pais', 'TEXT'); await addColumn('codigo_pais', 'TEXT'); await addColumn('idioma', 'TEXT'); await addColumn('tags', 'TEXT'); await addColumn('codec', 'TEXT'); await addColumn('bitrate', 'INTEGER'); await addColumn('votes', 'INTEGER NOT NULL DEFAULT 0'); await addColumn('clickcount', 'INTEGER NOT NULL DEFAULT 0'); await addColumn('orden', 'INTEGER NOT NULL DEFAULT 0'); await addColumn( 'grupo_id', "TEXT NOT NULL DEFAULT '${GrupoFavoritos.sinAsignarId}'", ); await _crearTablaGrupos(db); await _asegurarGrupoSinAsignar(db); } Future _crearTablaGrupos(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS grupos_favoritos ( id TEXT PRIMARY KEY, nombre TEXT NOT NULL, orden INTEGER NOT NULL DEFAULT 0, protegido INTEGER NOT NULL DEFAULT 0 ) '''); } Future _asegurarGrupoSinAsignar(Database db) async { await db.insert( 'grupos_favoritos', const GrupoFavoritos( id: GrupoFavoritos.sinAsignarId, nombre: 'Sin asignar', orden: 0, protegido: true, ).toMap(), conflictAlgorithm: ConflictAlgorithm.ignore, ); } Future> _columnas(Database db, String tabla) async { final info = await db.rawQuery('PRAGMA table_info($tabla)'); return info.map((row) => row['name'] as String).toSet(); } Future> obtenerTodos() async { final db = await _database; final rows = await db.query('favoritos', orderBy: 'orden ASC'); return rows.map(Emisora.fromMap).toList(); } Future> obtenerGrupos() async { final db = await _database; final rows = await db.query( 'grupos_favoritos', orderBy: 'orden ASC, nombre ASC', ); return rows.map(GrupoFavoritos.fromMap).toList(); } Future crearGrupo(String nombre) async { final db = await _database; final normalizado = _normalizarNombreGrupo(nombre); final maxOrden = Sqflite.firstIntValue( await db.rawQuery('SELECT MAX(orden) FROM grupos_favoritos'), ) ?? 0; final grupo = GrupoFavoritos( id: 'grupo_${DateTime.now().microsecondsSinceEpoch}', nombre: normalizado, orden: maxOrden + 1, ); await db.insert('grupos_favoritos', grupo.toMap()); return grupo; } Future renombrarGrupo(String id, String nombre) async { if (id == GrupoFavoritos.sinAsignarId) return; final db = await _database; await db.update( 'grupos_favoritos', {'nombre': _normalizarNombreGrupo(nombre)}, where: 'id = ? AND protegido = 0', whereArgs: [id], ); } /// Restaura un grupo tal como estaba en el dispositivo de origen. /// Hace un upsert preservando id, nombre y orden originales. /// Usado exclusivamente por importarConfig para garantizar portabilidad completa. Future restaurarGrupo(GrupoFavoritos grupo) async { if (grupo.esSinAsignar) return; final db = await _database; await db.insert( 'grupos_favoritos', grupo.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future eliminarGrupo(String id) async { if (id == GrupoFavoritos.sinAsignarId) return; final db = await _database; await db.transaction((txn) async { await txn.update( 'favoritos', {'grupo_id': GrupoFavoritos.sinAsignarId}, where: 'grupo_id = ?', whereArgs: [id], ); await txn.delete( 'grupos_favoritos', where: 'id = ? AND protegido = 0', whereArgs: [id], ); }); } Future asignarGrupo(String uuid, String grupoId) async { final db = await _database; final existe = Sqflite.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?', [grupoId], ), ) ?? 0; final destino = existe > 0 ? grupoId : GrupoFavoritos.sinAsignarId; await db.update( 'favoritos', {'grupo_id': destino}, where: 'uuid = ?', whereArgs: [uuid], ); } Future agregar(Emisora emisora) async { final db = await _database; final maxOrden = Sqflite.firstIntValue( await db.rawQuery('SELECT MAX(orden) FROM favoritos'), ) ?? -1; final nuevaEmisora = emisora.copyWith( orden: maxOrden + 1, grupoFavoritosId: GrupoFavoritos.sinAsignarId, ); await db.insert( 'favoritos', nuevaEmisora.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } Future eliminar(String uuid) async { final db = await _database; await db.delete('favoritos', where: 'uuid = ?', whereArgs: [uuid]); } Future esFavorito(String uuid) async { final db = await _database; final count = Sqflite.firstIntValue( await db.rawQuery( 'SELECT COUNT(*) FROM favoritos WHERE uuid = ?', [uuid], ), ); return (count ?? 0) > 0; } Future toggleFavorito(Emisora emisora) async { if (await esFavorito(emisora.uuid)) { await eliminar(emisora.uuid); return false; } else { await agregar(emisora); return true; } } Future reordenar(String uuid, int nuevoOrden) async { final db = await _database; await db.transaction((txn) async { final rows = await txn.query( 'favoritos', columns: ['uuid'], orderBy: 'orden ASC, id ASC', ); final uuids = rows.map((r) => r['uuid'] as String).toList(); final oldIndex = uuids.indexOf(uuid); if (oldIndex == -1 || uuids.isEmpty) return; final targetIndex = nuevoOrden.clamp(0, uuids.length - 1); final moved = uuids.removeAt(oldIndex); uuids.insert(targetIndex, moved); for (var i = 0; i < uuids.length; i++) { await txn.update( 'favoritos', {'orden': i}, where: 'uuid = ?', whereArgs: [uuids[i]], ); } }); } String _normalizarNombreGrupo(String nombre) { final normalizado = nombre.trim().replaceAll(RegExp(r'\s+'), ' '); if (normalizado.isEmpty) { throw ArgumentError('El nombre del grupo no puede estar vacío'); } if (normalizado.length > 28) { throw ArgumentError('El nombre del grupo no puede superar 28 caracteres'); } return normalizado; } }