import 'dart:async';
import 'dart:typed_data';

import 'package:commons_core/commons_core.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../data/variety_repository.dart';
import '../services/offer_mapper.dart';
import '../services/offer_outbox.dart';
import '../services/offer_thumbnail.dart';
import '../services/social_connection.dart';

/// State of the offer discovery/publish screen. Transport-agnostic — it holds
/// only what the UI shows, never a relay handle.
class OffersState extends Equatable {
  const OffersState({
    this.offers = const [],
    this.areaGeohash = '',
    this.query = '',
    this.typeFilter = const {},
    this.categoryFilter = const {},
    this.organicOnly = false,
    this.searching = false,
    this.publishing = false,
    this.hasSearched = false,
    this.error,
  });

  /// Offers discovered so far for [areaGeohash], newest appended as they arrive.
  final List<Offer> offers;

  /// The coarse area currently being browsed.
  final String areaGeohash;

  /// Free-text filter over [offers] typed in the search box (empty = show all).
  final String query;

  /// Active reciprocity-mode filter (gift/exchange/sale/wanted); empty = all.
  final Set<OfferType> typeFilter;

  /// Active category filter (free-text categories); empty = all.
  final Set<String> categoryFilter;

  /// When true, show only offers the grower declared organic ("eco").
  final bool organicOnly;

  final bool searching;
  final bool publishing;

  /// True once a discovery has been started, so the UI can tell "no search yet"
  /// from "searched, found nothing".
  final bool hasSearched;

  /// Last error, in human terms for the UI (null when fine).
  final String? error;

  /// [offers] narrowed by the text [query] and the chip filters (all ANDed).
  /// Kept separate from [offers] so filtering never drops the discoveries.
  List<Offer> get visibleOffers {
    final q = query.trim().toLowerCase();
    return offers.where((o) {
      if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
      if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
      if (categoryFilter.isNotEmpty &&
          (o.category == null || !categoryFilter.contains(o.category))) {
        return false;
      }
      if (organicOnly && !o.isOrganic) return false;
      return true;
    }).toList();
  }

  /// The distinct categories present across discovered [offers], sorted, so the
  /// UI only offers a category chip when some offer carries it.
  List<String> get categories {
    final set = <String>{
      for (final o in offers)
        if (o.category != null && o.category!.isNotEmpty) o.category!,
    };
    final list = set.toList()..sort();
    return list;
  }

  /// Whether any discovered offer declares organic, so the eco chip only shows
  /// when it can match something.
  bool get hasOrganic => offers.any((o) => o.isOrganic);

  bool get hasActiveFilter =>
      typeFilter.isNotEmpty || categoryFilter.isNotEmpty || organicOnly;

  OffersState copyWith({
    List<Offer>? offers,
    String? areaGeohash,
    String? query,
    Set<OfferType>? typeFilter,
    Set<String>? categoryFilter,
    bool? organicOnly,
    bool? searching,
    bool? publishing,
    bool? hasSearched,
    String? Function()? error,
  }) {
    return OffersState(
      offers: offers ?? this.offers,
      areaGeohash: areaGeohash ?? this.areaGeohash,
      query: query ?? this.query,
      typeFilter: typeFilter ?? this.typeFilter,
      categoryFilter: categoryFilter ?? this.categoryFilter,
      organicOnly: organicOnly ?? this.organicOnly,
      searching: searching ?? this.searching,
      publishing: publishing ?? this.publishing,
      hasSearched: hasSearched ?? this.hasSearched,
      error: error != null ? error() : this.error,
    );
  }

  @override
  List<Object?> get props => [
        offers,
        areaGeohash,
        query,
        typeFilter,
        categoryFilter,
        organicOnly,
        searching,
        publishing,
        hasSearched,
        error,
      ];
}

/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
/// the interface, not the Nostr backend, so it unit-tests with a fake and the
/// UI stays offline-tolerant (a null transport = the social layer is unavailable
/// and the screen degrades gracefully).
class OffersCubit extends Cubit<OffersState> {
  OffersCubit(
    this._transport, {
    Future<Uint8List?> Function(String varietyId)? coverPhoto,
    String? Function(Uint8List bytes)? thumbnail,
    Future<void> Function()? onDispose,
  })  : _coverPhoto = coverPhoto,
        _thumbnail = thumbnail,
        _onDispose = onDispose,
        super(const OffersState());

