// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:math';

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:http/http.dart' as http;
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:workmanager/workmanager.dart';
import 'package:potato_mesh_reader/dart_plugin_registrant.dart'
    as dart_plugin_registrant;

const String _gitVersionEnv =
    String.fromEnvironment('GIT_VERSION', defaultValue: '');
const String _gitTagEnv = String.fromEnvironment('GIT_TAG', defaultValue: '');
const String _gitCommitsEnv =
    String.fromEnvironment('GIT_COMMITS', defaultValue: '');
const String _gitShaEnv = String.fromEnvironment('GIT_SHA', defaultValue: '');
const String _gitDirtyEnv =
    String.fromEnvironment('GIT_DIRTY', defaultValue: '');
const Duration _requestTimeout = Duration(seconds: 5);
const String _themePreferenceKey = 'mesh.themeMode';
const String _notificationChannelId = 'mesh.messages';
const String _notificationChannelName = 'Mesh messages';
const String _notificationChannelDescription =
    'Alerts when new PotatoMesh messages arrive';
const String _notificationIconName = 'ic_mesh_notification';
const String _backgroundTaskName = 'mesh_message_poll';
const String _backgroundTaskId = 'mesh.message.poll';
const Duration _backgroundFetchInterval = Duration(minutes: 15);

/// Client interface used to deliver notifications when unseen messages arrive.
abstract class NotificationClient {
  const NotificationClient();

  /// Performs any platform-specific initialization, such as channel creation.
  Future<void> initialize();

  /// Shows a notification for an unseen message.
  Future<void> showNewMessage({
    required MeshMessage message,
    required String domain,
    String? senderShortName,
    String? senderLongName,
  });
}

/// Display names used when rendering a notification title and subtitle.
class NotificationSender {
  const NotificationSender({
    required this.shortName,
    this.longName,
  });

  /// Short name fallback for the sender when a long name is unavailable.
  final String shortName;

  /// Full descriptive name for the sender when provided by the mesh.
  final String? longName;

  /// Preferred display name that favors the long form when available.
  String get preferredName {
    if (longName != null && longName!.trim().isNotEmpty) {
      return longName!.trim();
    }
    return shortName.trim();
  }
}

/// No-op notification client used in tests and web builds.
class NoopNotificationClient implements NotificationClient {
  const NoopNotificationClient();

  @override
  Future<void> initialize() async {}

  @override
  Future<void> showNewMessage({
    required MeshMessage message,
    required String domain,
    String? senderShortName,
    String? senderLongName,
  }) async {}
}

/// Platform-aware notification client backed by the Flutter Local Notifications plugin.
class LocalNotificationClient implements NotificationClient {
  LocalNotificationClient({FlutterLocalNotificationsPlugin? plugin})
      : _plugin = plugin ?? FlutterLocalNotificationsPlugin();

  final FlutterLocalNotificationsPlugin _plugin;
  bool _initialized = false;

  AndroidNotificationChannel get _channel => const AndroidNotificationChannel(
        _notificationChannelId,
        _notificationChannelName,
        description: _notificationChannelDescription,
        importance: Importance.high,
      );

  @override
  Future<void> initialize() async {
    if (_initialized) return;
    if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) {
      // Unit tests and desktop builds do not have a notification host.
      _initialized = true;
      return;
    }
    final androidInit = buildAndroidInitializationSettings();
    final iosInit = buildDarwinInitializationSettings();
    final settings = InitializationSettings(android: androidInit, iOS: iosInit);
    await _plugin.initialize(settings);

    final android = _plugin.resolvePlatformSpecificImplementation<
        AndroidFlutterLocalNotificationsPlugin>();
    await android?.createNotificationChannel(_channel);
    if (Platform.isAndroid) {
      final enabled = await android?.areNotificationsEnabled() ?? true;
      if (!enabled) {
        final granted =
            await android?.requestNotificationsPermission() ?? false;
        debugPrint('D/Notifications: permission requested; granted=$granted');
      }
    }
    debugPrint('D/Notifications: initialized');

    _initialized = true;
  }

  NotificationDetails _notificationDetails() {
    const androidDetails = AndroidNotificationDetails(
      _notificationChannelId,
      _notificationChannelName,
      channelDescription: _notificationChannelDescription,
      importance: Importance.high,
      priority: Priority.high,
      enableVibration: true,
      playSound: true,
      category: AndroidNotificationCategory.message,
      icon: _notificationIconName,
    );
    const iosDetails = DarwinNotificationDetails(
      presentAlert: true,
      presentBadge: true,
      presentSound: true,
      categoryIdentifier: 'chat',
    );
    return const NotificationDetails(
      android: androidDetails,
      iOS: iosDetails,
    );
  }

  /// Builds the default Android initialization settings.
  @visibleForTesting
  AndroidInitializationSettings buildAndroidInitializationSettings() {
    return const AndroidInitializationSettings(_notificationIconName);
  }

  /// Builds the default Darwin (iOS) initialization settings.
  @visibleForTesting
  DarwinInitializationSettings buildDarwinInitializationSettings() {
    return const DarwinInitializationSettings(
      requestAlertPermission: true,
      requestBadgePermission: true,
      requestSoundPermission: true,
    );
  }

  /// Exposes notification details for test assertions.
  @visibleForTesting
  NotificationDetails notificationDetailsForTest() {
    return _notificationDetails();
  }

  /// Picks the preferred sender name prioritising the long form when present.
  String _preferredSenderName({
    required MeshMessage message,
    String? senderLongName,
    String? senderShortName,
  }) {
    final trimmedLong = senderLongName?.trim();
    if (trimmedLong != null && trimmedLong.isNotEmpty) {
      return trimmedLong;
    }
    final trimmedShort = senderShortName?.trim();
    if (trimmedShort != null && trimmedShort.isNotEmpty) {
      return trimmedShort;
    }
    return message.fromShort;
  }

  @override
  Future<void> showNewMessage({
    required MeshMessage message,
    required String domain,
    String? senderShortName,
    String? senderLongName,
  }) async {
    await initialize();
    if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
    debugPrint('D/Notifications: showing message ${message.id} on $domain');
    final displaySender = _preferredSenderName(
      senderLongName: senderLongName,
      senderShortName: senderShortName,
      message: message,
    );
    final channel = message.channelName?.trim().isNotEmpty == true
        ? message.channelName!.trim()
        : domain;
    final title = displaySender;
    final body = message.text.trim().isNotEmpty
        ? message.text.trim()
        : 'New message on $channel';
    await _plugin.show(
      message.id.hashCode.abs(),
      title,
      body,
      _notificationDetails(),
      payload: domain,
    );
  }
}

/// Callback dispatcher used by the Workmanager plugin.
@pragma('vm:entry-point')
void _workmanagerCallbackDispatcher() {
  Workmanager().executeTask((task, inputData) async {
    try {
      WidgetsFlutterBinding.ensureInitialized();
      dart_plugin_registrant.DartPluginRegistrant.ensureInitialized();
      return await BackgroundSyncManager.handleBackgroundTask(
        task,
        inputData,
      );
    } catch (error, stackTrace) {
      debugPrint('E/BackgroundSync dispatcher failed: $error\n$stackTrace');
      return false;
    }
  });
}

/// Workmanager abstraction to simplify testing.
abstract class WorkmanagerAdapter {
  const WorkmanagerAdapter();

  Future<void> initialize(Function dispatcher);

  Future<void> registerPeriodicTask(
    String taskId,
    String taskName, {
    Duration frequency,
    ExistingPeriodicWorkPolicy existingWorkPolicy,
    Duration? initialDelay,
    Constraints? constraints,
  });

  Future<void> cancelAll();
}

/// Real Workmanager adapter used in production builds.
class FlutterWorkmanagerAdapter implements WorkmanagerAdapter {
  FlutterWorkmanagerAdapter({Workmanager? delegate})
      : _delegate = delegate ?? Workmanager();

  final Workmanager _delegate;

  @override
  Future<void> initialize(Function dispatcher) {
    return _delegate.initialize(dispatcher);
  }

  @override
  Future<void> registerPeriodicTask(
    String taskId,
    String taskName, {
    Duration frequency = _backgroundFetchInterval,
    ExistingPeriodicWorkPolicy existingWorkPolicy =
        ExistingPeriodicWorkPolicy.keep,
    Duration? initialDelay,
    Constraints? constraints,
  }) {
    return _delegate.registerPeriodicTask(
      taskId,
      taskName,
      frequency: frequency,
      existingWorkPolicy: existingWorkPolicy,
      initialDelay: initialDelay,
      constraints: constraints,
      inputData: const {},
    );
  }

  @override
  Future<void> cancelAll() {
    return _delegate.cancelAll();
  }
}

/// Factories used to build repositories and notifications for background work.
class BackgroundDependencies {
  const BackgroundDependencies({
    required this.repositoryBuilder,
    required this.notificationBuilder,
  });

  final Future<MeshRepository> Function() repositoryBuilder;
  final Future<NotificationClient> Function() notificationBuilder;
}

/// Schedules and executes periodic background message refreshes.
class BackgroundSyncManager {
  BackgroundSyncManager({
    required this.workmanager,
    required this.dependencies,
  });

  final WorkmanagerAdapter workmanager;
  final BackgroundDependencies dependencies;

  static final BackgroundDependencies _defaultDependencies =
      BackgroundDependencies(
    repositoryBuilder: () async => MeshRepository(),
    notificationBuilder: () async => LocalNotificationClient(),
  );
  static BackgroundDependencies? _registeredDependencies;

  /// Registers dependencies for the Workmanager callback and schedules polling.
  Future<void> initialize({bool debugMode = false}) async {
    _registeredDependencies = dependencies;
    await workmanager.initialize(_workmanagerCallbackDispatcher);
  }

  /// Schedules the periodic background fetch task.
  Future<void> ensurePeriodicTask() {
    return workmanager.registerPeriodicTask(
      _backgroundTaskId,
      _backgroundTaskName,
      frequency: _backgroundFetchInterval,
      existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
      initialDelay: const Duration(minutes: 1),
      constraints: Constraints(
        networkType: NetworkType.connected,
      ),
    );
  }

  /// Clears registered dependencies; intended for tests only.
  @visibleForTesting
  static void resetForTest() {
    _registeredDependencies = null;
  }

  /// Executes the background task to fetch messages and post notifications.
  @pragma('vm:entry-point')
  static Future<bool> handleBackgroundTask(
    String task,
    Map<String, dynamic>? inputData,
  ) async {
    WidgetsFlutterBinding.ensureInitialized();
    final deps = _registeredDependencies ??
        ((Platform.isAndroid || Platform.isIOS) ? _defaultDependencies : null);
    if (deps == null) {
      debugPrint('D/BackgroundSync: no dependencies registered; skipping');
      return true;
    }
    try {
      debugPrint('D/BackgroundSync: start task=$task');
      final repository = await deps.repositoryBuilder();
      final notification = await deps.notificationBuilder();
      await notification.initialize();

      final domain = await repository.loadSelectedDomainOrDefault();
      final messages = await repository.loadMessages(domain: domain);
      final unseen = await repository.detectUnseenMessages(
        domain: domain,
        messages: messages,
      );

      debugPrint(
          'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}');

      for (final message in unseen) {
        final sender = repository.resolveNotificationSender(
          domain: domain,
          message: message,
        );
        debugPrint(
            'D/BackgroundSync: notifying message=${message.id} sender=${sender.preferredName}');
        await notification.showNewMessage(
          message: message,
          domain: domain,
          senderShortName: sender.shortName,
          senderLongName: sender.longName,
        );
      }
      return true;
    } on SocketException catch (error) {
      debugPrint('W/BackgroundSync: network unavailable ($error); will retry');
      return true;
    } catch (error, stackTrace) {
      debugPrint('E/BackgroundSync failed: $error\n$stackTrace');
      // Return true to avoid aggressive retries if the environment blocks plugins.
      return true;
    }
  }
}

void _logHttp(String message) {
  debugPrint('D/$message');
}

Future<List<Map<String, dynamic>>> _decodeJsonList(String body) {
  return compute(_decodeJsonListSync, body);
}

