import 'dart:async';

import 'package:flutter/widgets.dart';
import '../exceptions/media_server_exceptions.dart';

import '../media/media_item.dart';
import '../utils/app_logger.dart';

/// Debounced free-text media search shared by the main search screen and the
/// catalog (Explore) search screen: text controller + focus nodes, a 500ms
/// debounce, a generation guard against out-of-order responses, in-flight
/// invalidation when the text diverges from the query being fetched, and the
/// loading/failed/empty state flags the screens render from.
///
/// Implementations override [performSearchQuery]; everything else (including
/// controller/node disposal) is owned here.
mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
  static const Duration searchDebounceDuration = Duration(milliseconds: 500);

  late final TextEditingController searchController = TextEditingController();
  late final FocusNode searchFocusNode = FocusNode(debugLabel: '${searchDebugLabel}Input');
  late final FocusNode firstResultFocusNode = FocusNode(debugLabel: '${searchDebugLabel}FirstResult');

  /// Plain restartable timer instead of rate_limiter's Debounce: that one
  /// times its trailing edge with DateTime.now(), which never advances under
  /// the widget-test fake clock, so the debounce would be untestable.
  Timer? _debounceTimer;

  List<MediaItem> searchResults = [];
  bool isSearching = false;
  bool hasSearched = false;
  bool lastSearchFailed = false;
  String lastSearchedQuery = '';

  int _searchGeneration = 0;
  String? _inFlightQuery;
  bool _showedClearButton = false;
  String _lastObservedText = '';

  /// Names the focus nodes and log lines.
  String get searchDebugLabel => widget.runtimeType.toString();

  /// Run the actual search. Non-cancellation errors flip [lastSearchFailed].
  Future<List<MediaItem>> performSearchQuery(String query);

  /// A failed search was applied to the state (e.g. show a snackbar).
  void onSearchError(Object error) {}

  /// A successful search was applied to the state.
  void onSearchCompleted(String query, List<MediaItem> results) {}

  /// The field was cleared and the state reset.
  void onSearchCleared() {}

  /// The active query was superseded or the search scope is being disposed.
  /// Implementations may cancel transport work here; the generation guard
  /// remains authoritative for preventing stale UI commits.
  void onSearchInvalidated() {}

  @override
  void initState() {
    super.initState();
    searchController.addListener(_onSearchTextChanged);
  }

  @override
  void dispose() {
    _debounceTimer?.cancel();
    onSearchInvalidated();
    searchController.removeListener(_onSearchTextChanged);
    searchController.dispose();
    searchFocusNode.dispose();
    firstResultFocusNode.dispose();
    super.dispose();
  }

  void _onSearchTextChanged() {
    if (!mounted) return;
    // The controller also notifies on selection/composing changes (e.g. the
    // focus gain after an external text set writes a collapsed selection); a
    // selection-only notification mid-flight would re-arm the debounce and
    // re-run the identical query against the servers.
    final text = searchController.text;
    if (text == _lastObservedText) return;
    _lastObservedText = text;
    final query = text.trim();

    // The clear affordance tracks text emptiness; without this rebuild it
    // only appeared when a search landed ~500ms later.
    if (query.isNotEmpty != _showedClearButton) {
      _showedClearButton = query.isNotEmpty;
      setState(() {});
    }

    if (query.isEmpty) {
      _debounceTimer?.cancel();
      _searchGeneration++;
      if (_inFlightQuery != null) onSearchInvalidated();
      _inFlightQuery = null;
      setState(() {
        searchResults = [];
        hasSearched = false;
        isSearching = false;
        lastSearchFailed = false;
        lastSearchedQuery = '';
      });
      onSearchCleared();
      return;
    }

    if (query == lastSearchedQuery) {
      // Reverted to what's already shown: the pending debounce and any
      // in-flight pass for the intermediate text must not land afterwards.
      _debounceTimer?.cancel();
      if (_invalidateStaleInFlight(query)) setState(() => isSearching = false);
      return;
    }

    _invalidateStaleInFlight(query);
    _debounceTimer?.cancel();
    _debounceTimer = Timer(searchDebounceDuration, () => runSearch(query));
  }

  /// An in-flight search for text the field no longer shows can only land
  /// wrong; kill it via the generation. Returns true when one was dropped.
  bool _invalidateStaleInFlight(String current) {
    if (_inFlightQuery == null || _inFlightQuery == current) return false;
    _searchGeneration++;
    onSearchInvalidated();
    _inFlightQuery = null;
    return true;
  }

  /// Run [query] now, bypassing the debounce (submit, external refresh).
  Future<void> runSearch(String query) async {
    if (!mounted || query.isEmpty) return;
    if (_inFlightQuery != null) onSearchInvalidated();
    final generation = ++_searchGeneration;
    _inFlightQuery = query;
    setState(() {
      isSearching = true;
      hasSearched = true;
      lastSearchFailed = false;
    });
    try {
      final results = await performSearchQuery(query);
      if (!mounted || generation != _searchGeneration) return;
      _inFlightQuery = null;
      setState(() {
        searchResults = results;
        isSearching = false;
        lastSearchedQuery = query;
      });
      onSearchCompleted(query, results);
    } catch (e) {
      if (!mounted || generation != _searchGeneration) return;
      if (e is MediaServerHttpException && e.isCancellation) {
        _inFlightQuery = null;
        setState(() => isSearching = false);
        return;
      }
      appLogger.w('$searchDebugLabel: search failed', error: e);
      _inFlightQuery = null;
      setState(() {
        searchResults = [];
        isSearching = false;
        lastSearchFailed = true;
        lastSearchedQuery = query;
      });
      onSearchError(e);
    }
  }

  /// The results list both screens render: padded, without keep-alives or
  /// semantic indexes, one child per entry of [searchResults].
  Widget buildResultsSliver(NullableIndexedWidgetBuilder itemBuilder) {
    return SliverPadding(
      padding: const EdgeInsets.all(16),
      sliver: SliverList(
        delegate: SliverChildBuilderDelegate(
          itemBuilder,
          childCount: searchResults.length,
          addAutomaticKeepAlives: false,
          addSemanticIndexes: false,
        ),
      ),
    );
  }

  /// OSK "Search" / hardware Enter on TV: jump to results, or force the
  /// pending search to run now.
  void handleSearchSubmit() {
    final query = searchController.text.trim();
    if (query.isEmpty) return;
    if (searchResults.isNotEmpty && !isSearching && query == lastSearchedQuery) {
      firstResultFocusNode.requestFocus();
      return;
    }
    if ((_debounceTimer?.isActive ?? false) || !isSearching) {
      _debounceTimer?.cancel();
      runSearch(query);
    }
    // else: the in-flight search already covers the current text.
  }
}