  final OfferTransport? _transport;

  /// Fetches a variety's cover photo bytes; null in tests or when no inventory
  /// repo is wired.
  final Future<Uint8List?> Function(String varietyId)? _coverPhoto;

  /// Turns full photo bytes into a small `data:` thumbnail embedded in the offer
  /// (no media server). Null → offers publish without a photo.
  final String? Function(Uint8List bytes)? _thumbnail;

  /// Closes the owning [SocialSession]/connection when the cubit is disposed
  /// (null in tests, where the transport is a fake with nothing to close).
  final Future<void> Function()? _onDispose;
  StreamSubscription<Offer>? _sub;
  Timer? _searchTimeout;

  /// Whether a live transport is available (relay configured and reachable).
  bool get isOnline => _transport != null;

  /// Starts (or restarts) discovery for [geohashPrefix]. Results stream in.
  Future<void> discover(String geohashPrefix) async {
    final transport = _transport;
    if (transport == null) {
      emit(state.copyWith(error: () => 'offline', hasSearched: true));
      return;
    }
    await _sub?.cancel();
    _searchTimeout?.cancel();
    emit(OffersState(
      areaGeohash: geohashPrefix,
      // Keep the text and chip filters across a refresh (only the results reset).
      query: state.query,
      typeFilter: state.typeFilter,
      categoryFilter: state.categoryFilter,
      organicOnly: state.organicOnly,
      searching: true,
      hasSearched: true,
    ));
    _sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
      (offer) =>
          emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
      onError: (Object e) =>
          emit(state.copyWith(searching: false, error: () => '$e')),
    );
    // The discover stream stays open for live offers and never signals "done",
    // so stop the spinner after a beat: no results → show the empty state, not
    // an endless "searching".
    _searchTimeout = Timer(const Duration(seconds: 6), () {
      if (!isClosed && state.searching) {
        emit(state.copyWith(searching: false));
      }
    });
  }

  /// Appends [incoming] to [current], replacing any existing offer with the same
  /// author + id. Relays legitimately resend addressable events (a stored copy
  /// plus a live echo after publishing), so a plain append would double the
  /// listing; keeping one entry per (author, id) is the NIP-99 semantics.
  static List<Offer> _merge(List<Offer> current, Offer incoming) => [
        for (final o in current)
          if (!(o.id == incoming.id &&
              o.authorPubkeyHex == incoming.authorPubkeyHex))
            o,
        incoming,
      ];

  /// Narrows the visible offers to those whose summary matches [query]. Purely
  /// local over the already-discovered list; does not re-hit the transport.
  void search(String query) => emit(state.copyWith(query: query));

  /// Toggles a reciprocity-mode chip on/off. Purely local over the discovered
  /// list; does not re-hit the transport.
  void toggleType(OfferType type) {
    final next = {...state.typeFilter};
    next.contains(type) ? next.remove(type) : next.add(type);
    emit(state.copyWith(typeFilter: next));
  }

  /// Toggles a category chip on/off.
  void toggleCategory(String category) {
    final next = {...state.categoryFilter};
    next.contains(category) ? next.remove(category) : next.add(category);
    emit(state.copyWith(categoryFilter: next));
  }

  /// Toggles the organic ("eco") filter.
  void toggleOrganicOnly() =>
      emit(state.copyWith(organicOnly: !state.organicOnly));

  /// Clears every chip filter (leaves the text search untouched).
  void clearFilters() => emit(state.copyWith(
        typeFilter: const {},
        categoryFilter: const {},
        organicOnly: false,
      ));

  /// Publishes [offer]; returns the transport's verdict. No-op result when
  /// offline.
  Future<PublishResult> publish(Offer offer) async {
    final transport = _transport;
    if (transport == null) {
      return const PublishResult(accepted: false, transportRef: '', message: 'offline');
    }
    emit(state.copyWith(publishing: true, error: () => null));
    try {
      final result = await transport.publish(offer);
      emit(state.copyWith(
        publishing: false,
        error: () => result.accepted ? null : result.message,
      ));
      return result;
    } catch (e) {
      emit(state.copyWith(publishing: false, error: () => '$e'));
      rethrow;
    }
  }

  /// Publishes the user's [lots] as offers, each tagged with the coarse
  /// [areaGeohash] and signed by [authorPubkeyHex]. Returns how many the relay
  /// accepted. No-op (returns 0) when offline or with no area set.
  Future<int> publishLots(
    List<ShareableLot> lots, {
    required String authorPubkeyHex,
    required String areaGeohash,
  }) async {
    final transport = _transport;
    if (transport == null || areaGeohash.isEmpty || lots.isEmpty) return 0;
    emit(state.copyWith(publishing: true, error: () => null));
    var accepted = 0;
    try {
      for (final lot in lots) {
        final offer = OfferMapper.fromSharedLot(
          lotId: lot.lotId,
          authorPubkeyHex: authorPubkeyHex,
          summary: lot.summary,
          sharing: lot.offerStatus,
          areaGeohash: areaGeohash,
          category: lot.category,
          isOrganic: lot.isOrganic,
          imageUrl: await _coverThumbnail(lot.varietyId),
        );
        final result = await transport.publish(offer);
        if (result.accepted) accepted++;
      }
      emit(state.copyWith(publishing: false));
    } catch (e) {
      emit(state.copyWith(publishing: false, error: () => '$e'));
      rethrow;
    }
    return accepted;
  }

  /// Builds a small inline `data:` thumbnail from the lot's cover photo to embed
  /// in the offer, or null when there's no photo, no thumbnailer, or it can't be
  /// shrunk to fit. Best-effort: a missing image never blocks publishing.
  Future<String?> _coverThumbnail(String varietyId) async {
    final coverPhoto = _coverPhoto;
    final thumbnail = _thumbnail;
    if (coverPhoto == null || thumbnail == null) return null;
    try {
      final Uint8List? bytes = await coverPhoto(varietyId);
      if (bytes == null || bytes.isEmpty) return null;
      return thumbnail(bytes);
    } catch (_) {
      return null; // degrade: publish the offer without a photo
    }
  }

  @override
  Future<void> close() async {
    _searchTimeout?.cancel();
    await _sub?.cancel();
    await _onDispose?.call();
    return super.close();
  }
}