List<Map<String, dynamic>> _decodeJsonListSync(String body) {
  final dynamic decoded = jsonDecode(body);
  if (decoded is! List) {
    throw const FormatException('Expected JSON array');
  }
  return decoded.whereType<Map<String, dynamic>>().toList();
}

Future<Map<String, dynamic>> _decodeJsonMap(String body) {
  return compute(_decodeJsonMapSync, body);
}

Map<String, dynamic> _decodeJsonMapSync(String body) {
  final dynamic decoded = jsonDecode(body);
  if (decoded is! Map<String, dynamic>) {
    throw const FormatException('Expected JSON object');
  }
  return decoded;
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final notificationClient = LocalNotificationClient();
  await notificationClient.initialize();
  if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
    final backgroundManager = BackgroundSyncManager(
      workmanager: FlutterWorkmanagerAdapter(),
      dependencies: BackgroundDependencies(
        repositoryBuilder: () async => MeshRepository(),
        notificationBuilder: () async => LocalNotificationClient(),
      ),
    );
    await backgroundManager.initialize();
    await backgroundManager.ensurePeriodicTask();
  }
  runApp(PotatoMeshReaderApp(
    notificationClient: notificationClient,
  ));
}

/// Persistent storage for the theme choice so the UI can honor user intent.
class ThemePreferenceStore {
  const ThemePreferenceStore();

  Future<ThemeMode> load() async {
    final prefs = await SharedPreferences.getInstance();
    final stored = prefs.getString(_themePreferenceKey);
    switch (stored) {
      case 'dark':
        return ThemeMode.dark;
      case 'light':
        return ThemeMode.light;
      case 'system':
        return ThemeMode.system;
      default:
        return ThemeMode.system;
    }
  }

  Future<void> save(ThemeMode mode) async {
    final prefs = await SharedPreferences.getInstance();
    final value = mode == ThemeMode.dark
        ? 'dark'
        : mode == ThemeMode.light
            ? 'light'
            : 'system';
    await prefs.setString(_themePreferenceKey, value);
  }
}

/// Function type used to fetch messages from a specific endpoint.
typedef MessageFetcher = Future<List<MeshMessage>> Function({
  http.Client? client,
  String domain,
});

/// PotatoMesh Reader root widget that configures theming and the home screen.
class PotatoMeshReaderApp extends StatefulWidget {
  const PotatoMeshReaderApp({
    super.key,
    this.fetcher = fetchMessages,
    this.instanceFetcher = fetchInstances,
    this.initialDomain = 'potatomesh.net',
    this.repository,
    this.bootstrapper,
    this.enableAutoRefresh = true,
    this.themeStore = const ThemePreferenceStore(),
    this.notificationClient = const NoopNotificationClient(),
  });

  /// Fetch function injected to simplify testing and offline previews.
  final MessageFetcher fetcher;

  /// Loader for federation instance metadata, overridable in tests.
  final Future<List<MeshInstance>> Function({http.Client? client})
      instanceFetcher;

  /// Initial endpoint domain used when the app boots.
  final String initialDomain;

  /// Backing repository controlling persistence and caching.
  final MeshRepository? repository;

  /// Optional bootstrapper override for testing or previews.
  final Future<BootstrapResult> Function({ProgressCallback? onProgress})?
      bootstrapper;

  /// Whether the chat view should periodically refresh messages.
  final bool enableAutoRefresh;

  /// Storage used to persist the chosen theme.
  final ThemePreferenceStore themeStore;

  /// Client responsible for platform notifications.
  final NotificationClient notificationClient;

  @override
  State<PotatoMeshReaderApp> createState() => _PotatoMeshReaderAppState();
}

class _PotatoMeshReaderAppState extends State<PotatoMeshReaderApp> {
  late String _endpointDomain;
  int _endpointVersion = 0;
  late final MeshRepository _repository;
  late final NotificationClient _notificationClient;
  final GlobalKey<ScaffoldMessengerState> _messengerKey =
      GlobalKey<ScaffoldMessengerState>();
  BootstrapProgress _progress =
      const BootstrapProgress(stage: 'loading instances');
  Future<BootstrapResult>? _bootstrapFuture;
  BootstrapResult? _bootstrapResult;
  Object? _lastError;
  ThemeMode _themeMode = ThemeMode.system;

  @override
  void initState() {
    super.initState();
    _endpointDomain = widget.initialDomain;
    _repository = widget.repository ?? MeshRepository();
    _notificationClient = widget.notificationClient;
    NodeShortNameCache.instance.registerResolver(_repository);
    _loadThemeMode();
    _startBootstrap();
  }

  Future<void> _loadThemeMode() async {
    final mode = await widget.themeStore.load();
    if (!mounted) return;
    setState(() {
      _themeMode = mode;
    });
  }

  void _startBootstrap() {
    final loader = widget.bootstrapper ??
        (({ProgressCallback? onProgress}) => _repository.bootstrap(
              initialDomain: widget.initialDomain,
              onProgress: onProgress,
            ));

    setState(() {
      _bootstrapFuture = loader(onProgress: _updateProgress);
    });

    _bootstrapFuture!.then((result) {
      if (!mounted) return;
      setState(() {
        _bootstrapResult = result;
        _endpointDomain = result.selectedDomain;
        _endpointVersion += 1;
        _lastError = null;
      });
    }).catchError((error) {
      if (!mounted) return;
      setState(() {
        _lastError = error;
      });
    });
  }

  void _updateProgress(BootstrapProgress progress) {
    if (!mounted) return;
    setState(() {
      _progress = progress;
    });
  }

  String _normalizeDomain(String domain) {
    var cleaned = domain.trim().toLowerCase();
    if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
    if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
    if (cleaned.endsWith('/')) {
      cleaned = cleaned.substring(0, cleaned.length - 1);
    }
    return cleaned;
  }

  String? _instanceNameFor(String domain) {
    final normalized = _normalizeDomain(domain);
    final candidates = <MeshInstance>[
      ..._repository.instances,
      if (_bootstrapResult != null) ..._bootstrapResult!.instances,
    ];
    for (final instance in candidates) {
      if (_normalizeDomain(instance.domain) == normalized) {
        return instance.displayName;
      }
    }
    return null;
  }

  Future<List<MeshInstance>> _loadInstances({bool refresh = false}) async {
    if (!refresh && _repository.instances.isNotEmpty) {
      return _repository.instances;
    }
    final instances = await widget.instanceFetcher();
    await _repository.updateInstances(instances);
    return instances;
  }

  Future<void> _handleThemeChanged(ThemeMode mode) async {
    setState(() {
      _themeMode = mode;
    });
    await widget.themeStore.save(mode);
  }

  Future<void> _handleEndpointChanged(String newDomain) async {
    if (newDomain.isEmpty || newDomain == _endpointDomain) {
      return;
    }

    final previousDomain = _endpointDomain;
    final previousSelectedDomain = _repository.selectedDomain;
    await _repository.rememberSelectedDomain(newDomain);
    final future = _repository
        .loadDomainData(
          domain: newDomain,
          forceFull: true,
          onProgress: _updateProgress,
        )
        .then(
          (domainResult) => BootstrapResult(
            instances: _repository.instances,
            nodes: domainResult.nodes,
            messages: domainResult.messages,
            selectedDomain: domainResult.domain,
          ),
        );

    setState(() {
      _bootstrapFuture = future;
      _endpointDomain = newDomain;
      _endpointVersion += 1;
      _lastError = null;
    });

    try {
      final result = await future;
      if (!mounted) return;
      setState(() {
        _bootstrapResult = result;
        _endpointDomain = result.selectedDomain;
        _lastError = null;
      });
    } catch (error) {
      if (!mounted) return;
      setState(() {
        _lastError = error;
        _endpointDomain = previousDomain;
      });
      await _repository.rememberSelectedDomain(previousSelectedDomain);
      _messengerKey.currentState?.showSnackBar(
        SnackBar(content: Text('Failed to switch instance: $error')),
      );
    }
  }

  Future<List<MeshMessage>> _fetchMessagesForCurrentDomain({
    http.Client? client,
    String domain = '',
  }) {
    final activeDomain = domain.isNotEmpty ? domain : _endpointDomain;
    final hasCustomFetcher = !identical(widget.fetcher, fetchMessages);
    if (hasCustomFetcher) {
      return widget.fetcher(domain: activeDomain, client: client);
    }
    return _repository.loadMessages(domain: activeDomain);
  }

