import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';

import '../../utils/app_logger.dart';
import '../../utils/udp_broadcast_sockets.dart';
import 'remote_auth_context.dart';
import 'remote_auth_service.dart';

/// A host discovered on the LAN via UDP broadcast.
class DiscoveredHost {
  final String authContextId;
  final String clientId;
  final String name;
  final String platform;
  final int port;
  final List<String> ips;
  DateTime lastSeen;

  DiscoveredHost({
    required this.authContextId,
    required this.clientId,
    required this.name,
    required this.platform,
    required this.port,
    required this.ips,
    DateTime? lastSeen,
  }) : lastSeen = lastSeen ?? DateTime.now();

  List<String> get addresses => ips.map((ip) => '$ip:$port').toList();
}

/// UDP-based LAN discovery for companion remote.
///
/// Hosts broadcast authenticated beacons; clients listen and filter
/// by matching Plex home membership.
class LanDiscoveryService {
  static const int defaultDiscoveryPort = 48633;
  static const int _broadcastIntervalSeconds = 3;
  static const int _staleTimeoutSeconds = 10;
  static const int _beaconVersion = 1;

  /// UDP port used for both beacon targets and listener binding.
  final int discoveryPort;

  LanDiscoveryService({this.discoveryPort = defaultDiscoveryPort});
  // Broadcaster state (host)
  UdpBroadcastSocketSet? _broadcastSockets;
  Timer? _broadcastTimer;

  // Listener state (client)
  RawDatagramSocket? _listenSocket;
  StreamSubscription<RawSocketEvent>? _listenSubscription;
  Timer? _staleCleanupTimer;
  int _listenGeneration = 0;
  final Map<String, DiscoveredHost> _discoveredHosts = {};
  final _hostsController = StreamController<List<DiscoveredHost>>.broadcast();

  /// Whether the broadcaster is currently active.
  bool get isBroadcasting => _broadcastTimer != null;

  /// Whether the listener is currently active.
  bool get isListening => _listenSocket != null;

  // ── Host: Broadcasting ──

  Future<void> startBroadcastingForContexts({
    required List<RemoteAuthContext> contexts,
    required String deviceName,
    required String platform,
    required int wsPort,
    required List<String> ips,
  }) async {
    await stopBroadcasting();
    if (contexts.isEmpty) return;

    try {
      _broadcastSockets = await UdpBroadcastSockets.bind();
      if (_broadcastSockets!.isEmpty) return;

      appLogger.d('LanDiscovery: Broadcasting started on port $discoveryPort');

      // Send immediately, then periodically
      for (final context in contexts) {
        _sendBeacon(context, deviceName, platform, wsPort, ips);
      }
      _broadcastTimer = Timer.periodic(const Duration(seconds: _broadcastIntervalSeconds), (_) {
        for (final context in contexts) {
          _sendBeacon(context, deviceName, platform, wsPort, ips);
        }
      });
    } catch (e) {
      appLogger.e('LanDiscovery: Failed to start broadcasting', error: e);
      await stopBroadcasting();
    }
  }

  void _sendBeacon(RemoteAuthContext context, String deviceName, String platform, int wsPort, List<String> ips) {
    final broadcastSockets = _broadcastSockets;
    if (broadcastSockets == null || broadcastSockets.isEmpty) return;

    try {
      final auth = RemoteAuthService.instance;
      final discoveryKey = context.discoveryKey;
      final clientId = context.clientIdentifier;
      final homeHash = auth.computeDiscoveryTag(discoveryKey);

      final beaconHmac = auth.computeBeaconHmac(
        discoveryKey: discoveryKey,
        version: _beaconVersion,
        homeHash: homeHash,
        name: deviceName,
        platform: platform,
        clientId: clientId,
        port: wsPort,
        ips: ips,
      );

      final packet = jsonEncode({
        'app': 'plezy',
        'v': _beaconVersion,
        'homeHash': homeHash,
        'name': deviceName,
        'platform': platform,
        'clientId': clientId,
        'port': wsPort,
        'ips': ips,
        'hmac': beaconHmac,
      });

      final data = utf8.encode(packet);
      broadcastSockets.send(data, UdpBroadcastSockets.limitedBroadcastAddress, discoveryPort);
    } catch (e) {
      appLogger.e('LanDiscovery: Failed to send beacon', error: e);
    }
  }

  Future<void> stopBroadcasting() async {
    _broadcastTimer?.cancel();
    _broadcastTimer = null;
    await _broadcastSockets?.close();
    _broadcastSockets = null;
    appLogger.d('LanDiscovery: Broadcasting stopped');
  }

  // ── Client: Listening ──

