import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/events.dart'; import 'package:aves/remote/remote_settings.dart'; import 'package:aves/remote/remote_sync_bus.dart'; import 'package:aves/remote/remote_repository.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/remote/remote_client.dart'; import 'package:aves/remote/auth_client.dart'; import 'package:aves/remote/collection_source_remote_ext.dart'; import 'package:aves/remote/collection_source_remote_ws_ext.dart'; import 'remote_origin.dart'; import 'remote_http_api.dart'; import 'remote_sync_engine.dart'; import 'remote_state_store.dart'; import 'remote_ws_client.dart'; import 'remote_http.dart'; import 'remote_added_queue.dart'; class RemoteController { RemoteController._(); static final RemoteController instance = RemoteController._(); static const _kBootstrapDone = 'remote_bootstrap_done'; bool _syncInFlight = false; DateTime? _retryStartTime; Timer? _retryTimer; RemoteAuth? _auth; RemoteHttpApi? _api; RemoteSyncEngine? _engine; RemoteWsClient? _ws; RemoteStateStore? _stateStore; RemoteAddedQueue? _addedQueue; EventRingDeduper? _deduper; Future? _ensureStackFuture; bool _wsConnectInFlight = false; // coalescing progressive Timer? _progressiveTimer; bool _progressiveScheduled = false; String? _pendingSinceIso; // ------------------------------------------------------------ // helper: forza refresh lens quando cambia remoteVisible // ------------------------------------------------------------ void _notifyRemoteVisibilityChanged(CollectionSource source) { try { source.eventBus.fire(const FilterVisibilityChangedEvent()); } catch (_) { // ignore } } // refresh “forte” (la lens ascolta EntryRefreshedEvent e fa refresh()) void _forceCollectionRefresh(CollectionSource source) { try { source.eventBus.fire(const EntryRefreshedEvent({})); } catch (_) { // ignore } } // ------------------------------------------------------------ // bootstrap flag // ------------------------------------------------------------ Future bootstrapDone() async { const storage = FlutterSecureStorage(); return (await storage.read(key: _kBootstrapDone)) == '1'; } Future _setBootstrapDone() async { const storage = FlutterSecureStorage(); await storage.write(key: _kBootstrapDone, value: '1'); } // ------------------------------------------------------------ // init bus (usata da HomePage init) // ------------------------------------------------------------ Future initBusFromSettings() async { final s = await RemoteSettings.load(); if (!s.enabled) { RemoteSyncBus.instance.setDisabled(); return; } RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.syncing; RemoteSyncBus.instance.progressNotifier.value = const RemoteSyncProgress( done: 0, total: 0, showOverlay: false, ); } // ------------------------------------------------------------ // ws url // ------------------------------------------------------------ String _deriveWsUrl(String baseUrl) { final uri = Uri.parse(baseUrl); final scheme = uri.scheme == 'http' ? 'ws' : 'wss'; final parts = uri.host.split('.'); if (parts.isEmpty) return '$scheme://${uri.host}'; parts[0] = '${parts[0]}-ws'; return '$scheme://${parts.join('.')}'; } String _resolveWsUrl(RemoteSettings s) => s.wsUrl.trim().isNotEmpty ? s.wsUrl.trim() : _deriveWsUrl(s.baseUrl); // ------------------------------------------------------------ // ensure stack single-flight // ------------------------------------------------------------ Future _ensureRealtimeStack(CollectionSource source) { final existing = _ensureStackFuture; if (existing != null) return existing; final fut = _ensureRealtimeStackImpl(source); _ensureStackFuture = fut; return fut.whenComplete(() { if (identical(_ensureStackFuture, fut)) _ensureStackFuture = null; }); } Future _ensureRealtimeStackImpl(CollectionSource source) async { final s = await RemoteSettings.load(); _stateStore ??= RemoteStateStore(); _deduper ??= EventRingDeduper(_stateStore!); final wsUrl = _resolveWsUrl(s); final rebuildAuth = _auth == null || _auth!.email != s.email || _auth!.password != s.password || _auth!.base.toString() != (s.baseUrl.endsWith('/') ? s.baseUrl : '${s.baseUrl}/'); final rebuildWs = _ws == null || _ws!.wsUrl != wsUrl; if (rebuildAuth || rebuildWs) { _auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password); // allinea RemoteHttp (thumbs) allo stesso auth/token RemoteHttp.attach(baseUrl: s.baseUrl, auth: _auth!); await RemoteHttp.warmUp(); _api = RemoteHttpApi(baseUrl: s.baseUrl, auth: _auth!); _engine = RemoteSyncEngine( api: _api!, repo: RemoteRepository(localMediaDb.rawDb), source: source, state: _stateStore!, ); // chiudi eventuale ws precedente _ws?.close(); // ignore: unawaited_futures // crea la queue per batch di "added" con deduper e callback UI debounced final addedQueue = RemoteAddedQueue( api: _api!, engine: _engine!, deduper: _deduper!, onUiRefresh: () => _forceCollectionRefresh(source), ); // dichiariamo la variabile prima per poterla referenziare nelle closure late RemoteWsClient wsClient; wsClient = RemoteWsClient( wsUrl: wsUrl, store: _stateStore!, deduper: _deduper!, // quando il server richiede recovery, esegui la progressive sync (con UI/Bus) e poi invia recovery_done onNeedRecoverySync: () async { try { await _runProgressiveSync(source); } finally { // notifica al WS client che la recovery è completata wsClient.notifyRecoveryCompleted(); } }, // delega gli altri eventi al controller (bulk, del, updated, ecc.) onEvent: (msg) => _handleWsEvent(source, msg), ); // registra la queue nel client WS wsClient.setAddedQueue(addedQueue); // salva la queue e il client nello stato del controller _addedQueue = addedQueue; _ws = wsClient; source.remoteWsClient = wsClient; // opzionale: inizializza la queue se ha init/async setup try { await _addedQueue?.init(); } catch (_) { // ignore init errors, queue funzionerà comunque } } else { await _deduper!.init(); } } // ------------------------------------------------------------ // progressive coalescing // ------------------------------------------------------------ void _requestProgressiveSync({ required CollectionSource source, String? sinceIso, Duration delay = const Duration(milliseconds: 600), }) { if (sinceIso != null && sinceIso.trim().isNotEmpty) { _pendingSinceIso = _minIso(_pendingSinceIso, sinceIso.trim()); } if (_progressiveScheduled) return; _progressiveScheduled = true; _progressiveTimer?.cancel(); _progressiveTimer = Timer(delay, () async { _progressiveScheduled = false; final since = _pendingSinceIso; _pendingSinceIso = null; try { await _runProgressiveSync(source, since: since); } catch (_) {} }); } String? _minIso(String? a, String b) { if (a == null || a.isEmpty) return b; final da = DateTime.tryParse(a); final db = DateTime.tryParse(b); if (da == null) return b; if (db == null) return a; return db.isBefore(da) ? b : a; } // ------------------------------------------------------------ // PATCH 1: progressive NON deve far tornare arancione se eri già verde // ------------------------------------------------------------ Future _runProgressiveSync(CollectionSource source, {String? since}) async { await _ensureRealtimeStack(source); final engine = _engine; if (engine == null) return; final prev = RemoteSyncBus.instance.stateNotifier.value; // ✅ Se eri già "verde", non flashare l’icona su "syncing" final bool keepGreen = prev == RemoteSyncState.upToDate; int? opId; if (!keepGreen) { opId = RemoteSyncBus.instance.start(total: 0, showOverlay: false); _notifyRemoteVisibilityChanged(source); } try { if (since != null && since.trim().isNotEmpty) { await engine.progressiveSyncFrom(since.trim()); } else { await engine.progressiveSync(); } if (opId != null) { RemoteSyncBus.instance.finishUpToDate(opId: opId); _notifyRemoteVisibilityChanged(source); } else { // resta verde RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.upToDate; RemoteSyncBus.instance.progressNotifier.value = null; } _forceCollectionRefresh(source); } catch (e, st) { debugPrint('[remote] progressive sync failed: $e\n$st'); if (opId != null) { RemoteSyncBus.instance.failServerDown(opId: opId); _notifyRemoteVisibilityChanged(source); } else { RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown; RemoteSyncBus.instance.progressNotifier.value = null; _notifyRemoteVisibilityChanged(source); } _forceCollectionRefresh(source); rethrow; } } // ------------------------------------------------------------ // ws start/stop // ------------------------------------------------------------ Future _startWsAlreadyEnsured() async { if (_wsConnectInFlight) return; _wsConnectInFlight = true; try { final s = await RemoteSettings.load(); if (!s.enabled) return; if (_ws == null || _auth == null) return; if (_ws!.isConnected) return; final token = _auth!.token ?? await _auth!.login(); await _ws!.connect(token: token); } finally { _wsConnectInFlight = false; } } Future _stopWs(CollectionSource source) async { _progressiveTimer?.cancel(); _progressiveTimer = null; _progressiveScheduled = false; _pendingSinceIso = null; try { await _ws?.close(); } catch (_) {} try { await _addedQueue?.dispose(); } catch (_) {} _addedQueue = null; _ws = null; _engine = null; _api = null; _auth = null; _ensureStackFuture = null; _wsConnectInFlight = false; _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); } // ------------------------------------------------------------ // ws events // ------------------------------------------------------------ Future _handleWsEvent(CollectionSource source, Map msg) async { final type = msg['type']?.toString(); if (type == null) return true; // BULK add_dir / del_dir: se il server fornisce "since", esegui subito la sync mirata if ((type == 'add_dir' || type == 'del_dir') && msg['mode'] == 'bulk') { final since = msg['since']?.toString(); if (since != null && since.isNotEmpty) { try { await _runProgressiveSync(source, since: since); return true; } catch (_) { // se fallisce, segnala al caller che non è stato applicato return false; } } // se non c'è since, accetta l'evento e aspetta eventuale add_dir_done return true; } // add_dir_done / del_dir_done: server conferma fine bulk, esegui la sync mirata se presente if ((type == 'add_dir_done' || type == 'del_dir_done') && msg['mode'] == 'bulk') { final since = msg['since']?.toString(); try { await _runProgressiveSync(source, since: since); return true; } catch (_) { return false; } } // added / del / removed / updated // - per added: la RemoteAddedQueue è già registrata nel WS client e gestisce batching // - per del/removed/updated: chiediamo una progressive coalesced (puoi ottimizzare per id) if (type == 'added' || type == 'del' || type == 'removed' || type == 'updated') { _requestProgressiveSync(source: source); return true; } return true; } // ------------------------------------------------------------ // lifecycle // ------------------------------------------------------------ Future onAppStart({ required CollectionSource source, bool resumeBootstrapIfEnabled = true, }) async { final s = await RemoteSettings.load(); debugPrint('[remote-debug] onAppStart start settings.enabled=${s.enabled} bus=${RemoteSyncBus.instance.stateNotifier.value}'); if (!s.enabled) { debugPrint('[remote-debug] remote disabled in settings -> disabling bus and clearing remotes'); RemoteSyncBus.instance.setDisabled(); _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); final remotes = source.allEntries.where((e) => e.origin == RemoteOrigin.value).toSet(); if (remotes.isNotEmpty) { debugPrint('[remote-debug] removing ${remotes.length} remote entries from memory'); source.removeEntriesFromMemory(remotes); } await _stopWs(source); debugPrint('[remote-debug] onAppStart end (remote disabled) bus=${RemoteSyncBus.instance.stateNotifier.value}'); return; } final done = await bootstrapDone(); debugPrint('[remote-debug] bootstrapDone=$done'); if (!done) { debugPrint('[remote-debug] first bootstrap -> ensure stack and run fullSyncImpl'); await _ensureRealtimeStack(source); await _engine!.fullSyncImpl(); try { await _setBootstrapDone(); debugPrint('[remote-debug] bootstrap complete: flag set'); } catch (e, st) { debugPrint('[remote-debug] _setBootstrapDone failed: $e\n$st'); } return; } // prima di append: syncing + refresh RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.syncing; RemoteSyncBus.instance.progressNotifier.value = const RemoteSyncProgress(done: 0, total: 0, showOverlay: false); _notifyRemoteVisibilityChanged(source); debugPrint('[remote-debug] set state=syncing before append'); // append remoti dal DB (batching nella collection) debugPrint('[remote-debug] calling appendRemoteEntriesFromDb()'); await source.appendRemoteEntriesFromDb(); debugPrint('[remote-debug] after append state=${RemoteSyncBus.instance.stateNotifier.value} progress=${RemoteSyncBus.instance.progressNotifier.value}'); // forza refresh Home (cache/lens) _notifyRemoteVisibilityChanged(source); debugPrint('[remote-debug] notified visibility after append'); // avvia lo stack realtime (await per assicurare init, ma non bloccare la UI con full sync) debugPrint('[remote-debug] ensuring realtime stack'); await _ensureRealtimeStack(source); debugPrint('[remote-debug] ensureRealtimeStack done, starting WS (unawaited)'); unawaited(_startWsAlreadyEnsured()); // DEBUG: log stato WS se disponibile (non bloccante) try { debugPrint('[remote-debug] ws client present=${_ws != null} wsConnected=${_ws?.isConnected}'); } catch (_) {} // Imposta lo stato UP-TO-DATE solo dopo aver avviato lo stack realtime RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.upToDate; RemoteSyncBus.instance.progressNotifier.value = null; _notifyRemoteVisibilityChanged(source); debugPrint('[remote-debug] set state=upToDate after starting WS'); // avvia progressive sync coalesced _requestProgressiveSync(source: source, delay: const Duration(milliseconds: 200)); debugPrint('[remote-debug] requested progressive sync and exiting onAppStart'); } Future toggleRemote({required CollectionSource source}) async { final s = await RemoteSettings.load(); final upd = RemoteSettings( enabled: !s.enabled, baseUrl: s.baseUrl, indexPath: s.indexPath, email: s.email, password: s.password, wsUrl: s.wsUrl, ); await upd.save(); if (!upd.enabled) { RemoteSyncBus.instance.setDisabled(); _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); final remotes = source.allEntries.where((e) => e.origin == RemoteOrigin.value).toSet(); if (remotes.isNotEmpty) source.removeEntriesFromMemory(remotes); await _stopWs(source); return; } // ------------------------------------------------------------ // PATCH 2: se è la prima volta, mostra overlay (barra progresso) al bootstrap // ------------------------------------------------------------ final first = !(await bootstrapDone()); await onAppStart(source: source, resumeBootstrapIfEnabled: first); } // ------------------------------------------------------------ // full sync legacy (progress + overlay) // ------------------------------------------------------------ Future fullSync({ required CollectionSource source, required bool showOverlay, bool markBootstrapDoneOnSuccess = false, }) async { if (_syncInFlight) return; _syncInFlight = true; final s = await RemoteSettings.load(); if (!s.enabled) { RemoteSyncBus.instance.setDisabled(); _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); _syncInFlight = false; return; } try { if (s.baseUrl.trim().isEmpty) { RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown; RemoteSyncBus.instance.progressNotifier.value = null; _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); _syncInFlight = false; return; } RemoteAuth? auth; if (s.email.isNotEmpty && s.password.isNotEmpty) { auth = RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password); } final client = RemoteJsonClient(s.baseUrl, s.indexPath, auth: auth); await client.ping().timeout(const Duration(seconds: 3)); final items = await client.fetchAll().timeout(const Duration(seconds: 30)); final total = items.length; final opId = RemoteSyncBus.instance.start(total: total, showOverlay: showOverlay); _notifyRemoteVisibilityChanged(source); final repo = RemoteRepository(localMediaDb.rawDb); // Decide se è bootstrap (solo in quel caso deleteAllRemotes) final isBootstrap = !(await bootstrapDone()); final serverIds = items.map((e) => e.id).where((v) => v.isNotEmpty).toSet(); if (isBootstrap) { debugPrint('[remote-debug] fullSync bootstrap: deleteAllRemotes + upsert chunked'); await repo.deleteAllRemotes(); // upsert in chunk più piccoli per non bloccare il main thread const chunk = 40; int done = 0; for (var i = 0; i < total; i += chunk) { final end = (i + chunk < total) ? i + chunk : total; await repo.upsertAll(items.sublist(i, end), chunkSize: chunk); done = end; RemoteSyncBus.instance.update(opId: opId, done: done, total: total); } try { await _setBootstrapDone(); debugPrint('[remote-debug] bootstrap flag set after fullSync'); } catch (e, st) { debugPrint('[remote-debug] _setBootstrapDone failed: $e\n$st'); } } else { debugPrint('[remote-debug] fullSync incremental: upsert chunked + pruneMissingRemotes'); const chunk = 40; int done = 0; for (var i = 0; i < total; i += chunk) { final end = (i + chunk < total) ? i + chunk : total; await repo.upsertAll(items.sublist(i, end), chunkSize: chunk); done = end; RemoteSyncBus.instance.update(opId: opId, done: done, total: total); } await repo.pruneMissingRemotes(serverIds); } // append remoti dal DB (collection batching) await source.appendRemoteEntriesFromDb(); _notifyRemoteVisibilityChanged(source); RemoteSyncBus.instance.finishUpToDate(opId: opId); _notifyRemoteVisibilityChanged(source); // assicurati che lo stack realtime sia avviato e poi avvia WS await _ensureRealtimeStack(source); unawaited(_startWsAlreadyEnsured()); _requestProgressiveSync(source: source, delay: const Duration(milliseconds: 300)); } catch (e, st) { debugPrint('[remote] fullSync error: $e\n$st'); RemoteSyncBus.instance.stateNotifier.value = RemoteSyncState.serverDown; RemoteSyncBus.instance.progressNotifier.value = null; _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); _retryStartTime ??= DateTime.now(); _scheduleRetry(source); } finally { _syncInFlight = false; } } // ------------------------------------------------------------ // retry // ------------------------------------------------------------ void _scheduleRetry(CollectionSource source) { _retryTimer?.cancel(); _retryTimer = Timer(const Duration(seconds: 30), () async { final s = await RemoteSettings.load(); if (!s.enabled) return; if (_retryStartTime != null) { final elapsed = DateTime.now().difference(_retryStartTime!); if (elapsed > const Duration(minutes: 5)) { debugPrint('[remote] retry timeout -> disattivo remote'); final remotes = source.allEntries.where((e) => e.origin == RemoteOrigin.value).toSet(); if (remotes.isNotEmpty) source.removeEntriesFromMemory(remotes); final upd = RemoteSettings( enabled: false, baseUrl: s.baseUrl, indexPath: s.indexPath, email: s.email, password: s.password, wsUrl: s.wsUrl, ); await upd.save(); RemoteSyncBus.instance.setDisabled(); _notifyRemoteVisibilityChanged(source); _forceCollectionRefresh(source); await _stopWs(source); return; } } debugPrint('[remote] retry ping…'); final auth = (s.email.isNotEmpty && s.password.isNotEmpty) ? RemoteAuth(baseUrl: s.baseUrl, email: s.email, password: s.password) : null; final retryClient = RemoteJsonClient(s.baseUrl, s.indexPath, auth: auth); try { await retryClient.ping().timeout(const Duration(seconds: 3)); debugPrint('[remote] retry OK -> riprendo sync'); _retryTimer = null; _retryStartTime = null; unawaited(fullSync(source: source, showOverlay: false)); } catch (_) { debugPrint('[remote] retry fallito'); _scheduleRetry(source); } }); } Future onResume(CollectionSource source) async { final s = await RemoteSettings.load(); if (!s.enabled) return; await _ensureRealtimeStack(source); unawaited(_startWsAlreadyEnsured()); _requestProgressiveSync(source: source, delay: const Duration(milliseconds: 200)); } }