  @override
  Widget build(BuildContext context) {
    final seed = Colors.teal;
    final lightTheme = ThemeData(
      brightness: Brightness.light,
      colorScheme: ColorScheme.fromSeed(
        seedColor: seed,
        brightness: Brightness.light,
      ),
      useMaterial3: true,
      textTheme: const TextTheme(
        bodyMedium: TextStyle(
          fontFamily: 'monospace',
          fontSize: 13,
          height: 1.15,
        ),
      ),
    );
    final darkTheme = ThemeData(
      brightness: Brightness.dark,
      colorScheme: ColorScheme.fromSeed(
        seedColor: seed,
        brightness: Brightness.dark,
      ),
      useMaterial3: true,
      textTheme: const TextTheme(
        bodyMedium: TextStyle(
          fontFamily: 'monospace',
          fontSize: 13,
          height: 1.15,
        ),
      ),
    );

    return MaterialApp(
      title: '🥔 PotatoMesh Reader',
      debugShowCheckedModeBanner: false,
      scaffoldMessengerKey: _messengerKey,
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: _themeMode,
      home: FutureBuilder<BootstrapResult>(
        future: _bootstrapFuture,
        builder: (context, snapshot) {
          final effectiveResult = snapshot.data ?? _bootstrapResult;
          if (effectiveResult == null) {
            return LoadingScreen(
              progress: _progress,
              error: _lastError ?? snapshot.error,
            );
          }

          final domain = _repository.selectedDomain.isNotEmpty
              ? _repository.selectedDomain
              : effectiveResult.selectedDomain;
          final instanceName = _instanceNameFor(domain);
          final initialMessages = (effectiveResult.selectedDomain == domain)
              ? effectiveResult.messages
              : const <MeshMessage>[];
          return MessagesScreen(
            key: ValueKey<String>(domain),
            fetcher: _fetchMessagesForCurrentDomain,
            resetToken: _endpointVersion,
            domain: domain,
            repository: _repository,
            notificationClient: _notificationClient,
            instanceName: instanceName,
            enableAutoRefresh: widget.enableAutoRefresh,
            initialMessages: initialMessages,
            onOpenSettings: (context) {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: (_) => SettingsScreen(
                    currentDomain: _repository.selectedDomain.isNotEmpty
                        ? _repository.selectedDomain
                        : domain,
                    onDomainChanged: _handleEndpointChanged,
                    loadInstances: ({bool refresh = false}) =>
                        _loadInstances(refresh: refresh),
                    themeMode: _themeMode,
                    onThemeChanged: _handleThemeChanged,
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

/// Splash-style loading view shown while federation data is hydrated.
class LoadingScreen extends StatelessWidget {
  const LoadingScreen({
    super.key,
    required this.progress,
    this.error,
  });

  final BootstrapProgress progress;
  final Object? error;

  @override
  Widget build(BuildContext context) {
    final label = error != null
        ? 'Failed to load: $error'
        : (progress.label.isNotEmpty ? progress.label : 'Loading…');
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Padding(
              padding: const EdgeInsets.only(bottom: 24),
              child: Image.asset(
                'assets/icon-splash.png',
                height: 120,
                semanticLabel: 'PotatoMesh',
              ),
            ),
            const CircularProgressIndicator(),
            const SizedBox(height: 28),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 24),
              child: Text(
                label,
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Progress payload used to render bootstrap and domain load status.
class BootstrapProgress {
  const BootstrapProgress({
    required this.stage,
    this.current,
    this.total,
    this.detail,
  });

  final String stage;
  final int? current;
  final int? total;
  final String? detail;

  /// Human-friendly label summarising the current progress state.
  String get label {
    final buffer = StringBuffer(stage);
    if (current != null && total != null && total! > 0) {
      buffer.write(' ${current!}/${total!}');
    }
    if (detail != null && detail!.isNotEmpty) {
      buffer.write(' • ${detail!}');
    }
    return buffer.toString();
  }
}

/// Result container returned once federation, nodes, and messages are loaded.
class BootstrapResult {
  const BootstrapResult({
    required this.instances,
    required this.nodes,
    required this.messages,
    required this.selectedDomain,
  });

  final List<MeshInstance> instances;
  final List<MeshNode> nodes;
  final List<MeshMessage> messages;
  final String selectedDomain;
}

/// Domain-level fetch outcome used when switching between instances.
class DomainLoadResult {
  const DomainLoadResult({
    required this.domain,
    required this.nodes,
    required this.messages,
  });

  final String domain;
  final List<MeshNode> nodes;
  final List<MeshMessage> messages;
}

typedef ProgressCallback = void Function(BootstrapProgress progress);

/// Thin wrapper around [SharedPreferences] used to persist federation data.
class MeshLocalStore {
  MeshLocalStore(this._prefs);

  final SharedPreferences _prefs;

  static const String _instancesKey = 'mesh.instances';
  static const String _selectedDomainKey = 'mesh.selectedDomain';
  static const String _lastSeenKey = 'mesh.lastSeen';

  String _safeKey(String domain) {
    final base = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
    return base.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_');
  }

  Future<void> saveInstances(List<MeshInstance> instances) async {
    final encoded = jsonEncode(instances.map((i) => i.toJson()).toList());
    await _prefs.setString(_instancesKey, encoded);
  }

  List<MeshInstance> loadInstances() {
    final raw = _prefs.getString(_instancesKey);
    if (raw == null || raw.isEmpty) return const [];
    try {
      final dynamic decoded = jsonDecode(raw);
      if (decoded is! List) return const [];
      return decoded
          .whereType<Map<String, dynamic>>()
          .map(MeshInstance.fromJson)
          .where((instance) => instance.domain.isNotEmpty)
          .toList();
    } catch (_) {
      return const [];
    }
  }

  Future<void> saveSelectedDomain(String domain) async {
    await _prefs.setString(_selectedDomainKey, domain.trim());
  }

  String? loadSelectedDomain() {
    return _prefs.getString(_selectedDomainKey);
  }

  Future<void> saveNodes(String domain, List<MeshNode> nodes) async {
    final encoded = jsonEncode(nodes.map((n) => n.toJson()).toList());
    await _prefs.setString('mesh.nodes.${_safeKey(domain)}', encoded);
  }

  List<MeshNode> loadNodes(String domain) {
    final raw = _prefs.getString('mesh.nodes.${_safeKey(domain)}');
    if (raw == null || raw.isEmpty) return const [];
    try {
      final dynamic decoded = jsonDecode(raw);
      if (decoded is! List) return const [];
      return decoded
          .whereType<Map<String, dynamic>>()
          .map(MeshNode.fromJson)
          .where((node) => node.nodeId.isNotEmpty)
          .toList();
    } catch (_) {
      return const [];
    }
  }

  Future<void> saveMessages(String domain, List<MeshMessage> messages) async {
    final trimmed = messages.length > 1200
        ? messages.sublist(messages.length - 1200)
        : messages;
    final encoded = jsonEncode(trimmed.map((m) => m.toJson()).toList());
    await _prefs.setString('mesh.messages.${_safeKey(domain)}', encoded);
  }

  List<MeshMessage> loadMessages(String domain) {
    final raw = _prefs.getString('mesh.messages.${_safeKey(domain)}');
    if (raw == null || raw.isEmpty) return const [];
    try {
      final dynamic decoded = jsonDecode(raw);
      if (decoded is! List) return const [];
      return decoded
          .whereType<Map<String, dynamic>>()
          .map(MeshMessage.fromJson)
          .toList();
    } catch (_) {
      return const [];
    }
  }

  Future<void> saveLastSeenMessageKey(String domain, String key) async {
    await _prefs.setString('$_lastSeenKey.${_safeKey(domain)}', key);
  }

  String? loadLastSeenMessageKey(String domain) {
    return _prefs.getString('$_lastSeenKey.${_safeKey(domain)}');
  }
}

/// Provider used by [NodeShortNameCache] to resolve cached node metadata.
abstract class MeshNodeResolver {
  MeshNode? findNode(String domain, String nodeId);
}

/// Repository responsible for federation discovery, caching, and persistence.
class MeshRepository implements MeshNodeResolver {
  MeshRepository({
    SharedPreferences? prefs,
    http.Client? client,
    Random? random,
  })  : _prefs = prefs,
        _client = client,
        _random = random ?? Random();

  SharedPreferences? _prefs;
  MeshLocalStore? _store;
  MessageSeenTracker? _tracker;
  final http.Client? _client;
  final Random _random;

  final Map<String, List<MeshNode>> _nodesByDomain = {};
  final Map<String, List<MeshMessage>> _messagesByDomain = {};
  final Map<String, bool> _messagesLoaded = {};
  final Map<String, Set<String>> _nodeFetchInFlight = {};
  List<MeshInstance> _instances = const [];
  String _selectedDomain = 'potatomesh.net';

  List<MeshInstance> get instances => _instances;
  String get selectedDomain => _selectedDomain;

  Future<MeshLocalStore> _ensureStore() async {
    if (_store != null) return _store!;
    _prefs ??= await SharedPreferences.getInstance();
    _store = MeshLocalStore(_prefs!);
    return _store!;
  }

  /// Returns the last selected domain, defaulting to the provided fallback.
  Future<String> loadSelectedDomainOrDefault(
      {String fallback = 'potatomesh.net'}) async {
    final store = await _ensureStore();
    final cached = store.loadSelectedDomain();
    _selectedDomain = (cached != null && cached.isNotEmpty) ? cached : fallback;
    return _selectedDomain;
  }

  Future<MessageSeenTracker> _ensureTracker() async {
    if (_tracker != null) return _tracker!;
    final store = await _ensureStore();
    _tracker = MessageSeenTracker(store);
    return _tracker!;
  }

  /// Persist the selected domain choice without performing network calls.
  Future<void> rememberSelectedDomain(String domain) async {
    _selectedDomain = _domainKey(domain);
    final store = await _ensureStore();
    await store.saveSelectedDomain(_selectedDomain);
  }

  String _domainKey(String domain) {
    var cleaned = domain.trim();
    if (cleaned.isEmpty) return 'potatomesh.net';
    cleaned = cleaned.toLowerCase();
    if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
    if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
    if (cleaned.endsWith('/')) {
      cleaned = cleaned.substring(0, cleaned.length - 1);
    }
    if (cleaned.isEmpty) return 'potatomesh.net';
    return cleaned;
  }

  /// Kicks off the full bootstrap flow including federation discovery, node
  /// validation, and initial message downloads.
  Future<BootstrapResult> bootstrap({
    String initialDomain = 'potatomesh.net',
    ProgressCallback? onProgress,
  }) async {
    final store = await _ensureStore();
    final cachedInstances = store.loadInstances();
    final hasCachedInstances = cachedInstances.isNotEmpty;
    if (hasCachedInstances) {
      _instances = cachedInstances;
    }

    final cachedDomain = store.loadSelectedDomain();
    _selectedDomain = (cachedDomain != null && cachedDomain.isNotEmpty)
        ? cachedDomain
        : initialDomain;

    final httpClient = _client ?? http.Client();
    final shouldCloseClient = _client == null;

    if (!hasCachedInstances) {
      final discovered = await _discoverInstances(
        client: httpClient,
        onProgress: onProgress,
      );
      final validated = await _validateInstances(
        discovered,
        httpClient,
        onProgress,
      );
      if (validated.isNotEmpty) {
        _instances = validated;
        await store.saveInstances(validated);
      }
    }

    _selectedDomain = _resolveSelectedDomain(_instances, _selectedDomain);
    await store.saveSelectedDomain(_selectedDomain);

    // Hydrate caches from storage before hitting the network so the UI has
    // something to render if connectivity is constrained.
    final cachedNodes = store.loadNodes(_selectedDomain);
    if (cachedNodes.isNotEmpty) {
      final key = _domainKey(_selectedDomain);
      _nodesByDomain[key] = cachedNodes;
      NodeShortNameCache.instance
          .prime(domain: _selectedDomain, nodes: cachedNodes);
    }
    final cachedMessages = store.loadMessages(_selectedDomain);
    if (cachedMessages.isNotEmpty) {
      _messagesByDomain[_domainKey(_selectedDomain)] = cachedMessages;
      _messagesLoaded[_domainKey(_selectedDomain)] = true;
    }

    final domainResult = await _loadFirstResponsiveInstance(
      preferredDomain: _selectedDomain,
      candidates: _instances,
      httpClient: httpClient,
      onProgress: onProgress,
    );

    if (shouldCloseClient) {
      httpClient.close();
    }

    return BootstrapResult(
      instances: _instances,
      nodes: domainResult.nodes,
      messages: domainResult.messages,
      selectedDomain: _selectedDomain,
    );
  }

  /// Loads nodes and messages for a domain, persisting the selection.
  Future<DomainLoadResult> loadDomainData({
    required String domain,
    ProgressCallback? onProgress,
    http.Client? httpClient,
    bool forceFull = false,
  }) async {
    final store = await _ensureStore();
    final targetDomain =
        domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();

    final client = httpClient ?? _client ?? http.Client();
    final shouldClose = httpClient == null && _client == null;

    try {
      final nodes = await _fetchNodesList(
        domain: targetDomain,
        client: client,
        persist: true,
        useCacheWhenAvailable: !forceFull,
        onProgress: onProgress,
      );

      final messages = await _loadMessagesInternal(
        domain: targetDomain,
        client: client,
        forceFull: forceFull,
        onProgress: onProgress,
      );

      _selectedDomain = targetDomain;
      await store.saveSelectedDomain(_selectedDomain);

      return DomainLoadResult(
        domain: targetDomain,
        nodes: nodes,
        messages: messages,
      );
    } finally {
      if (shouldClose) {
        client.close();
      }
    }
  }

  /// Fetches messages, using a full sync initially and incremental loads later.
  Future<List<MeshMessage>> loadMessages({required String domain}) async {
    await _ensureStore();
    final key = _domainKey(domain);
    final loaded = _messagesLoaded[key] ?? false;
    final client = _client ?? http.Client();
    final shouldClose = _client == null;
    try {
      // Ensure cached data is available for immediate rendering.
      if (!loaded && !_messagesByDomain.containsKey(key)) {
        final cached = _store?.loadMessages(domain) ?? const [];
        if (cached.isNotEmpty) {
          _messagesByDomain[key] = cached;
        }
      }
      return _loadMessagesInternal(
        domain: domain,
        client: client,
        forceFull: !loaded,
      );
    } finally {
      if (shouldClose) {
        client.close();
      }
    }
  }

  /// Returns any messages that arrived after the last recorded entry for a domain.
  Future<List<MeshMessage>> detectUnseenMessages({
    required String domain,
    required List<MeshMessage> messages,
  }) async {
    final tracker = await _ensureTracker();
    return tracker.unseenSince(domain: domain, messages: messages);
  }

  /// Stores a nodes snapshot for quick lookup without refetching mid-session.
  Future<List<MeshNode>> loadNodes({required String domain}) async {
    await _ensureStore();
    final key = _domainKey(domain);
    if (_nodesByDomain.containsKey(key)) {
      return _nodesByDomain[key]!;
    }

    final cached = _store?.loadNodes(domain) ?? const [];
    if (cached.isNotEmpty) {
      _nodesByDomain[key] = cached;
      NodeShortNameCache.instance.prime(domain: domain, nodes: cached);
      return cached;
    }

    final client = _client ?? http.Client();
    final shouldClose = _client == null;
    try {
      return _fetchNodesList(
        domain: domain,
        client: client,
        persist: true,
        useCacheWhenAvailable: true,
      );
    } finally {
      if (shouldClose) {
        client.close();
      }
    }
  }

  /// Public entry point for fetching and caching federation instances.
  Future<List<MeshInstance>> discoverInstances({
    http.Client? client,
    ProgressCallback? onProgress,
  }) async {
    final store = await _ensureStore();
    final cached = store.loadInstances();
    final httpClient = client ?? _client ?? http.Client();
    final shouldClose = client == null && _client == null;
    try {
      final discovered = await _discoverInstances(
        client: httpClient,
        onProgress: onProgress,
      );
      final validated = await _validateInstances(
        discovered,
        httpClient,
        onProgress,
      );
      if (validated.isNotEmpty) {
        _instances = validated;
        await store.saveInstances(validated);
        return validated;
      }
      if (cached.isNotEmpty) return cached;
      return discovered;
    } finally {
      if (shouldClose) {
        httpClient.close();
      }
    }
  }

  /// Overwrites the cached instances and persists them to local storage.
  Future<void> updateInstances(List<MeshInstance> instances) async {
    _instances = instances;
    final store = await _ensureStore();
    await store.saveInstances(instances);
  }

  @override
  MeshNode? findNode(String domain, String nodeId) {
    final key = _domainKey(domain);
    final nodes = _nodesByDomain[key];
    if (nodes == null) return null;
    final trimmed = nodeId.trim();
    for (final node in nodes) {
      if (_matchesNodeId(node.nodeId, trimmed)) {
        return node;
      }
    }
    return null;
  }

  Future<List<MeshInstance>> _discoverInstances({
    required http.Client client,
    ProgressCallback? onProgress,
  }) async {
    final seen = <String>{};
    final queue = Queue<String>();
    final results = <MeshInstance>[];

    Future<void> enqueueFromDomain(String domain) async {
      try {
        final uri = _buildInstancesUri(domain);
        _logHttp('GET $uri');
        final resp = await client.get(uri).timeout(_requestTimeout);
        _logHttp('HTTP ${resp.statusCode} $uri');
        if (resp.statusCode != 200) return;
        final decoded = await _decodeJsonList(resp.body);
        final parsed = decoded
            .map(MeshInstance.fromJson)
            .where((instance) => instance.domain.isNotEmpty)
            .toList();
        for (final instance in parsed) {
          final key = _domainKey(instance.domain);
          if (seen.contains(key)) continue;
          seen.add(key);
          results.add(instance);
          queue.add(instance.domain);
        }
      } catch (_) {
        // Skip unreachable domains during discovery.
      }
    }

    onProgress?.call(const BootstrapProgress(stage: 'loading instances'));
    await enqueueFromDomain('potatomesh.net');

    while (queue.isNotEmpty) {
      final domain = queue.removeFirst();
      onProgress?.call(
        BootstrapProgress(
          stage: 'discovering instances',
          current: results.length,
          total: null,
          detail: domain,
        ),
      );
      await enqueueFromDomain(domain);
    }

    final deduped = <String, MeshInstance>{};
    for (final instance in results) {
      final key = _domainKey(instance.domain);
      if (instance.isPrivate) continue;
      deduped[key] = instance;
    }

    final list = deduped.values.toList()
      ..sort((a, b) =>
          a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
    return list;
  }

  Future<List<MeshInstance>> _validateInstances(
    List<MeshInstance> candidates,
    http.Client client,
    ProgressCallback? onProgress,
  ) async {
    if (candidates.isEmpty) return const [];
    final now = DateTime.now().toUtc();
    final valid = <MeshInstance>[];
    final total = candidates.length;
    for (var i = 0; i < candidates.length; i++) {
      final candidate = candidates[i];
      onProgress?.call(
        BootstrapProgress(
          stage: 'verifying instances',
          current: i + 1,
          total: total,
          detail: candidate.domain,
        ),
      );
      try {
        final nodes = await _fetchNodesList(
          domain: candidate.domain,
          client: client,
          limit: 200,
          persist: false,
          useCacheWhenAvailable: false,
        );
        final active = nodes
            .where((node) => node.isActive(const Duration(hours: 24), now))
            .toList();
        if (active.length >= 10) {
          valid.add(candidate);
        }
      } catch (_) {
        // Invalid instance; skip.
      }
    }
    return valid.isNotEmpty ? valid : candidates;
  }

  Future<DomainLoadResult> _loadFirstResponsiveInstance({
    required String preferredDomain,
    required List<MeshInstance> candidates,
    required http.Client httpClient,
    ProgressCallback? onProgress,
  }) async {
    final store = await _ensureStore();
    final ordered = <String>{
      preferredDomain,
      ...candidates.map((c) => c.domain)
    };
    DomainLoadResult? result;
    Object? lastError;
    for (final domain in ordered) {
      try {
        result = await loadDomainData(
          domain: domain,
          onProgress: onProgress,
          httpClient: httpClient,
          forceFull: true,
        );
        break;
      } catch (error) {
        lastError = error;
        continue;
      }
    }

    if (result != null) {
      return result;
    }

    final cachedNodes = store.loadNodes(preferredDomain);
    final cachedMessages = store.loadMessages(preferredDomain);
    if (cachedNodes.isNotEmpty || cachedMessages.isNotEmpty) {
      _selectedDomain = preferredDomain;
      await store.saveSelectedDomain(_selectedDomain);
      return DomainLoadResult(
        domain: preferredDomain,
        nodes: cachedNodes,
        messages: cachedMessages,
      );
    }

    throw lastError ?? Exception('No responsive instances');
  }

  String _resolveSelectedDomain(
    List<MeshInstance> available,
    String desired,
  ) {
    if (available.isEmpty) {
      return desired.trim().isNotEmpty ? desired.trim() : 'potatomesh.net';
    }
    final desiredKey = _domainKey(desired);
    for (final instance in available) {
      if (_domainKey(instance.domain) == desiredKey) {
        return instance.domain;
      }
    }
    return available[_random.nextInt(available.length)].domain;
  }

  Future<List<MeshNode>> _fetchNodesList({
    required String domain,
    required http.Client client,
    int limit = 1000,
    bool persist = true,
    bool useCacheWhenAvailable = true,
    ProgressCallback? onProgress,
  }) async {
    final key = _domainKey(domain);
    if (useCacheWhenAvailable && _nodesByDomain.containsKey(key)) {
      return _nodesByDomain[key]!;
    }
    final uri = _buildNodesUri(domain, limit: limit);
    _logHttp('GET $uri');
    final resp = await client.get(uri).timeout(_requestTimeout);
    _logHttp('HTTP ${resp.statusCode} $uri');
    if (resp.statusCode != 200) {
      throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
    }
    final decoded = await _decodeJsonList(resp.body);
    final nodes = <MeshNode>[];
    var index = 0;
    for (final entry in decoded) {
      index += 1;
      final node = MeshNode.fromJson(entry);
      if (node.nodeId.isEmpty) continue;
      nodes.add(node);
      onProgress?.call(
        BootstrapProgress(
          stage: 'loading nodes',
          current: index,
          total: decoded.length,
          detail: domain,
        ),
      );
    }

    if (persist) {
      _nodesByDomain[key] = nodes;
      NodeShortNameCache.instance.prime(domain: domain, nodes: nodes);
      await _store?.saveNodes(domain, nodes);
    }

    return nodes;
  }

  Future<List<MeshMessage>> _loadMessagesInternal({
    required String domain,
    required http.Client client,
    bool forceFull = false,
    ProgressCallback? onProgress,
  }) async {
    final key = _domainKey(domain);
    final alreadyLoaded = _messagesLoaded[key] ?? false;
    final initialFetch = forceFull || !alreadyLoaded;
    final since = initialFetch ? 0 : _latestRxTimeSeconds(domain);
    const limit = 1000;

    final uri = _buildMessagesUri(domain, since: since, limit: limit);
    _logHttp('GET $uri');
    final resp = await client.get(uri).timeout(_requestTimeout);
    _logHttp('HTTP ${resp.statusCode} $uri');
    if (resp.statusCode != 200) {
      throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
    }
    final decoded = await _decodeJsonList(resp.body);

    final messages = <MeshMessage>[];
    var index = 0;
    for (final entry in decoded) {
      index += 1;
      final message = MeshMessage.fromJson(entry);
      messages.add(message);
      onProgress?.call(
        BootstrapProgress(
          stage: 'loading messages',
          current: index,
          total: decoded.length,
          detail: domain,
        ),
      );
    }

    final merged = _mergeMessages(domain, messages);
    _messagesLoaded[key] = true;
    await _store?.saveMessages(domain, merged);

    // Ensure new senders are cached locally for name lookups.
    await _hydrateMissingNodes(
        domain: domain, messages: messages, client: client);

    return merged;
  }

  List<MeshMessage> _mergeMessages(String domain, List<MeshMessage> incoming) {
    final key = _domainKey(domain);
    final existing = List<MeshMessage>.from(_messagesByDomain[key] ?? const []);
    final seen = existing.map(MessageSeenTracker.messageKey).toSet();
    for (final msg in incoming) {
      final key = MessageSeenTracker.messageKey(msg);
      if (seen.contains(key)) {
        continue;
      }
      existing.add(msg);
      seen.add(key);
    }
    final sorted = sortMessagesByRxTime(existing);
    if (sorted.length > 1200) {
      sorted.removeRange(0, sorted.length - 1200);
    }
    _messagesByDomain[key] = sorted;
    return sorted;
  }

  int _latestRxTimeSeconds(String domain) {
    final key = _domainKey(domain);
    final messages = _messagesByDomain[key];
    if (messages == null || messages.isEmpty) return 0;

    var maxSeconds = 0;
    for (final message in messages) {
      final rxMillis = message.rxTime?.toUtc().millisecondsSinceEpoch;
      final candidate = message.rxTimeSeconds ??
          (rxMillis != null ? rxMillis ~/ 1000 : null) ??
          _coerceIsoSeconds(message.rxIso);
      if (candidate != null && candidate > maxSeconds) {
        maxSeconds = candidate;
      }
    }
    return maxSeconds;
  }

  int? _coerceIsoSeconds(String rxIso) {
    if (rxIso.isEmpty) return null;
    try {
      return DateTime.parse(rxIso).toUtc().millisecondsSinceEpoch ~/ 1000;
    } catch (_) {
      return null;
    }
  }

  Future<void> _hydrateMissingNodes({
    required String domain,
    required List<MeshMessage> messages,
    required http.Client client,
  }) async {
    final store = await _ensureStore();
    final key = _domainKey(domain);
    var nodes = List<MeshNode>.from(_nodesByDomain[key] ?? const []);
    if (nodes.isEmpty) {
      final cached = store.loadNodes(domain);
      if (cached.isNotEmpty) {
        nodes = List<MeshNode>.from(cached);
        _nodesByDomain[key] = nodes;
        NodeShortNameCache.instance.prime(domain: domain, nodes: cached);
      }
    }
    final knownIds = nodes.map((n) => _normalizeNodeId(n.nodeId)).toSet();
    final inFlight = _nodeFetchInFlight.putIfAbsent(key, () => {});
    for (final message in messages) {
      final rawNodeId = message.lookupNodeId.trim();
      final nodeId = _normalizeNodeId(rawNodeId);
      if (nodeId.isEmpty || knownIds.contains(nodeId)) continue;
      if (inFlight.contains(nodeId)) continue;
      inFlight.add(nodeId);
      try {
        final uri = _buildNodeUri(domain, rawNodeId);
        _logHttp('GET $uri');
        final resp = await client.get(uri).timeout(_requestTimeout);
        _logHttp('HTTP ${resp.statusCode} $uri');
        if (resp.statusCode != 200) {
          knownIds.add(nodeId);
          continue;
        }
        final decoded = await _decodeJsonMap(resp.body);
        final node = MeshNode.fromJson(decoded);
        if (node.nodeId.isEmpty) continue;
        nodes = List<MeshNode>.from(nodes)..add(node);
        _nodesByDomain[key] = nodes;
        NodeShortNameCache.instance.prime(domain: domain, nodes: [node]);
        await _store?.saveNodes(domain, nodes);
        knownIds.add(_normalizeNodeId(node.nodeId));
      } catch (_) {
        // Swallow node lookup errors during refresh.
      } finally {
        inFlight.remove(nodeId);
      }
    }
  }

  /// Resolves the best-effort sender names for notification payloads.
  NotificationSender resolveNotificationSender({
    required String domain,
    required MeshMessage message,
  }) {
    final node = findNode(domain, message.lookupNodeId);
    final trimmedLong = node?.longName.trim();
    final fallbackId =
        message.lookupNodeId.isNotEmpty ? message.lookupNodeId : message.fromId;
    final shortFromNode = node?.displayShortName.trim();
    final shortName = (shortFromNode != null && shortFromNode.isNotEmpty)
        ? shortFromNode
        : NodeShortNameCache.fallbackShortName(fallbackId);

    return NotificationSender(
      shortName: shortName,
      longName:
          trimmedLong != null && trimmedLong.isNotEmpty ? trimmedLong : null,
    );
  }

  bool _matchesNodeId(String existing, String candidate) {
    final cleanExisting = _normalizeNodeId(existing);
    final cleanCandidate = _normalizeNodeId(candidate);
    return cleanExisting.trim() == cleanCandidate.trim();
  }

  String _normalizeNodeId(String id) {
    return id.startsWith('!') ? id.substring(1) : id;
  }
}

/// Tracks and persists the most recently seen message per domain.
class MessageSeenTracker {
  MessageSeenTracker(this._store);

  final MeshLocalStore _store;

  /// Returns unseen messages that arrived after the last recorded message,
  /// while updating the persisted marker to the newest entry.
  Future<List<MeshMessage>> unseenSince({
    required String domain,
    required List<MeshMessage> messages,
  }) async {
    if (messages.isEmpty) return const [];

    final lastSeen = _store.loadLastSeenMessageKey(domain);
    final ordered = sortMessagesByRxTime(List<MeshMessage>.from(messages));
    final latestKey = messageKey(ordered.last);
    await _store.saveLastSeenMessageKey(domain, latestKey);

    if (lastSeen == null || lastSeen.isEmpty) {
      return const [];
    }

    var markerFound = false;
    final unseen = <MeshMessage>[];
    for (final message in ordered) {
      if (markerFound) {
        unseen.add(message);
        continue;
      }
      if (messageKey(message) == lastSeen) {
        markerFound = true;
      }
    }

    return markerFound ? unseen : const [];
  }

  /// Returns the stable key used to detect repeated messages.
  static String messageKey(MeshMessage msg) {
    return '${msg.id}-${msg.rxIso}-${msg.fromId}-${msg.text}';
  }
}

/// Displays the fetched mesh messages and supports pull-to-refresh.
class MessagesScreen extends StatefulWidget {
  const MessagesScreen({
    super.key,
    this.fetcher = fetchMessages,
    this.onOpenSettings,
    this.resetToken = 0,
    required this.domain,
    this.repository,
    this.initialMessages = const [],
    this.instanceName,
    this.enableAutoRefresh = true,
    this.notificationClient = const NoopNotificationClient(),
  });

  /// Fetch function used to load messages from the PotatoMesh API.
  final Future<List<MeshMessage>> Function() fetcher;

  /// Handler invoked when the settings icon is tapped.
  final void Function(BuildContext context)? onOpenSettings;

  /// Bumps when the endpoint changes to force a refresh of cached data.
  final int resetToken;

  /// Active endpoint domain used for auxiliary lookups like node metadata.
  final String domain;

  /// Optional repository backing persistence for this screen.
  final MeshRepository? repository;

  /// Messages obtained during the bootstrap phase to avoid re-fetching.
  final List<MeshMessage> initialMessages;

  /// Human-friendly name of the selected instance if the user picked one.
  final String? instanceName;

  /// Whether periodic background refresh is enabled.
  final bool enableAutoRefresh;

  /// Client used to deliver local notifications for unseen messages.
  final NotificationClient notificationClient;

  @override
  State<MessagesScreen> createState() => _MessagesScreenState();
}

class _MessagesScreenState extends State<MessagesScreen>
    with WidgetsBindingObserver {
  late Future<List<MeshMessage>> _future;
  List<MeshMessage> _messages = const [];
  final ScrollController _scrollController = ScrollController();
  Timer? _refreshTimer;
  bool _isForeground = true;
  int _fetchVersion = 0;
  late final NotificationClient _notificationClient;
  late final Future<void> _notificationReady;

  @override
  void initState() {
    super.initState();
    _notificationClient = widget.notificationClient;
    _notificationReady = _notificationClient.initialize();
    _messages = List<MeshMessage>.from(widget.initialMessages);
    _future = Future.value(_messages);
    _startFetch(clear: _messages.isEmpty);
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _refresh();
      _startAutoRefresh();
    });
  }

  /// When the fetcher changes, reload the future so the widget reflects the
  /// new data source on rebuilds.
  @override
  void didUpdateWidget(covariant MessagesScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.notificationClient != widget.notificationClient) {
      _notificationClient = widget.notificationClient;
      _notificationReady = _notificationClient.initialize();
    }
    if (oldWidget.fetcher != widget.fetcher ||
        oldWidget.resetToken != widget.resetToken ||
        oldWidget.enableAutoRefresh != widget.enableAutoRefresh) {
      _restartAutoRefresh();
      setState(() {
        _messages = List<MeshMessage>.from(widget.initialMessages);
        _future = Future.value(_messages);
      });
      _startFetch(clear: _messages.isEmpty);
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _refreshTimer?.cancel();
    _scrollController.dispose();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final nowForeground = state == AppLifecycleState.resumed ||
        state == AppLifecycleState.inactive;
    if (nowForeground != _isForeground) {
      _isForeground = nowForeground;
      if (_isForeground) {
        _refresh();
        _startAutoRefresh();
      } else {
        _refreshTimer?.cancel();
      }
    }
  }

  /// Reloads the message feed and waits for completion for pull-to-refresh.
  ///
  /// Errors are intentionally swallowed so the [FutureBuilder] can surface them
  /// via its `snapshot.error` state without bubbling an exception to the
  /// gesture handler.
  Future<void> _refresh({bool appendOnly = false}) async {
    await _startFetch(appendOnly: appendOnly);
  }

  void _appendMessages(List<MeshMessage> newMessages) {
    if (newMessages.isEmpty) return;
    final existingKeys = _messages.map(_messageKey).toSet();
    var added = 0;
    final combined = List<MeshMessage>.from(_messages);
    for (final msg in newMessages) {
      final key = _messageKey(msg);
      if (existingKeys.contains(key)) continue;
      combined.add(msg);
      existingKeys.add(key);
      added += 1;
    }
    if (added == 0 && _messages.isNotEmpty) {
      _scheduleScrollToBottom();
      return;
    }
    setState(() {
      _messages = combined;
    });
    _scheduleScrollToBottom();
  }

  String _messageKey(MeshMessage msg) {
    return MessageSeenTracker.messageKey(msg);
  }

  void _scheduleScrollToBottom({int retries = 5}) {
    if (retries <= 0) return;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!_scrollController.hasClients) {
        _scheduleScrollToBottom(retries: retries - 1);
        return;
      }
      _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
    });
  }

  void _startAutoRefresh() {
    if (!widget.enableAutoRefresh) return;
    _refreshTimer?.cancel();
    if (!_isForeground) return;
    _refreshTimer = Timer.periodic(
      const Duration(seconds: 60),
      (_) => _refresh(appendOnly: true),
    );
  }

  void _restartAutoRefresh() {
    if (_isForeground) {
      _startAutoRefresh();
    }
  }

  Future<void> _notifyUnseenMessages(List<MeshMessage> fetched) async {
    final repo = widget.repository;
    if (repo == null || fetched.isEmpty) return;
    try {
      final unseen = await repo.detectUnseenMessages(
        domain: widget.domain,
        messages: fetched,
      );
      if (_isForeground || unseen.isEmpty) {
        return;
      }
      await _notificationReady;
      for (final message in unseen) {
        final sender = repo.resolveNotificationSender(
          domain: widget.domain,
          message: message,
        );
        await _notificationClient.showNewMessage(
          message: message,
          domain: widget.domain,
          senderShortName: sender.shortName,
          senderLongName: sender.longName,
        );
      }
    } catch (error) {
      debugPrint('D/Notification error: $error');
    }
  }

  Future<void> _startFetch(
      {bool clear = false, bool appendOnly = false}) async {
    final version = ++_fetchVersion;
    final future = widget.fetcher();
    if (!appendOnly) {
      setState(() {
        if (clear) {
          _messages = const [];
        }
        _future = future;
      });
    }
    try {
      final msgs = await future;
      if (version != _fetchVersion) return;
      _appendMessages(msgs);
      await _notifyUnseenMessages(msgs);
    } catch (error) {
      if (appendOnly) {
        debugPrint('D/Failed to append messages: $error');
        return;
      }
      rethrow;
    }
  }

  String _dateLabelFor(MeshMessage message) {
    if (message.rxTime != null) {
      final local = message.rxTime!.toLocal();
      final y = local.year.toString().padLeft(4, '0');
      final m = local.month.toString().padLeft(2, '0');
      final d = local.day.toString().padLeft(2, '0');
      return '$y-$m-$d';
    }
    if (message.rxIso.isNotEmpty && message.rxIso.length >= 10) {
      return message.rxIso.substring(0, 10);
    }
    return 'Unknown';
  }

  Color _zebraColor(BuildContext context, int index) {
    final isEven = index.isEven;
    final brightness = Theme.of(context).brightness;
    if (brightness == Brightness.dark) {
      return isEven
          ? Colors.black
          : Color.lerp(Colors.black, Colors.white, 0.05)!;
    }
    return isEven
        ? Colors.white
        : Color.lerp(Colors.white, Colors.black, 0.05)!;
  }

  @override
  Widget build(BuildContext context) {
    final titleText =
        (widget.instanceName != null && widget.instanceName!.trim().isNotEmpty)
            ? '🥔 ${widget.instanceName!.trim()}'
            : '🥔 PotatoMesh Reader';
    return Scaffold(
      appBar: AppBar(
        leading: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 8),
          child: SvgPicture.asset(
            'assets/potatomesh-logo.svg',
            height: 28,
            semanticsLabel: 'PotatoMesh logo',
          ),
        ),
        title: Text(titleText),
        actions: [
          IconButton(
            tooltip: 'Refresh',
            icon: const Icon(Icons.refresh),
            onPressed: _refresh,
          ),
          IconButton(
            tooltip: 'Settings',
            icon: const Icon(Icons.settings),
            onPressed: () {
              if (widget.onOpenSettings != null) {
                widget.onOpenSettings!(context);
              }
            },
          ),
        ],
      ),
      body: FutureBuilder<List<MeshMessage>>(
        future: _future,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting &&
              _messages.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError && _messages.isEmpty) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Text(
                  'Failed to load messages:\n${snapshot.error}',
                  textAlign: TextAlign.center,
                ),
              ),
            );
          }
          final messages = _messages;
          if (messages.isEmpty) {
            return const Center(child: Text('No messages yet.'));
          }

          return RefreshIndicator(
            onRefresh: _refresh,
            child: ScrollConfiguration(
              behavior: const ScrollBehavior().copyWith(scrollbars: false),
              child: ListView.builder(
                controller: _scrollController,
                padding: EdgeInsets.zero,
                itemCount: messages.length,
                itemBuilder: (context, index) {
                  final msg = messages[index];
                  final currentLabel = _dateLabelFor(msg);
                  final prevLabel =
                      index > 0 ? _dateLabelFor(messages[index - 1]) : null;
                  final needsDivider =
                      prevLabel == null || currentLabel != prevLabel;
                  final zebraColor = _zebraColor(context, index);
                  final content = needsDivider
                      ? Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            DateDivider(label: currentLabel),
                            ChatLine(
                              message: msg,
                              domain: widget.domain,
                            ),
                          ],
                        )
                      : ChatLine(
                          message: msg,
                          domain: widget.domain,
                        );
                  return Container(color: zebraColor, child: content);
                },
              ),
            ),
          );
        },
      ),
    );
  }
}