  Stream<List<DiscoveredHost>> startListeningForContexts(List<RemoteAuthContext> contexts) {
    _stopListeningInternal();
    _discoveredHosts.clear();
    final generation = _listenGeneration;

    unawaited(_bindListener(contexts, generation));
    // Periodically remove stale hosts
    _staleCleanupTimer = Timer.periodic(const Duration(seconds: 2), (_) {
      final now = DateTime.now();
      final staleIds = <String>[];
      for (final entry in _discoveredHosts.entries) {
        if (now.difference(entry.value.lastSeen).inSeconds > _staleTimeoutSeconds) {
          staleIds.add(entry.key);
        }
      }
      if (staleIds.isNotEmpty) {
        for (final id in staleIds) {
          _discoveredHosts.remove(id);
        }
        _emitHosts();
      }
    });

    return _hostsController.stream;
  }

  Future<void> _bindListener(List<RemoteAuthContext> contexts, int generation) async {
    try {
      final socket = await RawDatagramSocket.bind(
        InternetAddress.anyIPv4,
        discoveryPort,
        reuseAddress: true,
        reusePort: true,
      );
      if (generation != _listenGeneration) {
        socket.close();
        return;
      }
      _listenSocket = socket;
      appLogger.d('LanDiscovery: Listening on port $discoveryPort');

      _listenSubscription = socket.listenDatagrams(
        (datagram) => _handleDatagram(datagram, contexts),
        debugLabel: 'LanDiscovery listener',
      );
    } catch (e) {
      if (generation == _listenGeneration) {
        appLogger.e('LanDiscovery: Failed to bind listener', error: e);
      }
    }
  }

  void _handleDatagram(Datagram datagram, List<RemoteAuthContext> contexts) {
    try {
      final packet = utf8.decode(datagram.data);
      final json = jsonDecode(packet) as Map<String, dynamic>;

      if (json['app'] != 'plezy') return;

      final version = json['v'] as int? ?? 0;
      final homeHash = json['homeHash'] as String? ?? '';
      final name = json['name'] as String? ?? '';
      final platform = json['platform'] as String? ?? '';
      final clientId = json['clientId'] as String? ?? '';
      final port = json['port'] as int? ?? 0;
      final ips = (json['ips'] as List<dynamic>?)?.cast<String>() ?? [];
      final hmac = json['hmac'] as String? ?? '';

      final auth = RemoteAuthService.instance;
      RemoteAuthContext? matchedContext;
      for (final context in contexts) {
        final discoveryKey = context.discoveryKey;
        if (!auth.verifyBeaconHmac(
          receivedHmac: hmac,
          discoveryKey: discoveryKey,
          version: version,
          homeHash: homeHash,
          name: name,
          platform: platform,
          clientId: clientId,
          port: port,
          ips: ips,
        )) {
          continue;
        }
        if (!auth.matchesDiscoveryTag(homeHash, discoveryKey)) {
          continue;
        }
        matchedContext = context;
        break;
      }
      if (matchedContext == null) {
        return; // Different home
      }

      // Valid beacon from same home. Normalize only after authentication so
      // stored endpoint order matches the canonical HMAC representation.
      final normalizedIps = List<String>.from(ips)..sort();
      final lastSeen = DateTime.now();
      final hostKey = clientId;
      final existing = _discoveredHosts[hostKey];
      if (existing != null) {
        final hostChanged =
            existing.name != name ||
            existing.platform != platform ||
            existing.port != port ||
            !const ListEquality<String>().equals(existing.ips, normalizedIps);
        if (hostChanged) {
          _discoveredHosts[hostKey] = DiscoveredHost(
            authContextId: existing.authContextId,
            clientId: clientId,
            name: name,
            platform: platform,
            port: port,
            ips: normalizedIps,
            lastSeen: lastSeen,
          );
          _emitHosts();
        } else {
          existing.lastSeen = lastSeen;
        }
      } else {
        _discoveredHosts[hostKey] = DiscoveredHost(
          authContextId: matchedContext.id,
          clientId: clientId,
          name: name,
          platform: platform,
          port: port,
          ips: normalizedIps,
          lastSeen: lastSeen,
        );
        appLogger.d('LanDiscovery: Discovered host: $name ($platform) at ${normalizedIps.join(", ")}:$port');
        _emitHosts();
      }
    } catch (e) {
      // Ignore malformed packets
    }
  }

  void _emitHosts() {
    _hostsController.add(_discoveredHosts.values.toList());
  }

  void stopListening() {
    _stopListeningInternal();
    _discoveredHosts.clear();
    _emitHosts();
  }

  void _stopListeningInternal() {
    ++_listenGeneration;
    _staleCleanupTimer?.cancel();
    _staleCleanupTimer = null;
    _listenSubscription?.cancel();
    _listenSubscription = null;
    _listenSocket?.close();
    _listenSocket = null;
    appLogger.d('LanDiscovery: Listening stopped');
  }

  void dispose() {
    stopBroadcasting();
    stopListening();
    _hostsController.close();
  }
}
