// Flutter play-event lifecycle reporter (#415 stage 3).
//
// The Flutter client previously reported NO plays — listening on
// mobile never reached the server's play_events, so history,
// recommendation scoring, ListenBrainz scrobbles, and (since #415)
// system-playlist rotation all missed mobile activity entirely. This
// closes that gap and is the path that carries the #415 `source` tag.
//
// State machine over (current track id, playing). Mirrors the web
// events dispatcher, with one deliberate divergence: when a track
// changes inside a queue we classify ended-vs-skipped by whether the
// prior track reached (near) its duration, instead of the web
// dispatcher's blanket "track change = skip". Blanket-skip would mark
// every naturally-finished in-queue track as a skip and dilute the
// recommendation skip-ratio — the exact failure mode that motivated
// doing this properly. (The web dispatcher likely has the same
// false-skip issue; flagged separately, not fixed here.)

import 'dart:async';
import 'dart:math';

import 'package:audio_service/audio_service.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../api/endpoints/events.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
import '../cache/mutation_queue.dart'
    show MutationKinds, mutationQueueProvider;
import '../library/library_providers.dart' show dioProvider;
import 'audio_handler.dart' show MinstrelAudioHandler;
import 'player_provider.dart' show audioHandlerProvider;

const _clientIdKey = 'play_events_client_id';

/// Tolerance for "the track basically finished": within 3s of the
/// known duration counts as a natural completion, not a skip.
const _completionToleranceMs = 3000;

class PlayEventsReporter with WidgetsBindingObserver {
  PlayEventsReporter(this._ref);
  final Ref _ref;

  final _subs = <StreamSubscription<dynamic>>[];
  bool _disposed = false;

  String? _clientId;
  EventsApi? _api;

  // Server play_event_id when the live play_started succeeded; null
  // if start failed / offline — then the close is captured into the
  // offline mutation queue instead of a live ended/skipped call.
  String? _openPlayEventId;

  // The play currently being tracked, captured independently of
  // connectivity so an offline play is still a complete record.
  String? _curTrackId;
  DateTime? _curStartedAt;
  String? _curSource;
  int _curLastPositionMs = 0;
  int _curDurationMs = 0;
  bool _curReachedEnd = false;

  String? _prevTrackId;

  Future<void> start() async {
    final MinstrelAudioHandler handler;
    try {
      // audioHandlerProvider throws until main() overrides it (real
      // app always does). In tests / no-audio environments there's
      // nothing to report — fail safe and stay inert rather than
      // surfacing an unhandled async error.
      handler = _ref.read(audioHandlerProvider);
      _clientId = await _resolveClientId();
      final dio = await _ref.read(dioProvider.future);
      _api = EventsApi(dio);
    } catch (_) {
      return;
    }
    if (_disposed) return;
    WidgetsBinding.instance.addObserver(this);

    _subs.add(handler.positionStream.listen((p) {
      final ms = p.inMilliseconds;
      // Advance the tracked play's progress ONLY while its track is
      // the current one. A track change resets position to 0; gating
      // on _curTrackId keeps the finishing track's last-known values
      // intact for the close branch.
      final mi = handler.mediaItem.value;
      if (mi != null && mi.id == _curTrackId) {
        _curLastPositionMs = ms;
        final d = mi.duration;
        if (d != null && d.inMilliseconds > 0) {
          _curDurationMs = d.inMilliseconds;
          if (ms >= _curDurationMs - _completionToleranceMs) {
            _curReachedEnd = true;
          }
        }
      }
    }));
    _subs.add(handler.mediaItem.listen((_) => _evaluate(handler)));
    _subs.add(handler.playbackState.listen((_) => _evaluate(handler)));
  }

  void _evaluate(MinstrelAudioHandler handler) {
    if (_disposed) return;
    final mi = handler.mediaItem.value;
    final st = handler.playbackState.value;
    final tid = mi?.id;
    final playing = st.playing;
    final completed = st.processingState == AudioProcessingState.completed;

    // Track changed → close the prior tracked play.
    if (tid != _prevTrackId && _curTrackId != null) {
      _closeCurrent(viaOffline: false);
    }

    // Entered playing for a new track → begin tracking it.
    if (tid != null && playing && _curTrackId != tid) {
      _beginTrack(handler, tid);
    }

    // Whole-queue natural end (just_audio only emits `completed` at
    // the end of the sequence, not between items) → close it.
    if (completed && _curTrackId != null) {
      _curReachedEnd = true;
      _closeCurrent(viaOffline: false);
    }

    _prevTrackId = tid;
  }