/// Individual chat line styled in IRC-inspired format.
class ChatLine extends StatelessWidget {
  const ChatLine({
    super.key,
    required this.message,
    required this.domain,
  });

  static final Map<String, double> _indentCache = {};

  /// Message data to render.
  final MeshMessage message;
  final String domain;

  /// Generates a stable color from the nickname characters by hashing to a hue.
  Color _nickColor(String nick) {
    final h = nick.codeUnits.fold<int>(0, (a, b) => (a + b) % 360);
    return HSLColor.fromAHSL(1, h.toDouble(), 0.5, 0.6).toColor();
  }

  List<TextSpan> _buildLinkedSpans(
    String text,
    TextStyle baseStyle,
    TextStyle linkStyle,
  ) {
    final spans = <TextSpan>[];
    final urlPattern = RegExp(r'(https?:\/\/[^\s]+)');
    int start = 0;

    for (final match in urlPattern.allMatches(text)) {
      if (match.start > start) {
        spans.add(TextSpan(
          text: text.substring(start, match.start),
          style: baseStyle,
        ));
      }

      final urlText = match.group(0) ?? '';
      final uri = Uri.tryParse(urlText);
      spans.add(TextSpan(
        text: urlText,
        style: linkStyle,
        recognizer: TapGestureRecognizer()
          ..onTap = () async {
            if (uri != null) {
              await launchUrl(uri, mode: LaunchMode.externalApplication);
            }
          },
      ));
      start = match.end;
    }

    if (start < text.length) {
      spans.add(TextSpan(
        text: text.substring(start),
        style: baseStyle,
      ));
    }

    if (spans.isEmpty) {
      spans.add(TextSpan(text: text, style: baseStyle));
    }

    return spans;
  }