/// Opens an [OffersCubit] over the SHARED [SocialConnection], or an offline one
/// that degrades gracefully. Local-first: when the connection isn't up (no relay
/// configured / unreachable), the cubit gets a null transport and the screen
/// still opens. Does NOT close the session — the connection owns it.
Future<OffersCubit> createOffersCubit(
  SocialConnection connection, {
  VarietyRepository? repository,
}) async {
  final session = await connection.session();
  return OffersCubit(
    session?.offers,
    coverPhoto: repository?.coverPhotoFor,
    thumbnail: offerThumbnailDataUri,
  );
}

/// Publishes any queued (offline-parked) lots now that we're online, then clears
/// them from the [outbox]. Rebuilds each offer from the lot's CURRENT state, so
/// since-deleted or now-private lots simply drop out. Returns how many published.
Future<int> flushOutbox({
  required OfferOutbox outbox,
  required OffersCubit cubit,
  required List<ShareableLot> shareableLots,
  required String authorPubkeyHex,
  required String areaGeohash,
}) async {
  if (!cubit.isOnline || areaGeohash.isEmpty) return 0;
  final queued = await outbox.pending();
  if (queued.isEmpty) return 0;
  final toPublish =
      shareableLots.where((l) => queued.contains(l.lotId)).toList();
  var published = 0;
  if (toPublish.isNotEmpty) {
    published = await cubit.publishLots(
      toPublish,
      authorPubkeyHex: authorPubkeyHex,
      areaGeohash: areaGeohash,
    );
  }
  // Clear everything attempted (or gone) so we don't loop on it.
  await outbox.remove(queued);
  return published;
}
