import 'dart:async';

import 'package:fl_query/fl_query.dart';
import 'package:fl_query/src/base_operation.dart';
import 'package:fl_query/src/mixins/autocast.dart';
import 'package:flutter/widgets.dart';
import 'package:hive/hive.dart';

abstract class BaseQuery<T extends Object, Outside, Error>
    extends BaseOperation<T, Error> with AutoCast {
// all params
  final String queryKey;
  bool? refetchOnMount;
  bool? refetchOnReconnect;

  final T? _initialData;

  // got from global options
  Duration _staleTime;

  /// total count of how many times the query retried to get a successful
  /// result
  int refetchCount = 0;
  bool enabled;

  QueryStatus status;

  @protected
  final Set onDataListeners = Set();
  @protected
  final Set onErrorListeners = Set();

  // externalData will always be passed to the task Callback
  // it will change based on the presence of QueryBuilder
  Outside _externalData;

  Outside? _prevUsedExternalData;

  Duration? refetchInterval;

  Timer? _refetchIntervalTimer;

  bool? refetchOnApplicationResume;
  bool? refetchOnWindowFocus;

  T? _previousData;

  BaseQuery({
    required this.queryKey,
    required Duration staleTime,
    required super.cacheTime,
    required Outside externalData,
    required super.retries,
    required super.retryDelay,
    required this.status,
    super.connectivity,
    this.refetchOnMount,
    this.refetchOnReconnect,
    this.refetchInterval,
    this.refetchOnApplicationResume,
    this.refetchOnWindowFocus,
    this.enabled = true,
    T? previousData,
    T? initialData,
    onData,
    onError,
  })  : _staleTime = staleTime,
        _initialData = initialData,
        _externalData = externalData,
        _previousData = previousData,
        super(data: previousData ?? initialData) {
    if (onData != null) onDataListeners.add(onData);
    if (onError != null) onErrorListeners.add(onError);

    if (refetchInterval != null && refetchInterval != Duration.zero) {
      _refetchIntervalTimer = createRefetchTimer();
    }
    if (canCacheToDisk) {
      loadFromDisk();
    }
  }
  // all getters & setters

  Outside get externalData => _externalData;
  Outside? get prevUsedExternalData => _prevUsedExternalData;

  @protected
  Timer createRefetchTimer();

  @protected
  Future<void> loadFromDisk() async {
    final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
    final rawData = await box.get(queryKey);
    if (rawData == null) return;
    data = deserialize(rawData);
    if (!isLoading && !isRefetching) {
      status = QueryStatus.cached;
    }
    updatedAt = DateTime.now();
    await notifyDataListeners();
    notifyListeners();
  }

  Future<void> saveToDisk({bool delete = false}) async {
    if (!canCacheToDisk) return;
    if (!hasData) {
      if (delete) {
        final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
        await box.delete(queryKey);
      }
      return;
    }
    final box = await Hive.lazyBox<String>(kFlQueryBoxKey);
    await box.put(queryKey, serialize(data!)!);
  }

  /// Calls the task function & doesn't check if there's already
  /// cached data available
  @protected
  Future<void> execute() async {
    try {
      retryAttempts = 0;
      await setData();
      await saveToDisk();
      _prevUsedExternalData = _externalData;
      updatedAt = DateTime.now();
      status = QueryStatus.success;
      await notifyDataListeners();
      notifyListeners();
    } catch (e) {
      if (retries == 0) {
        status = QueryStatus.error;
        setError(e);
        await notifyErrorListeners();
        notifyListeners();
      } else {
        // retrying for retry count if failed for the first time
        while (retryAttempts <= retries) {
          await Future.delayed(retryDelay);
          try {
            await setData();
            await saveToDisk();
            _prevUsedExternalData = _externalData;
            status = QueryStatus.success;
            await notifyDataListeners();
            notifyListeners();
            break;
          } catch (e) {
            if (retryAttempts == retries) {
              status = QueryStatus.error;
              setError(e);
              await notifyErrorListeners();
              notifyListeners();
              break;
            }
            retryAttempts++;
          }
        }
      }
    }
  }

  void addDataListener(listener) {
    onDataListeners.add(listener);
  }

  void addErrorListener(listener) {
    onErrorListeners.add(listener);
  }

  void removeDataListener(listener) {
    onDataListeners.remove(listener);
  }

  void removeErrorListener(listener) {
    onErrorListeners.remove(listener);
  }

  /// fetches data or runs the provided task initially
  ///
  /// Once [data] is available it won't run the [task] ever again
  /// and will only return the available data
  ///
  /// If a [fetch] is already running in the background it'll just return
  /// the current available [data] (which can be nul if no [initialPage]
  /// was provided) instead of running the task to prevent race conditions
  Future<T?> fetch() async {
    if (!enabled) return null;

    if (isCachedData) {
      status = QueryStatus.loading;
      await execute().then((_) {
        fetched = true;
      });
      notifyListeners();
      return data;
    }

    /// if isLoading/isRefetching is true that means its already fetching/
    /// refetching. So [_execute] again can create a race condition
    if (isLoading ||
        isRefetching ||
        !(await isNetworkOnline) ||
        (hasData && !isPreviousData)) return data;
    status = QueryStatus.loading;
    notifyListeners();
    return execute().then((_) {
      fetched = true;
      return data;
    });
  }

  /// refetches a valid or invalid [Query]
  ///
  /// When called before calling [fetch] in a [Query] it'll
  /// automatically run [fetch]
  ///
  /// But if it's used to fetch the first data of a non-enabled [Query]
  /// aka `LazyQuery`, it'll execute the task & will set the status
  /// `enabled=true`
  ///
  /// If a [refetch] is already running in the background it'll just return
  /// the current available [data] instead of running the task to prevent
  /// race conditions
  Future<T?> refetch() async {
    /// if isLoading/isRefetching is true that means its already fetching/
    /// refetching. So [_execute] again can create a race condition
    if (isRefetching || isLoading || !(await isNetworkOnline)) return data;
    if (enabled && !fetched) return await fetch();
    status = QueryStatus.refetching;
    refetchCount++;
    // disabling the lazy query bound when query was actually called
    if (!enabled) enabled = true;
    notifyListeners();
    return await execute().then((_) => data);
  }

  @protected
  String? serialize(T data);

  @protected
  T? deserialize(String rawData);

  @protected
  bool get canCacheToDisk;

  @protected
  FutureOr<void> setData();
  @protected
  void setError(dynamic);
  @protected
  FutureOr<void> notifyDataListeners() async {
    for (final onData in onDataListeners) {
      await onData(data);
    }
  }

  @protected
  FutureOr<void> notifyErrorListeners() async {
    for (final onError in onErrorListeners) {
      await onError(error);
    }
  }

  /// Sets the [externalData] from outside of the query
  ///
  /// Remember, it's for the very instance of [Query]
  /// So this won't persist through later UI/[Query] updates
  void setExternalData(Outside externalData) {
    _prevUsedExternalData = _externalData;
    _externalData = externalData;
  }

  /// Resets the query
  ///
  /// The values of internal state of the query are reset to the
  /// initial ones
  void reset() {
    refetchCount = 0;
    data = _previousData ?? _initialData;
    saveToDisk(delete: true);
    error = null;
    fetched = false;
    status = QueryStatus.idle;
    retryAttempts = 0;
    onDataListeners.clear();
    onErrorListeners.clear();
    mounts.clear();
  }

  @override
  void dispose() async {
    await Hive.openLazyBox(kFlQueryBoxKey).then((box) => box.delete(queryKey));
    super.dispose();
  }

  /// Update configurations of the query
  /// after already creating the Query instance
  ///
  /// Remember, it's just for the single query instance
  /// In the next UI update/render the options will get reset
  /// to the default ones defined in the [QueryJob] or [QueryBowlScope]
  void updateDefaultOptions({
    Duration? refetchInterval,
    Duration? staleTime,
    Duration? cacheTime,
    bool? refetchOnMount,
    bool? refetchOnReconnect,
    bool? refetchOnApplicationResume,
    bool? refetchOnWindowFocus,
  }) {
    bool updated = false;
    if (this.refetchInterval == null &&
        refetchInterval != null &&
        refetchInterval != Duration.zero) {
      this.refetchInterval = refetchInterval;
      _refetchIntervalTimer?.cancel();
      _refetchIntervalTimer = createRefetchTimer();
      updated = true;
    }
    if (this.cacheTime == Duration(minutes: 5) && cacheTime != null) {
      this.cacheTime = cacheTime;
      updated = true;
    }
    if (this._staleTime == const Duration(milliseconds: 500) &&
        staleTime != null) {
      this._staleTime = staleTime;
      updated = true;
    }
    if (this.refetchOnMount == null && refetchOnMount != null) {
      this.refetchOnMount = refetchOnMount;
      updated = true;
    }
    if (this.refetchOnReconnect == null && refetchOnReconnect != null) {
      this.refetchOnReconnect = refetchOnReconnect;
      updated = true;
    }
    if (this.refetchOnApplicationResume == null &&
        refetchOnApplicationResume != null) {
      this.refetchOnApplicationResume = refetchOnApplicationResume;
      updated = true;
    }
    if (this.refetchOnWindowFocus == null && refetchOnWindowFocus != null) {
      this.refetchOnWindowFocus = refetchOnWindowFocus;
      updated = true;
    }
    if (updated) notifyListeners();
  }

  /// can be used to update the data manually. Can be useful when used
  /// together with mutations to perform optimistic updates or manual data
  /// updates
  /// For updating particular queries after a mutation using the
  /// `QueryBowl.refetchQueries` is more appropriate. But this one can be
  /// used when only 1 query needs get updated
  ///
  /// Every time a new instance of data should be returned because of
  /// immutability
  void setQueryData(QueryUpdateFunction<T> updateFn) async {
    final newData = await updateFn(data);
    if (data == newData) return;
    data = newData;
    await saveToDisk();
    status = QueryStatus.success;
    notifyListeners();
  }

  /// invalidates the query
  ///
  /// Forcefully makes the query stale & expired which results in a refetch
  /// when met conditions
  void invalidate() {
    /// subtracting [staleTime] from [updatedAt] as staleTime=Duration.zero
    /// indicates the query must never become stale but subtracting the
    /// [staleTime] will always revert the updatedAt time to the default
    /// time whenever isStale is called
    updatedAt = updatedAt.subtract(_staleTime);
    notifyListeners();
  }

  bool get isStale {
    /// when [_staleTime] is [Duration.zero], the query will always be
    /// stale & will never refetch in the background. But can be inactive
    /// if [mounts.length] become zero
    if (_staleTime == Duration.zero) return false;

    // when [DateTime.now()] is after [update_at + stale_time] it means
    // the data has become stale
    return DateTime.now().isAfter(updatedAt.add(_staleTime));
  }

  bool get isError => status == QueryStatus.error;
  bool get isIdle => status == QueryStatus.idle;
  bool get isLoading => status == QueryStatus.loading;
  bool get isRefetching => status == QueryStatus.refetching;
  bool get isSuccess => status == QueryStatus.success;
  bool get isCachedData => status == QueryStatus.cached;
  bool get isPreviousData {
    return _previousData != null ? _previousData == data : false;
  }

  String get debugLabel;

  @override
  String toString() {
    return debugLabel;
  }

  operator ==(other);
}