  String _fallbackShortName(String fromId) {
    return NodeShortNameCache.fallbackShortName(fromId);
  }

  double _computeIndentPixels(TextStyle baseStyle, BuildContext context) {
    final key =
        '${baseStyle.fontFamily}-${baseStyle.fontSize}-${baseStyle.fontWeight}-${baseStyle.fontStyle}';
    final cached = _indentCache[key];
    if (cached != null) return cached;
    final painter = TextPainter(
      text: TextSpan(text: ' ', style: baseStyle),
      textDirection: Directionality.of(context),
    )..layout();
    final width = painter.size.width * 8;
    _indentCache[key] = width;
    return width;
  }

  @override
  Widget build(BuildContext context) {
    final timeStr = '[${message.timeFormatted}]';
    final rawId = message.fromId.isNotEmpty ? message.fromId : '?';
    final lookupId =
        message.lookupNodeId.isNotEmpty ? message.lookupNodeId : rawId;
    final nick = rawId.startsWith('!') ? rawId : '!$rawId';
    final channel = '#${message.channelName ?? ''}'.trim();
    final bodyText = message.text.isEmpty ? '⟂ (no text)' : message.text;
    final colorScheme = Theme.of(context).colorScheme;
    final baseStyle = DefaultTextStyle.of(context)
        .style
        .copyWith(color: colorScheme.onSurface);
    final linkStyle = baseStyle.copyWith(
      color: colorScheme.tertiary,
      decoration: TextDecoration.underline,
    );
    final indentPx = _computeIndentPixels(baseStyle, context);

    return FutureBuilder<String>(
        future: NodeShortNameCache.instance.shortNameFor(
          domain: domain,
          nodeId: lookupId,
        ),
        builder: (context, snapshot) {
          final shortName = snapshot.data?.isNotEmpty == true
              ? snapshot.data!
              : _fallbackShortName(lookupId);
          final paddedShortName = NodeShortNameCache.padToWidth(shortName);
          return SelectionArea(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text.rich(
                  TextSpan(
                    children: [
                      TextSpan(
                        text: timeStr,
                        style: const TextStyle(
                          color: Colors.grey,
                          fontWeight: FontWeight.w500,
                        ),
                      ),
                      const TextSpan(text: ' '),
                      TextSpan(
                        text: '<$nick>',
                        style: TextStyle(
                          color: _nickColor(message.fromShort),
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      const TextSpan(text: ' '),
                      TextSpan(
                        text: '($paddedShortName)',
                        style: baseStyle.copyWith(
                          color: _nickColor(message.fromShort),
                          fontWeight: FontWeight.w600,
                        ),
                      ),
                      const TextSpan(text: ' '),
                      TextSpan(
                        text: channel,
                        style: TextStyle(
                            color: Theme.of(context).colorScheme.tertiary),
                      ),
                    ],
                    style: baseStyle,
                  ),
                ),
                Padding(
                  padding: EdgeInsets.only(left: indentPx),
                  child: SelectableText.rich(
                    TextSpan(
                      children: _buildLinkedSpans(
                        bodyText,
                        baseStyle,
                        linkStyle,
                      ),
                    ),
                  ),
                ),
              ],
            ),
          );
        });
  }
}

