import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:uuid/uuid.dart';

import '../../../shared/utils/app_logger.dart';
import '../../../shared/utils/async_lock.dart';
import '../../contracts/trakt/trakt_response.dart';
import '../../contracts/trakt/trakt_sync_job.dart';
import '../../domain/ports/authed_trakt_gateway.dart';
import 'trakt_retry_policy.dart';

const _tag = 'TraktQueue';


class TraktPendingSyncQueue {
  TraktPendingSyncQueue({
    required this.store,
    required this.sender,
    required this.policy,
    DateTime Function()? clock,
    String Function()? idGenerator,
  }) : _clock = clock ?? DateTime.now,
       _idGenerator = idGenerator ?? (() => const Uuid().v4());

  final JsonTraktSyncJobStore store;
  final AuthedTraktGateway sender;
  final TraktRetryPolicy policy;
  final DateTime Function() _clock;
  final String Function() _idGenerator;

  final _lock = AsyncLock();
  bool _draining = false;

  Future<void> enqueue({
    required TraktSyncJobType type,
    required Map<String, dynamic> body,
    Duration? retryAfter,
  }) async {
    await _lock.run(() async {
      final now = _clock();
      final jobs = await store.load();
      final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
      final job = TraktSyncJob(
        id: _idGenerator(),
        type: type,
        body: body,
        nextRetryAt: retryAfter == null ? null : now.add(retryAfter),
      );
      nextJobs.add(job);
      await store.save(nextJobs);
      AppLogger.debug(_tag, 'enqueue ${type.wire} (total=${nextJobs.length})');
    });
  }


  Future<void> discardSupersededBy({
    required TraktSyncJobType type,
    required Map<String, dynamic> body,
  }) async {
    await _lock.run(() async {
      final jobs = await store.load();
      final nextJobs = _discardSupersededJobs(jobs, type: type, body: body);
      if (nextJobs.length != jobs.length) {
        await store.save(nextJobs);
      }
    });
  }


  Future<int> pendingCount() async {
    final jobs = await _lock.run(store.load);
    return jobs.where((j) => !j.exhausted).length;
  }


  Future<void> drain() async {
    if (_draining) return;
    _draining = true;
    try {
      await _lock.run(_drainLocked);
    } finally {
      _draining = false;
    }
  }

  Future<void> _drainLocked() async {
    final jobs = await store.load();
    if (jobs.isEmpty) return;

    bool connected;
    try {
      connected = await sender.isConnected();
    } catch (_) {
      connected = false;
    }
    if (!connected) {
      AppLogger.debug(
        _tag,
        'drain skipped: not connected (${jobs.length} pending)',
      );
      return;
    }

    final now = _clock();
    final remaining = <TraktSyncJob>[];
    var changed = false;

    for (final job in jobs) {
      if (job.exhausted) {
        remaining.add(job);
        continue;
      }
      final due = job.nextRetryAt == null || !job.nextRetryAt!.isAfter(now);
      if (!due) {
        remaining.add(job);
        continue;
      }

      changed = true;
      TraktScrobbleOutcome outcome;
      try {
        outcome = job.type.isHistory
            ? await sender.syncHistory(job.type, job.body)
            : await sender.scrobble(job.type, job.body);
      } catch (e) {
        outcome = const TraktScrobbleOutcome(TraktResponseClass.transient);
        AppLogger.debug(_tag, 'send threw, treat as transient', e);
      }

      final decision = policy.evaluate(
        cls: outcome.cls,
        retryCount: job.retryCount,
        retryAfter: outcome.retryAfter,
      );

      switch (decision.decision) {
        case TraktRetryDecision.complete:
          AppLogger.debug(_tag, 'job ${job.id} ${job.type.wire} → complete');
          break;
        case TraktRetryDecision.discard:
          AppLogger.debug(
            _tag,
            'job ${job.id} ${job.type.wire} → discard '
            '(${outcome.cls.name})',
          );
          break;
        case TraktRetryDecision.retry:
          remaining.add(
            job.copyWith(
              retryCount: job.retryCount + 1,
              nextRetryAt: now.add(decision.delay ?? policy.baseBackoff),
            ),
          );
          AppLogger.debug(
            _tag,
            'job ${job.id} → retry in '
            '${(decision.delay ?? policy.baseBackoff).inSeconds}s '
            '(count=${job.retryCount + 1})',
          );
          break;
        case TraktRetryDecision.giveUp:
          remaining.add(job.copyWith(clearNextRetryAt: true, exhausted: true));
          AppLogger.warn(
            _tag,
            'job ${job.id} ${job.type.wire} → giveUp '
            '(exhausted after ${job.retryCount} retries)',
          );
          break;
      }
    }

    if (changed) await store.save(remaining);
  }