  void _beginTrack(MinstrelAudioHandler handler, String trackId) {
    _curTrackId = trackId;
    _curStartedAt = DateTime.now().toUtc();
    _curSource = handler.queueSource;
    _curLastPositionMs = 0;
    _curReachedEnd = false;
    final d = handler.mediaItem.value?.duration;
    _curDurationMs = d?.inMilliseconds ?? 0;
    _openPlayEventId = null;
    // Fire the live play_started; adopt the server id only if we're
    // still on this track when the response lands. Failure is fine —
    // the close path captures the whole play into the offline queue.
    _startLive(handler, trackId);
  }

  Future<void> _startLive(MinstrelAudioHandler handler, String trackId) async {
    final api = _api;
    final cid = _clientId;
    if (api == null || cid == null) return;
    try {
      final id = await api.playStarted(
        trackId: trackId,
        clientId: cid,
        source: _curSource,
      );
      if (id != null && _curTrackId == trackId) {
        _openPlayEventId = id;
      }
    } catch (_) {
      // Offline / flaky — _openPlayEventId stays null; the close path
      // enqueues the completed play for replay.
    }
  }

  /// Closes the currently-tracked play. `finished` is derived from
  /// whether it reached ~its duration. If the live start registered a
  /// server id we attempt the live ended/skipped close and fall back
  /// to the offline queue on failure; with no server id (offline
  /// start) — or viaOffline (app teardown, must be durable) — the
  /// completed play is enqueued directly. The server's RecordOffline
  /// Play applies the canonical skip rule, so the offline payload
  /// only needs duration, not our finished/skipped guess.
  void _closeCurrent({required bool viaOffline}) {
    final trackId = _curTrackId;
    final startedAt = _curStartedAt;
    if (trackId == null || startedAt == null) {
      _resetCurrent();
      return;
    }
    final reached = _curReachedEnd;
    final lastPos = _curLastPositionMs;
    final durationMs = (reached && _curDurationMs > 0)
        ? _curDurationMs
        : lastPos;
    final source = _curSource;
    final id = _openPlayEventId;

    if (!viaOffline && id != null) {
      // Live close; on failure, fall back to the durable offline path
      // so a transient blip at close time doesn't lose the play.
      final fut = reached
          ? _api?.playEnded(playEventId: id, durationPlayedMs: durationMs)
          : _api?.playSkipped(playEventId: id, positionMs: lastPos);
      fut?.catchError((_) {
        _enqueueOffline(trackId, startedAt, source, durationMs);
      });
    } else {
      _enqueueOffline(trackId, startedAt, source, durationMs);
    }
    _resetCurrent();
  }

  void _resetCurrent() {
    _curTrackId = null;
    _curStartedAt = null;
    _curSource = null;
    _curLastPositionMs = 0;
    _curDurationMs = 0;
    _curReachedEnd = false;
    _openPlayEventId = null;
  }

  void _enqueueOffline(
    String trackId,
    DateTime startedAt,
    String? source,
    int durationPlayedMs,
  ) {
    final cid = _clientId;
    if (cid == null) return;
    // ignore: unawaited_futures
    _ref.read(mutationQueueProvider).enqueue(MutationKinds.playOffline, {
      'trackId': trackId,
      'clientId': cid,
      'at': startedAt.toIso8601String(),
      'durationPlayedMs': durationPlayedMs,
      if (source != null && source.isNotEmpty) 'source': source,
    });
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    // App backgrounded / killed mid-play: close durably via the
    // offline queue (a fire-and-forget POST during teardown is
    // unreliable; the queue survives a process kill and drains on
    // next launch). Mirrors the intent of web's pagehide beacon.
    if (state == AppLifecycleState.paused ||
        state == AppLifecycleState.detached) {
      if (_curTrackId != null) {
        _closeCurrent(viaOffline: true);
      }
    }
  }

  Future<String> _resolveClientId() async {
    final storage = _ref.read(secureStorageProvider);
    final existing = await storage.read(key: _clientIdKey);
    if (existing != null && existing.isNotEmpty) return existing;
    final rnd = Random.secure();
    final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
    final id =
        bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
    await storage.write(key: _clientIdKey, value: id);
    return id;
  }

  void dispose() {
    _disposed = true;
    WidgetsBinding.instance.removeObserver(this);
    for (final s in _subs) {
      s.cancel();
    }
    _subs.clear();
  }
}

/// Read once at app start (app.dart postFrame) to activate reporting.
/// Disposed via ref.onDispose when the scope tears down.
final playEventsReporterProvider = Provider<PlayEventsReporter>((ref) {
  final r = PlayEventsReporter(ref);
  ref.onDispose(r.dispose);
  // ignore: unawaited_futures
  r.start();
  return r;
});