/// Bold, grey date divider between chat messages.
class DateDivider extends StatelessWidget {
  const DateDivider({super.key, required this.label});

  final String label;

  @override
  Widget build(BuildContext context) {
    final color = Theme.of(context).colorScheme.onSurfaceVariant;
    return Padding(
      padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
      child: Text(
        '-- $label --',
        style: TextStyle(
          fontWeight: FontWeight.w700,
          color: color,
        ),
      ),
    );
  }
}

/// MVP settings view offering endpoint selection and about info.
class SettingsScreen extends StatefulWidget {
  const SettingsScreen({
    super.key,
    required this.currentDomain,
    required this.onDomainChanged,
    this.loadInstances = _defaultInstanceLoader,
    this.themeMode = ThemeMode.system,
    this.onThemeChanged,
  });

  /// Currently selected endpoint domain.
  final String currentDomain;

  /// Callback fired when the user changes the endpoint.
  final ValueChanged<String> onDomainChanged;

  /// Loader used to fetch federation instance metadata.
  final Future<List<MeshInstance>> Function({bool refresh}) loadInstances;

  /// Current theme mode selection.
  final ThemeMode themeMode;

  /// Callback when the theme selection changes.
  final ValueChanged<ThemeMode>? onThemeChanged;

  static Future<List<MeshInstance>> _defaultInstanceLoader(
      {bool refresh = false}) {
    return fetchInstances();
  }

  @override
  State<SettingsScreen> createState() => _SettingsScreenState();
}

class _SettingsScreenState extends State<SettingsScreen> {
  static const String _defaultDomain = 'potatomesh.net';
  static const String _defaultName = 'BerlinMesh';
  List<MeshInstance> _instances = const [];
  bool _loading = false;
  String _selectedDomain = '';
  String? _error;
  String _versionLabel = '';
  Future<InstanceVersion?>? _instanceVersionFuture;
  late ThemeMode _selectedThemeMode;

  @override
  void initState() {
    super.initState();
    _selectedDomain = widget.currentDomain;
    _selectedThemeMode = widget.themeMode;
    _fetchInstances();
    _loadVersion();
    _instanceVersionFuture =
        InstanceVersionCache.instance.fetch(domain: _selectedDomain);
  }