  List<TraktSyncJob> _discardSupersededJobs(
    List<TraktSyncJob> jobs, {
    required TraktSyncJobType type,
    required Map<String, dynamic> body,
  }) {
    final key = _mediaKey(body);
    if (key == null) return List.of(jobs);
    if (type.isHistory) {
      return jobs
          .where((job) {
            if (job.exhausted) return true;
            if (!job.type.isHistory) return true;
            return _mediaKey(job.body) != key;
          })
          .toList(growable: true);
    }
    if (type != TraktSyncJobType.scrobbleStop) return List.of(jobs);
    return jobs
        .where((job) {
          if (job.exhausted) return true;
          if (job.type == TraktSyncJobType.scrobbleStop) return true;
          return _mediaKey(job.body) != key;
        })
        .toList(growable: true);
  }

  static String? _mediaKey(Map<String, dynamic> body) {
    final movie = body['movie'];
    if (movie is Map) {
      return 'movie:${jsonEncode(_canonical(movie.cast<String, dynamic>()))}';
    }
    final movies = body['movies'];
    if (movies is List && movies.isNotEmpty) {
      final first = movies.first;
      if (first is Map) {
        return 'movie:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
      }
    }
    final show = body['show'];
    final episode = body['episode'];
    if (show is Map && episode is Map) {
      final identity = {
        'show': _canonical(show.cast<String, dynamic>()),
        'episode': _canonical(episode.cast<String, dynamic>()),
      };
      return 'episode:${jsonEncode(identity)}';
    }
    final shows = body['shows'];
    if (shows is List && shows.isNotEmpty) {
      final first = shows.first;
      if (first is Map) {
        return 'show:${jsonEncode(_canonical(first, skipWatchedAt: true))}';
      }
    }
    return null;
  }

  static Object? _canonical(Object? value, {bool skipWatchedAt = false}) {
    if (value is Map) {
      var filtered = value.entries;
      if (skipWatchedAt) {
        filtered = filtered.where((e) => e.key.toString() != 'watched_at');
      }
      final entries =
          filtered
              .map(
                (e) => MapEntry(
                  e.key.toString(),
                  _canonical(e.value, skipWatchedAt: skipWatchedAt),
                ),
              )
              .toList()
            ..sort((a, b) => a.key.compareTo(b.key));
      return <String, Object?>{
        for (final entry in entries) entry.key: entry.value,
      };
    }
    if (value is List) {
      return value
          .map((e) => _canonical(e, skipWatchedAt: skipWatchedAt))
          .toList(growable: false);
    }
    return value;
  }
}


class JsonTraktSyncJobStore {
  JsonTraktSyncJobStore({required this._file});

  final File _file;

  Future<List<TraktSyncJob>> load() async {
    if (!await _file.exists()) return <TraktSyncJob>[];
    final raw = await _file.readAsString();
    if (raw.trim().isEmpty) return <TraktSyncJob>[];
    try {
      final list = jsonDecode(raw) as List<dynamic>;
      return list
          .whereType<Map<String, dynamic>>()
          .map(TraktSyncJob.fromJson)
          .toList();
    } catch (_) {
      return <TraktSyncJob>[];
    }
  }

  Future<void> save(List<TraktSyncJob> jobs) async {
    if (!await _file.parent.exists()) {
      await _file.parent.create(recursive: true);
    }
    await _file.writeAsString(
      const JsonEncoder.withIndent(
        '  ',
      ).convert(jobs.map((e) => e.toJson()).toList()),
      flush: true,
    );
  }
}