  @override
  void didUpdateWidget(covariant SettingsScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.currentDomain != widget.currentDomain) {
      _selectedDomain = widget.currentDomain;
      _instanceVersionFuture =
          InstanceVersionCache.instance.fetch(domain: _selectedDomain);
    }
    if (oldWidget.themeMode != widget.themeMode) {
      _selectedThemeMode = widget.themeMode;
    }
  }

  Future<void> _fetchInstances({bool refresh = false}) async {
    setState(() {
      _loading = true;
      _error = null;
    });

    try {
      final fetched = await widget.loadInstances(refresh: refresh);
      if (!mounted) return;
      setState(() {
        _instances = fetched;
      });
    } catch (error) {
      if (!mounted) return;
      setState(() {
        _instances = const [];
        _error = error.toString();
      });
    } finally {
      if (mounted) {
        setState(() {
          _loading = false;
        });
      }
    }
  }

  Future<void> _loadVersion() async {
    try {
      final info = await PackageInfo.fromPlatform();
      final label = _composeGitAwareVersion(info);
      if (!mounted) return;
      setState(() {
        _versionLabel = label;
      });
    } catch (_) {
      if (!mounted) return;
      setState(() {
        _versionLabel = 'v0.0.0';
      });
    }
  }

  void _onEndpointChanged(String? domain) {
    if (domain == null || domain.isEmpty) {
      return;
    }

    setState(() {
      _selectedDomain = domain;
    });
    widget.onDomainChanged(domain);
    setState(() {
      _instanceVersionFuture =
          InstanceVersionCache.instance.fetch(domain: _selectedDomain);
    });
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('Endpoint set to $domain')),
    );
  }

  void _onThemeModeChanged(ThemeMode? mode) {
    if (mode == null) return;
    setState(() {
      _selectedThemeMode = mode;
    });
    widget.onThemeChanged?.call(mode);
  }

  List<DropdownMenuItem<String>> _buildEndpointOptions() {
    final seen = <String, String>{};
    void addOption(String domain, String name) {
      final key = domain.trim();
      if (key.isEmpty || seen.containsKey(key)) return;
      seen[key] = name;
    }

    addOption(_defaultDomain, _defaultName);
    for (final instance in _instances) {
      addOption(instance.domain, instance.displayName);
    }
    if (_selectedDomain.isNotEmpty && !seen.containsKey(_selectedDomain)) {
      addOption(_selectedDomain, 'Custom (${_selectedDomain.trim()})');
    }

    final sortedKeys = seen.keys.toList()
      ..sort(
          (a, b) => seen[a]!.toLowerCase().compareTo(seen[b]!.toLowerCase()));

    return sortedKeys
        .map(
          (domain) => DropdownMenuItem<String>(
            value: domain,
            child: Text(seen[domain]!),
          ),
        )
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    final endpointItems = _buildEndpointOptions();
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        children: [
          ListTile(
            leading: const Icon(Icons.cloud),
            title: const Text('Endpoint'),
            subtitle: Text('$_selectedDomain/api/messages'),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Expanded(
                      child: DropdownButtonFormField<String>(
                        key: ValueKey<String>(_selectedDomain),
                        initialValue: _selectedDomain.isNotEmpty
                            ? _selectedDomain
                            : _defaultDomain,
                        isExpanded: true,
                        decoration: const InputDecoration(
                          labelText: 'Select endpoint',
                          border: OutlineInputBorder(),
                        ),
                        items: endpointItems,
                        onChanged: _loading ? null : _onEndpointChanged,
                      ),
                    ),
                    const SizedBox(width: 8),
                    IconButton(
                      tooltip: 'Refresh instances',
                      icon: const Icon(Icons.refresh),
                      onPressed: _loading
                          ? null
                          : () => _fetchInstances(refresh: true),
                    ),
                  ],
                ),
                const SizedBox(height: 8),
                if (_loading)
                  const LinearProgressIndicator()
                else if (_error != null)
                  Text(
                    'Failed to load instances: $_error',
                    style: const TextStyle(color: Colors.redAccent),
                  )
                else if (_instances.isEmpty)
                  const Text('No federation instances returned.'),
              ],
            ),
          ),
          FutureBuilder<InstanceVersion?>(
            key: ValueKey<String>(_selectedDomain),
            future: _instanceVersionFuture,
            builder: (context, snapshot) {
              final info = snapshot.data;
              final domainDisplay = _selectedDomain.trim().isEmpty
                  ? 'potatomesh.net'
                  : _selectedDomain.trim();
              final domainUri = _buildDomainUrl(domainDisplay);
              Widget subtitle;
              if (snapshot.connectionState == ConnectionState.waiting) {
                subtitle = const Text('Loading version info…');
              } else if (info != null) {
                subtitle = Text(info.summary);
              } else {
                subtitle = const Text('Version info unavailable');
              }
              return ListTile(
                leading: const Icon(Icons.storage),
                title: const Text('Instance'),
                subtitle: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    subtitle,
                    const SizedBox(height: 4),
                    RichText(
                      text: TextSpan(
                        text: domainDisplay,
                        style: TextStyle(
                          color: Theme.of(context).colorScheme.primary,
                          decoration: TextDecoration.underline,
                        ),
                        recognizer: TapGestureRecognizer()
                          ..onTap = () async {
                            if (domainUri != null) {
                              await launchUrl(
                                domainUri,
                                mode: LaunchMode.externalApplication,
                              );
                            }
                          },
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
          const Divider(),
          const ListTile(
            leading: Icon(Icons.palette_outlined),
            title: Text('Theme'),
            subtitle: Text('Select preferred appearance'),
          ),
          Padding(
            padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
            child: DropdownButtonFormField<ThemeMode>(
              decoration: const InputDecoration(
                border: OutlineInputBorder(),
                labelText: 'Appearance',
              ),
              initialValue: _selectedThemeMode,
              items: const [
                DropdownMenuItem(
                  value: ThemeMode.system,
                  child: Text('System'),
                ),
                DropdownMenuItem(
                  value: ThemeMode.light,
                  child: Text('Light'),
                ),
                DropdownMenuItem(
                  value: ThemeMode.dark,
                  child: Text('Dark'),
                ),
              ],
              onChanged: _onThemeModeChanged,
            ),
          ),
          const Divider(),
          const ListTile(
            leading: Icon(Icons.info_outline),
            title: Text('About'),
            subtitle: Text(
                '🥔 PotatoMesh Reader - a read-only view of a selected Meshtastic region.'),
          ),
          ListTile(
            leading: const Icon(Icons.tag),
            title: const Text('Version'),
            subtitle: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(_versionLabel.isNotEmpty ? _versionLabel : 'Loading…'),
                const SizedBox(height: 4),
                RichText(
                  text: TextSpan(
                    text: 'github.com/l5yth/potato-mesh',
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.primary,
                      decoration: TextDecoration.underline,
                    ),
                    recognizer: TapGestureRecognizer()
                      ..onTap = () async {
                        final uri = Uri.parse(
                          'https://github.com/l5yth/potato-mesh/',
                        );
                        await launchUrl(
                          uri,
                          mode: LaunchMode.externalApplication,
                        );
                      },
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

/// --- Data layer ------------------------------------------------------------

/// Representation of a single mesh message returned by the PotatoMesh API.
class MeshMessage {
  final int id;
  final int? rxTimeSeconds;
  final DateTime? rxTime;
  final String rxIso;
  final String fromId;
  final String nodeId;
  final String toId;
  final int? channel;
  final String? channelName;
  final String portnum;
  final String text;
  final int? rssi;
  final double? snr;
  final int? hopLimit;

  /// Creates a [MeshMessage] with all properties parsed from the API response.
  MeshMessage({
    required this.id,
    this.rxTimeSeconds,
    required this.rxTime,
    required this.rxIso,
    required this.fromId,
    this.nodeId = '',
    required this.toId,
    required this.channel,
    required this.channelName,
    required this.portnum,
    required this.text,
    required this.rssi,
    required this.snr,
    required this.hopLimit,
  });

  /// Parses a [MeshMessage] from the raw JSON map returned by the API.
  factory MeshMessage.fromJson(Map<String, dynamic> json) {
    int? parsedSeconds;
    final rawSeconds = json['rx_time'];
    if (rawSeconds != null) {
      parsedSeconds = int.tryParse(rawSeconds.toString());
    }

    DateTime? parsedTime;
    if (json['rx_iso'] is String) {
      try {
        final parsedIso = DateTime.parse(json['rx_iso'] as String);
        parsedTime = parsedIso.toLocal();
        parsedSeconds ??= parsedIso.toUtc().millisecondsSinceEpoch ~/ 1000;
      } catch (_) {
        parsedTime = null;
      }
    }

    final parsedMillis = parsedTime?.toUtc().millisecondsSinceEpoch;
    if (parsedMillis != null && parsedSeconds == null) {
      parsedSeconds = parsedMillis ~/ 1000;
    }
    parsedTime ??= parsedSeconds != null
        ? DateTime.fromMillisecondsSinceEpoch(parsedSeconds * 1000, isUtc: true)
            .toLocal()
        : null;

    double? parseDouble(dynamic v) {
      if (v == null) return null;
      if (v is num) return v.toDouble();
      return double.tryParse(v.toString());
    }

    int? parseInt(dynamic v) {
      if (v == null) return null;
      if (v is int) return v;
      return int.tryParse(v.toString());
    }

    return MeshMessage(
      id: parseInt(json['id']) ?? 0,
      rxTimeSeconds: parsedSeconds,
      rxTime: parsedTime,
      rxIso: json['rx_iso']?.toString() ?? '',
      fromId: json['from_id']?.toString() ?? '',
      nodeId: json['node_id']?.toString() ?? '',
      toId: json['to_id']?.toString() ?? '',
      channel: parseInt(json['channel']),
      channelName: json['channel_name']?.toString(),
      portnum: json['portnum']?.toString() ?? '',
      text: json['text']?.toString() ?? '',
      rssi: parseInt(json['rssi']),
      snr: parseDouble(json['snr']),
      hopLimit: parseInt(json['hop_limit']),
    );
  }

  /// Formats the message time as HH:MM in local time.
  String get timeFormatted {
    if (rxTime == null) return '--:--';
    final h = rxTime!.hour.toString().padLeft(2, '0');
    final m = rxTime!.minute.toString().padLeft(2, '0');
    return '$h:$m';
  }

  /// Returns sender without a leading `!` prefix for display.
  String get fromShort {
    if (fromId.isEmpty) return '?';
    return fromId.startsWith('!') ? fromId.substring(1) : fromId;
  }

  /// Prefer the explicit node id when present, falling back to the sender id.
  String get lookupNodeId => nodeId.isNotEmpty ? nodeId : fromId;

  /// Serialises the message for persistence in local storage.
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'rx_time': rxTimeSeconds,
      'rx_iso': rxIso,
      'from_id': fromId,
      'node_id': nodeId,
      'to_id': toId,
      'channel': channel,
      'channel_name': channelName,
      'portnum': portnum,
      'text': text,
      'rssi': rssi,
      'snr': snr,
      'hop_limit': hopLimit,
    };
  }
}

/// Mesh federation instance metadata used to configure endpoints.
class MeshInstance {
  const MeshInstance({
    required this.name,
    required this.domain,
    this.id = '',
    this.isPrivate = false,
    this.lastUpdateTime,
  });

  /// Human-friendly instance name.
  final String name;

  /// Instance domain hosting the PotatoMesh API.
  final String domain;

  /// Unique identifier for the instance when provided by the API.
  final String id;

  /// True when the instance is marked as private and should be hidden.
  final bool isPrivate;

  /// Optional last update timestamp from the federation payload.
  final DateTime? lastUpdateTime;

  /// Prefer the provided name, falling back to the domain.
  String get displayName => name.isNotEmpty ? name : domain;

  /// Parse a [MeshInstance] from an API payload entry.
  factory MeshInstance.fromJson(Map<String, dynamic> json) {
    final domain = json['domain']?.toString().trim() ?? '';
    final name = json['name']?.toString().trim() ?? '';
    final id = json['id']?.toString().trim() ?? '';
    final isPrivateRaw = json['isPrivate'] ?? json['private'];
    final isPrivate = isPrivateRaw is bool
        ? isPrivateRaw
        : isPrivateRaw?.toString().toLowerCase() == 'true';
    DateTime? lastUpdate;
    final lastUpdateRaw = json['lastUpdateTime'];
    if (lastUpdateRaw != null) {
      final seconds = int.tryParse(lastUpdateRaw.toString());
      if (seconds != null) {
        lastUpdate =
            DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
      }
    }

    return MeshInstance(
      name: name,
      domain: domain,
      id: id,
      isPrivate: isPrivate,
      lastUpdateTime: lastUpdate,
    );
  }

  /// Serialize the instance for persistence.
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'name': name,
      'domain': domain,
      'isPrivate': isPrivate,
      'lastUpdateTime': lastUpdateTime != null
          ? lastUpdateTime!.millisecondsSinceEpoch ~/ 1000
          : null,
    };
  }
}

/// Node metadata persisted locally to avoid repeated network lookups.
class MeshNode {
  const MeshNode({
    required this.nodeId,
    this.shortName = '',
    this.longName = '',
    this.lastHeard,
    this.firstHeard,
    this.hwModel,
    this.latitude,
    this.longitude,
  });

  final String nodeId;
  final String shortName;
  final String longName;
  final DateTime? lastHeard;
  final DateTime? firstHeard;
  final String? hwModel;
  final double? latitude;
  final double? longitude;

  /// Returns a name suitable for chat rendering.
  String get displayShortName => shortName.isNotEmpty
      ? shortName
      : NodeShortNameCache.fallbackShortName(nodeId);

  /// Whether the node was heard within the provided freshness window.
  bool isActive(Duration freshness, DateTime nowUtc) {
    if (lastHeard == null) return false;
    final threshold = nowUtc.subtract(freshness);
    return lastHeard!.isAfter(threshold);
  }

  factory MeshNode.fromJson(Map<String, dynamic> json) {
    DateTime? parseSeconds(dynamic value) {
      if (value == null) return null;
      final seconds = int.tryParse(value.toString());
      if (seconds == null) return null;
      return DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true);
    }

    double? parseDouble(dynamic value) {
      if (value == null) return null;
      if (value is num) return value.toDouble();
      return double.tryParse(value.toString());
    }

    return MeshNode(
      nodeId: json['node_id']?.toString() ?? json['id']?.toString() ?? '',
      shortName:
          json['short_name']?.toString() ?? json['shortName']?.toString() ?? '',
      longName:
          json['long_name']?.toString() ?? json['longName']?.toString() ?? '',
      lastHeard:
          parseSeconds(json['last_heard']) ?? parseSeconds(json['lastSeen']),
      firstHeard: parseSeconds(json['first_heard']),
      hwModel: json['hw_model']?.toString(),
      latitude: parseDouble(json['latitude']),
      longitude: parseDouble(json['longitude']),
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'node_id': nodeId,
      'short_name': shortName,
      'long_name': longName,
      'last_heard':
          lastHeard != null ? lastHeard!.millisecondsSinceEpoch ~/ 1000 : null,
      'first_heard': firstHeard != null
          ? firstHeard!.millisecondsSinceEpoch ~/ 1000
          : null,
      'hw_model': hwModel,
      'latitude': latitude,
      'longitude': longitude,
    };
  }
}

/// Build a messages API URI for a given domain or absolute URL.
Uri _buildMessagesUri(String domain, {int since = 0, int limit = 1000}) {
  final trimmed = domain.trim();
  final params = {
    'limit': limit.toString(),
    'encrypted': 'false',
    'since': since.toString(),
  };
  if (trimmed.isEmpty) {
    return Uri.https('potatomesh.net', '/api/messages', params);
  }

  if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
    final parsed = Uri.parse(trimmed);
    return parsed.replace(
      path: '/api/messages',
      queryParameters: params,
    );
  }

  return Uri.https(trimmed, '/api/messages', params);
}

/// Build a node metadata API URI for a given domain.
Uri _buildNodeUri(String domain, String nodeId) {
  final trimmedDomain = domain.trim();
  final encodedId = Uri.encodeComponent(nodeId);

  if (trimmedDomain.isEmpty) {
    return Uri.https('potatomesh.net', '/api/nodes/$encodedId');
  }

  if (trimmedDomain.startsWith('http://') ||
      trimmedDomain.startsWith('https://')) {
    final parsed = Uri.parse(trimmedDomain);
    return parsed.replace(path: '/api/nodes/$encodedId');
  }

  return Uri.https(trimmedDomain, '/api/nodes/$encodedId');
}

/// Build the bulk nodes API URI for fetching recent nodes.
Uri _buildNodesUri(String domain, {int limit = 1000}) {
  final trimmedDomain = domain.trim();
  final params = {'limit': limit.toString()};

  if (trimmedDomain.isEmpty) {
    return Uri.https('potatomesh.net', '/api/nodes', params);
  }

  if (trimmedDomain.startsWith('http://') ||
      trimmedDomain.startsWith('https://')) {
    final parsed = Uri.parse(trimmedDomain);
    return parsed.replace(path: '/api/nodes', queryParameters: params);
  }

  return Uri.https(trimmedDomain, '/api/nodes', params);
}

/// Build a /version endpoint URI for a given domain.
Uri _buildVersionUri(String domain) {
  final trimmed = domain.trim();
  if (trimmed.isEmpty) {
    return Uri.https('potatomesh.net', '/version');
  }
  if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
    final parsed = Uri.parse(trimmed);
    return parsed.replace(path: '/version');
  }
  return Uri.https(trimmed, '/version');
}

/// Build an instances API URI for federation discovery.
Uri _buildInstancesUri(String domain) {
  final trimmed = domain.trim();
  if (trimmed.isEmpty || trimmed == 'potatomesh.net') {
    return Uri.https('potatomesh.net', '/api/instances');
  }
  if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
    final parsed = Uri.parse(trimmed);
    return parsed.replace(path: '/api/instances');
  }
  return Uri.https(trimmed, '/api/instances');
}

String _composeGitAwareVersion(PackageInfo info) {
  const versionDefine = _gitVersionEnv;
  if (versionDefine.isNotEmpty) {
    return versionDefine.startsWith('v') ? versionDefine : 'v$versionDefine';
  }

  const tagDefine = _gitTagEnv;
  if (tagDefine.isNotEmpty) {
    final tag = tagDefine.startsWith('v') ? tagDefine : 'v$tagDefine';
    final suffixParts = <String>[];
    const commitsDefine = _gitCommitsEnv;
    const shaDefine = _gitShaEnv;
    const dirtyDefine = _gitDirtyEnv;
    final commits = commitsDefine.trim();
    final sha = shaDefine.trim();
    final dirtyFlag = dirtyDefine.toLowerCase().trim();
    final dirty = dirtyFlag == 'true' || dirtyFlag == '1' || dirtyFlag == 'yes';

    if (commits.isNotEmpty && commits != '0') {
      suffixParts.add(commits);
      if (sha.isNotEmpty) {
        suffixParts.add(sha);
      }
    } else if (sha.isNotEmpty) {
      suffixParts.add(sha);
    }

    if (dirty) {
      if (suffixParts.isEmpty) {
        suffixParts.add('dirty');
      } else {
        suffixParts[suffixParts.length - 1] = '${suffixParts.last}-dirty';
      }
    }

    return suffixParts.isEmpty ? tag : '$tag+${suffixParts.join('-')}';
  }

  final base = 'v${info.version}';
  return info.buildNumber.isNotEmpty ? '$base+${info.buildNumber}' : base;
}

Uri? _buildDomainUrl(String domain) {
  final trimmed = domain.trim();
  if (trimmed.isEmpty) return null;
  final hasScheme =
      trimmed.startsWith('http://') || trimmed.startsWith('https://');
  final candidate = hasScheme ? trimmed : 'https://$trimmed';
  return Uri.tryParse(candidate);
}

/// Fetches the latest PotatoMesh messages and returns them sorted by receive time.
///
/// A custom [client] can be supplied for testing; otherwise a short-lived
/// [http.Client] is created and closed after the request completes.
Future<List<MeshMessage>> fetchMessages({
  http.Client? client,
  String domain = 'potatomesh.net',
  int since = 0,
}) async {
  final uri = _buildMessagesUri(domain, since: since, limit: 1000);
  _logHttp('GET $uri');

  final httpClient = client ?? http.Client();
  final shouldClose = client == null;

  final resp = await httpClient.get(uri).timeout(_requestTimeout);
  _logHttp('HTTP ${resp.statusCode} $uri');
  if (shouldClose) {
    httpClient.close();
  }
  if (resp.statusCode != 200) {
    throw Exception('HTTP ${resp.statusCode}: ${resp.body}');
  }

  final decoded = await _decodeJsonList(resp.body);
  final msgs = decoded.map(MeshMessage.fromJson).toList();

  return sortMessagesByRxTime(msgs);
}

/// Memoised loader for node short names sourced from the API.
class NodeShortNameCache {
  NodeShortNameCache._();

  /// Singleton instance used by chat line rendering.
  static final NodeShortNameCache instance = NodeShortNameCache._();

  MeshNodeResolver? _resolver;
  final Map<String, Future<String>> _cache = {};
  final Map<String, Map<String, String>> _primedShortNames = {};
  bool _allowRemoteLookups = true;

  /// Registers a resolver that can supply locally cached node metadata.
  void registerResolver(MeshNodeResolver resolver) {
    _resolver = resolver;
  }

  /// Clears memoised entries; primarily used in tests.
  void clear() {
    _cache.clear();
    _primedShortNames.clear();
  }

  /// Enables or disables remote lookups for short names.
  set allowRemoteLookups(bool enabled) {
    _allowRemoteLookups = enabled;
  }

  /// Seeds the cache with a batch of node metadata to avoid network calls.
  void prime({required String domain, required Iterable<MeshNode> nodes}) {
    final key = _normalizeDomainKey(domain);
    final map = _primedShortNames.putIfAbsent(key, () => {});
    for (final node in nodes) {
      final id = node.nodeId.trim();
      final name = node.shortName.trim();
      if (id.isEmpty || name.isEmpty) continue;
      map[id] = name;
    }
  }

  /// Resolve the short name for a node, defaulting to the fallback suffix.
  Future<String> shortNameFor({
    required String domain,
    required String nodeId,
    http.Client? client,
  }) {
    final trimmedId = nodeId.trim();
    final normalizedDomain = _normalizeDomainKey(domain);
    final normalizedId = _normalizeNodeId(trimmedId);
    final fallback = fallbackShortName(trimmedId);
    if (normalizedId.isEmpty) return Future.value(fallback);
    if (!_allowRemoteLookups) return Future.value(fallback);

    final domainKey = normalizedDomain;
    final primed = _primedShortNames[domainKey];
    if (primed != null) {
      final primedName =
          primed[trimmedId] ?? primed['!$trimmedId'] ?? primed[normalizedId];
      if (primedName != null && primedName.isNotEmpty) {
        return Future.value(padToWidth(primedName));
      }
    }

    final resolved = _resolver?.findNode(domainKey, trimmedId);
    if (resolved != null && resolved.displayShortName.isNotEmpty) {
      final name = resolved.displayShortName;
      _storePrimed(domainKey, trimmedId, name);
      return Future.value(padToWidth(name));
    }

    final key = '$domainKey|$normalizedId';
    if (_cache.containsKey(key)) {
      return _cache[key]!;
    }

    final future = _loadShortName(
      domain: domain,
      nodeId: trimmedId,
      fallback: fallback,
      client: client,
    );
    _cache[key] = future;
    return future;
  }

  Future<String> _loadShortName({
    required String domain,
    required String nodeId,
    required String fallback,
    http.Client? client,
  }) async {
    final normalizedDomain = _normalizeDomainKey(domain);
    final normalizedId = _normalizeNodeId(nodeId);
    final cacheKey = '$normalizedDomain|$normalizedId';
    if (_cache.containsKey(cacheKey)) {
      return _cache[cacheKey]!;
    }

    final uri = _buildNodeUri(domain, nodeId);
    final httpClient = client ?? http.Client();
    final shouldClose = client == null;

    try {
      _logHttp('GET $uri');
      final resp = await httpClient.get(uri).timeout(_requestTimeout);
      _logHttp('HTTP ${resp.statusCode} $uri');
      if (resp.statusCode != 200) return fallback;

      final decoded = await _decodeJsonMap(resp.body);
      final raw = decoded['short_name'] ?? decoded['shortName'];
      if (raw != null) {
        final name = raw.toString().trim();
        if (name.isNotEmpty) {
          _storePrimed(domain, nodeId, name);
          return padToWidth(name);
        }
      }

      return fallback;
    } catch (_) {
      return fallback;
    } finally {
      if (shouldClose) {
        httpClient.close();
      }
    }
  }

  /// Fallback that uses the trailing four characters of the node id.
  static String fallbackShortName(String fromId) {
    final trimmed = fromId.startsWith('!') ? fromId.substring(1) : fromId;
    if (trimmed.isEmpty) return '????';
    final suffix =
        trimmed.length <= 4 ? trimmed : trimmed.substring(trimmed.length - 4);
    return padToWidth(suffix);
  }

  /// Ensures the provided short name is at least [width] characters wide.
  static String padToWidth(String value, {int width = 4}) {
    if (value.length >= width) return value;
    return value.padLeft(width);
  }

  void _storePrimed(String domain, String nodeId, String name) {
    final domainKey = _normalizeDomainKey(domain);
    final map = _primedShortNames.putIfAbsent(domainKey, () => {});
    final trimmedId = nodeId.trim();
    final normalizedId = _normalizeNodeId(trimmedId);
    map[trimmedId] = name;
    map['!$normalizedId'] = name;
    map[normalizedId] = name;
  }

  String _normalizeDomainKey(String domain) {
    var cleaned = domain.trim();
    if (cleaned.startsWith('https://')) cleaned = cleaned.substring(8);
    if (cleaned.startsWith('http://')) cleaned = cleaned.substring(7);
    if (cleaned.endsWith('/')) {
      cleaned = cleaned.substring(0, cleaned.length - 1);
    }
    if (cleaned.isEmpty) return 'potatomesh.net';
    return cleaned.toLowerCase();
  }

  String _normalizeNodeId(String id) {
    final trimmed = id.trim();
    return trimmed.startsWith('!') ? trimmed.substring(1) : trimmed;
  }
}

/// Cached metadata describing an instance's public version payload.
class InstanceVersion {
  const InstanceVersion({
    required this.name,
    required this.channel,
    required this.frequency,
    required this.instanceDomain,
  });

  final String name;
  final String? channel;
  final String? frequency;
  final String? instanceDomain;

  String get summary {
    final parts = <String>[];
    if (name.isNotEmpty) parts.add(name);
    if (channel != null && channel!.isNotEmpty) parts.add(channel!);
    if (frequency != null && frequency!.isNotEmpty) parts.add(frequency!);
    return parts.isNotEmpty ? parts.join(' · ') : 'Unknown';
  }

  factory InstanceVersion.fromJson(Map<String, dynamic> json) {
    final config = json['config'] is Map<String, dynamic>
        ? json['config'] as Map<String, dynamic>
        : <String, dynamic>{};
    final siteName = config['siteName']?.toString().trim() ?? '';
    final name = (json['name']?.toString().trim() ?? '').isNotEmpty
        ? json['name'].toString().trim()
        : siteName;
    return InstanceVersion(
      name: name,
      channel: config['channel']?.toString().trim(),
      frequency: config['frequency']?.toString().trim(),
      instanceDomain: config['instanceDomain']?.toString().trim(),
    );
  }
}

/// Memoised loader for instance version payloads.
class InstanceVersionCache {
  InstanceVersionCache._();

  static final InstanceVersionCache instance = InstanceVersionCache._();

  final Map<String, Future<InstanceVersion?>> _cache = {};

  Future<InstanceVersion?> fetch({
    required String domain,
    http.Client? client,
  }) {
    final key = domain.trim().isEmpty ? 'potatomesh.net' : domain.trim();
    if (_cache.containsKey(key)) {
      return _cache[key]!;
    }
    final future = _load(key, client: client);
    _cache[key] = future;
    return future;
  }

  Future<InstanceVersion?> _load(
    String domain, {
    http.Client? client,
  }) async {
    final uri = _buildVersionUri(domain);
    final httpClient = client ?? http.Client();
    final shouldClose = client == null;
    try {
      _logHttp('GET $uri');
      final resp = await httpClient.get(uri).timeout(_requestTimeout);
      _logHttp('HTTP ${resp.statusCode} $uri');
      if (resp.statusCode != 200) return null;
      final decoded = await _decodeJsonMap(resp.body);
      return InstanceVersion.fromJson(decoded);
    } catch (_) {
      return null;
    } finally {
      if (shouldClose) {
        httpClient.close();
      }
    }
  }
}

/// Fetches and validates federation instances, persisting them locally.
Future<List<MeshInstance>> fetchInstances({http.Client? client}) {
  final repository = MeshRepository(client: client);
  return repository.discoverInstances(client: client);
}

/// Returns a new list sorted by receive time so older messages render first.
///
/// Messages that lack a receive time keep their original positions to avoid
/// shuffling "unknown" entries to the start or end of the feed. Only messages
/// with a concrete [rxTime] are re-ordered chronologically.
List<MeshMessage> sortMessagesByRxTime(List<MeshMessage> messages) {
  final knownTimes = messages.where((m) => m.rxTime != null).toList()
    ..sort((a, b) => a.rxTime!.compareTo(b.rxTime!));

  var knownIndex = 0;
  return messages.map((message) {
    if (message.rxTime == null) {
      return message;
    }

    final sortedMessage = knownTimes[knownIndex];
    knownIndex += 1;
    return sortedMessage;
  }).toList();
}
