// ignore_for_file: avoid_rx_value_getter_outside_obx, constant_identifier_names
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';

import 'package:flutter/material.dart';

import 'package:intl/intl.dart';

import 'package:namida/class/file_parts.dart';
import 'package:namida/class/search_matcher.dart';
import 'package:namida/class/split_config.dart';
import 'package:namida/class/track.dart';
import 'package:namida/class/video.dart';
import 'package:namida/controller/history_controller.dart';
import 'package:namida/controller/indexer_controller.dart';
import 'package:namida/controller/navigator_controller.dart';
import 'package:namida/controller/notification_controller.dart';
import 'package:namida/controller/platform/zip_manager/zip_manager.dart';
import 'package:namida/core/constants.dart';
import 'package:namida/core/enums.dart';
import 'package:namida/core/extensions.dart';
import 'package:namida/core/icon_fonts/broken_icons.dart';
import 'package:namida/core/translations/language.dart';
import 'package:namida/core/utils.dart';
import 'package:namida/ui/dialogs/track_advanced_dialog.dart';
import 'package:namida/ui/widgets/custom_widgets.dart';
import 'package:namida/youtube/class/youtube_id.dart';
import 'package:namida/youtube/controller/youtube_history_controller.dart';
import 'package:namida/youtube/controller/youtube_import_controller.dart';
import 'package:namida/youtube/controller/youtube_info_controller.dart';

class JsonToHistoryParser {
  static JsonToHistoryParser get inst => _instance;
  static final JsonToHistoryParser _instance = JsonToHistoryParser._internal();
  JsonToHistoryParser._internal();

  final parsedHistoryJson = 0.obs;
  final totalJsonToParse = 0.obs;
  final addedHistoryJsonToPlaylist = 0.obs;
  final isParsing = false.obs;
  final _loadingFileProgress = (0, 0).obs;
  final _updatingYoutubeStatsDirectoryProgress = 0.obs;
  final _updatingYoutubeStatsDirectoryTotal = 0.obs;
  final Rx<TrackSource> currentParsingSource = TrackSource.local.obs;
  final _currentOldestDate = Rxn<DateTime>();
  final _currentNewestDate = Rxn<DateTime>();

  String get _parsedProgressR => '${parsedHistoryJson.valueR.formatDecimal()} / ${totalJsonToParse.valueR.formatDecimal()}';
  String get _parsedProgressPercentageR => '${(_percentageR * 100).round()}%';
  String get _addedHistoryJsonR => addedHistoryJsonToPlaylist.valueR.formatDecimal();
  double get _percentageR {
    final p = parsedHistoryJson.valueR / totalJsonToParse.valueR;
    return p.isFinite ? p : 0;
  }

  bool _isShowingParsingMenu = false;

  void _hideParsingDialog() => _isShowingParsingMenu = false;

  void showParsingProgressDialog() {
    if (_isShowingParsingMenu) return;
    Widget getTextWidget(String text, {TextStyle? style}) {
      return Text(text, style: style ?? namida.textTheme.displayMedium);
    }

    _isShowingParsingMenu = true;
    final dateText = _currentNewestDate.value != null ? "(${_currentOldestDate.value!.dateFormattedOriginal} → ${_currentNewestDate.value!.dateFormattedOriginal})" : '';

    NamidaNavigator.inst.navigateDialog(
      onDismissing: _hideParsingDialog,
      dialog: CustomBlurryDialog(
        normalTitleStyle: true,
        titleWidgetInPadding: Obx(
          (context) {
            final title = '${isParsing.valueR ? lang.extractingInfo : lang.done} ($_parsedProgressPercentageR)';
            return Text(
              "$title ${isParsing.valueR ? '' : ' ✓'}",
              style: namida.textTheme.displayLarge,
            );
          },
        ),
        actions: [
          NamidaTextButton(
            text: lang.confirm,
            onTap: () {
              _hideParsingDialog();
              NamidaNavigator.inst.closeDialog();
            },
          ),
        ],
        child: Padding(
          padding: const EdgeInsets.all(12.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ObxO(
                rx: _loadingFileProgress,
                builder: (context, loadingProgress) {
                  final count = loadingProgress.$1;
                  final total = loadingProgress.$2;
                  return getTextWidget(
                    '${lang.loadingFile}... $count/$total ${count >= total ? lang.done : ''}',
                  );
                },
              ),
              const SizedBox(height: 10.0),
              Obx((context) => getTextWidget('$_parsedProgressR ${lang.parsed}')),
              const SizedBox(height: 10.0),
              Obx((context) => getTextWidget('$_addedHistoryJsonR ${lang.added}')),
              const SizedBox(height: 4.0),
              if (dateText != '') ...[
                getTextWidget(dateText, style: namida.textTheme.displaySmall),
                const SizedBox(height: 4.0),
              ],
              const SizedBox(height: 4.0),
              Obx((context) {
                final shouldShow = currentParsingSource.valueR == TrackSource.youtube || currentParsingSource.valueR == TrackSource.youtubeMusic;
                return shouldShow
                    ? getTextWidget('${lang.stats}: ${_updatingYoutubeStatsDirectoryProgress.valueR}/${_updatingYoutubeStatsDirectoryTotal.valueR}')
                    : const SizedBox();
              }),
            ],
          ),
        ),
      ),
    );
  }

  bool get shouldShowMissingEntriesDialog => _latestMissingMap.valueR.isNotEmpty && _latestMissingMap.length != _latestMissingMapAddedStatus.length;
  final _latestMissingMap = <_MissingListenEntry, List<int>>{}.obs;
  final _latestMissingMapAddedStatus = <_MissingListenEntry, Track>{}.obs;

  void showMissingEntriesDialog() {
    if (_latestMissingMap.value.isEmpty) return;

    Future<void> addTrackToHistory(MapEntry<_MissingListenEntry, List<int>> entry, Track choosen) async {
      final twds = entry.value.map(
        (e) => TrackWithDate(
          dateAdded: e,
          track: choosen,
          source: entry.key.source,
        ),
      );
      await HistoryController.inst.addTracksToHistory(twds.toList());
      _latestMissingMapAddedStatus[entry.key] = choosen;
    }

    List<int> addTrackToHistoryOnly(MapEntry<_MissingListenEntry, List<int>> entry, Track choosen) {
      final twds = entry.value.map(
        (e) => TrackWithDate(
          dateAdded: e,
          track: choosen,
          source: entry.key.source,
        ),
      );
      final days = HistoryController.inst.addTracksToHistoryOnly(twds.toList(), preventDuplicate: true);
      _latestMissingMapAddedStatus[entry.key] = choosen;
      return days;
    }

    void pickTrack(MapEntry<_MissingListenEntry, List<int>> entry) {
      showLibraryTracksChooseDialog(
        trackName: "${entry.key.artistOrChannel} - ${entry.key.title}",
        onChoose: (choosenTrack) async {
          await addTrackToHistory(entry, choosenTrack);
          NamidaNavigator.inst.closeDialog();
        },
      );
    }

    Track getDummyTrack(_MissingListenEntry missingListen) {
      return Track.explicit('namida_dummy/${missingListen.source.name}/${missingListen.artistOrChannel} - ${missingListen.title}');
    }

    void confirmAddAsDummy({required String confirmMessage, required Future<void> Function() onConfirm}) {
      NamidaNavigator.inst.navigateDialog(
        dialog: CustomBlurryDialog(
          normalTitleStyle: true,
          isWarning: true,
          title: lang.confirm,
          bodyText: confirmMessage,
          actions: [
            const CancelButton(),
            NamidaButton(
              onTap: () async {
                await onConfirm();
                NamidaNavigator.inst.closeDialog();
              },
              text: lang.confirm,
            ),
          ],
        ),
      );
    }

    final showAddAsDummyIcon = false.obs;

    NamidaNavigator.inst.navigateDialog(
      onDisposing: () {
        showAddAsDummyIcon.close();
      },
      dialog: CustomBlurryDialog(
        normalTitleStyle: true,
        title: lang.missingEntries,
        trailingWidgets: [
          const SizedBox(width: 4.0),
          Obx(
            (context) => NamidaIconButton(
              horizontalPadding: 4.0,
              icon: Broken.command_square,
              iconSize: 24.0,
              onPressed: () async {
                confirmAddAsDummy(
                  confirmMessage: 'Add ${_latestMissingMap.value.entries.length} as dummy tracks?',
                  onConfirm: () async {
                    final historyDays = <int>[];
                    final missing = _latestMissingMap.value.entries.toFixedList()..sortByReverse((e) => e.value.length);
                    for (var e in missing) {
                      final replacedWithTrack = _latestMissingMapAddedStatus[e.key];
                      if (replacedWithTrack == null) {
                        historyDays.addAll(addTrackToHistoryOnly(e, getDummyTrack(e.key)));
                      }
                    }

                    HistoryController.inst.removeDuplicatedItems(historyDays);
                    HistoryController.inst.sortHistoryTracks(historyDays);
                    await HistoryController.inst.saveHistoryToStorage(historyDays);
                    HistoryController.inst.updateMostPlayedPlaylist();

                    NamidaNavigator.inst.closeDialog();
                  },
                );
              },
            ).animateEntrance(showWhen: showAddAsDummyIcon.valueR),
          ),
          NamidaIconButton(
            horizontalPadding: 4.0,
            icon: Broken.eye,
            onPressed: () {
              showAddAsDummyIcon.toggle();
            },
          ),
          const SizedBox(width: 4.0),
        ],
        child: SizedBox(
          width: namida.width,
          height: namida.height * 0.6,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Padding(
                padding: const EdgeInsets.all(8.0),
                child: Text(
                  lang.historyImportMissingEntriesNote,
                  style: namida.textTheme.displaySmall,
                ),
              ),
              Expanded(
                child: Obx(
                  (context) {
                    final missing = _latestMissingMap.valueR.entries.toFixedList()..sortByReverse((e) => e.value.length);
                    return NamidaScrollbarWithController(
                      child: (sc) => SuperSmoothListView.separated(
                        controller: sc,
                        separatorBuilder: (context, index) => const SizedBox(height: 8.0),
                        itemCount: missing.length,
                        itemBuilder: (context, index) {
                          final entry = missing[index];
                          return Obx(
                            (context) {
                              final replacedWithTrack = _latestMissingMapAddedStatus[entry.key];
                              return IgnorePointer(
                                ignoring: replacedWithTrack != null,
                                child: AnimatedOpacity(
                                  duration: const Duration(milliseconds: 200),
                                  opacity: replacedWithTrack != null ? 0.6 : 1.0,
                                  child: NamidaInkWell(
                                    onTap: () => pickTrack(entry),
                                    bgColor: namida.theme.cardTheme.color,
                                    borderRadius: 12.0,
                                    padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 6.0),
                                    child: Row(
                                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                                      children: [
                                        NamidaInkWell(
                                          bgColor: namida.theme.cardTheme.color,
                                          borderRadius: 42.0,
                                          padding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 2.0),
                                          child: Text(
                                            entry.value.length.formatDecimal(),
                                            style: namida.textTheme.displaySmall,
                                          ),
                                        ),
                                        const SizedBox(width: 12.0),
                                        Expanded(
                                          child: Column(
                                            crossAxisAlignment: CrossAxisAlignment.start,
                                            mainAxisSize: MainAxisSize.min,
                                            children: [
                                              Text(
                                                entry.key.title,
                                                style: namida.textTheme.displayMedium,
                                              ),
                                              Text(
                                                "${entry.key.artistOrChannel} - ${entry.key.source.name}",
                                                maxLines: 1,
                                                softWrap: false,
                                                overflow: TextOverflow.ellipsis,
                                                style: namida.textTheme.displaySmall,
                                              ),
                                              if (replacedWithTrack != null)
                                                Text(
                                                  "→ ${replacedWithTrack.originalArtist} - ${replacedWithTrack.title}",
                                                  maxLines: 2,
                                                  softWrap: false,
                                                  overflow: TextOverflow.ellipsis,
                                                  style: namida.textTheme.displaySmall?.copyWith(fontSize: 11.5),
                                                ),
                                            ],
                                          ),
                                        ),
                                        const SizedBox(width: 4.0),
                                        NamidaIconButton(
                                          horizontalPadding: 4.0,
                                          icon: Broken.repeat_circle,
                                          iconSize: 24.0,
                                          onPressed: () => pickTrack(entry),
                                        ),
                                        Obx(
                                          (context) => NamidaIconButton(
                                            horizontalPadding: 2.0,
                                            icon: Broken.command,
                                            iconSize: 20.0,
                                            onPressed: () => confirmAddAsDummy(
                                              confirmMessage: 'Add "${entry.key.artistOrChannel} - ${entry.key.title}" as dummy track?',
                                              onConfirm: () async => await addTrackToHistory(entry, getDummyTrack(entry.key)),
                                            ),
                                          ).animateEntrance(showWhen: showAddAsDummyIcon.valueR),
                                        ),
                                        const SizedBox(width: 2.0),
                                      ],
                                    ),
                                  ),
                                ),
                              );
                            },
                          );
                        },
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void _resetValues() {
    isParsing.value = false;
    _loadingFileProgress.value = (0, 0);

    totalJsonToParse.value = 0;
    parsedHistoryJson.value = 0;
    addedHistoryJsonToPlaylist.value = 0;
    _updatingYoutubeStatsDirectoryProgress.value = 0;
    _updatingYoutubeStatsDirectoryTotal.value = 0;
    _currentOldestDate.value = null;
    _currentNewestDate.value = null;
  }

  Timer? _notificationTimer;

  Future<void> addFilesSourceToNamidaHistory({
    required List<File> files,
    Directory? mainDirectory,
    required TrackSource source,
    bool matchAll = false,
    bool ytIsMatchingTypeLink = true,
    bool isMatchingTypeTitleAndArtist = false,
    bool ytMatchYT = true,
    bool ytMatchYTMusic = true,
    DateTime? oldestDate,
    DateTime? newestDate,
  }) async {
    _resetValues();
    isParsing.value = true;
    _currentOldestDate.value = oldestDate;
    _currentNewestDate.value = newestDate;
    showParsingProgressDialog();

    Directory? tempZipMainDestination;

    final contents = mainDirectory != null && files.isEmpty ? await mainDirectory.listAllIsolate(recursive: true, followLinks: false) : files;
    files = await _filterFilesFromPossibleZips(
      contents,
      source,
      () async => tempZipMainDestination ??= await Directory.systemTemp.createTemp('namida_parser_'),
      (progress, total) => _loadingFileProgress.value = (progress, total),
    );

    if (files.isEmpty) {
      snackyy(message: 'No related files were found in this directory.', isError: true);
      _resetValues();
      NamidaNavigator.inst.closeDialog();
      return;
    }

    // TODO: warning to backup history

    await Future.delayed(Duration.zero);

    NotificationManager.instance.ensurePermissionGranted();
    final startTime = DateTime.now();
    _notificationTimer?.cancel();
    _notificationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
      NotificationManager.instance.importHistoryNotification(parsedHistoryJson.value, totalJsonToParse.value, startTime);
    });

    final datesAdded = <int>[];
    final datesAddedYoutube = <int>[];
    var allMissingEntries = <_MissingListenEntry, List<int>>{};

    switch (source) {
      case TrackSource.youtube || TrackSource.youtubeMusic:
        currentParsingSource.value = TrackSource.youtube;
        final res = await _parseYTHistoryJsonAndAdd(
          files: files,
          isMatchingTypeLink: ytIsMatchingTypeLink,
          isMatchingTypeTitleAndArtist: isMatchingTypeTitleAndArtist,
          matchYT: ytMatchYT,
          matchYTMusic: ytMatchYTMusic,
          oldestDate: oldestDate,
          newestDate: newestDate,
          matchAll: matchAll,
        );
        if (res != null) {
          allMissingEntries = res.missingEntries;
          datesAdded.addAll(res.historyDays);
          datesAddedYoutube.addAll(res.ytHistoryDays);
        }
        break;

      case TrackSource.lastfm:
        currentParsingSource.value = TrackSource.lastfm;
        final res = await _addLastFmSource(
          files: files,
          matchAll: matchAll,
          oldestDate: oldestDate,
          newestDate: newestDate,
        );
        if (res != null) {
          allMissingEntries = res.missingEntries;
          datesAdded.addAll(res.historyDays);
        }
        break;
      case TrackSource.local:
        break;
    }

    // -- local history --
    HistoryController.inst.removeDuplicatedItems(datesAdded);
    HistoryController.inst.sortHistoryTracks(datesAdded);
    await HistoryController.inst.saveHistoryToStorage(datesAdded);
    HistoryController.inst.updateMostPlayedPlaylist();

    // -- youtube history --
    if (datesAddedYoutube.isNotEmpty) {
      YoutubeHistoryController.inst.removeDuplicatedItems(datesAddedYoutube);
      YoutubeHistoryController.inst.sortHistoryTracks(datesAddedYoutube);
      await YoutubeHistoryController.inst.saveHistoryToStorage(datesAddedYoutube);
      YoutubeHistoryController.inst.updateMostPlayedPlaylist();
    }

    isParsing.value = false;

    _notificationTimer?.cancel();
    NotificationManager.instance.doneImportingHistoryNotification(parsedHistoryJson.value, addedHistoryJsonToPlaylist.value);

    _latestMissingMap.value = allMissingEntries;
    _latestMissingMapAddedStatus.clear();
    showMissingEntriesDialog();
    tempZipMainDestination?.delete(recursive: true);
  }

  Future<List<File>> _filterFilesFromPossibleZips(
    List<FileSystemEntity> contents,
    TrackSource source,
    Future<Directory> Function() tempZipMainDestination,
    void Function(int progress, int total) onProgress,
  ) async {
    final files = <File>[];

    late final zipManager = ZipManager.platform();

    Stream<FileSystemEntity> listDir(Directory dir) => dir.list(recursive: true, followLinks: false);
    FutureOr<void> executeFileEnsureZipExtracted(File file, void Function(File file) onMatch) async {
      if (NamidaFileExtensionsWrapper.zip.isPathValid(file.path)) {
        final tempDir = (await tempZipMainDestination()).path;
        final destinationDir = Directory(FileParts.joinPath(tempDir, file.path.getFilenameWOExt));
        try {
          await zipManager.extractZip(zipFile: file, destinationDir: destinationDir);
          final zipContents = listDir(destinationDir);
          await for (final file in zipContents) {
            if (file is File) {
              onMatch(file);
            }
          }
        } catch (_) {
          // on windows: most likely RangeError in package:archive.InputMemoryStream.readByte
        }
      } else {
        onMatch(file);
      }
    }

    int progress = 0;
    final int total = contents.length;
    if (total == 0) return [];

    if (source == TrackSource.youtube || source == TrackSource.youtubeMusic) {
      for (final file in contents) {
        progress++;
        onProgress(progress, total);
        if (file is File) {
          await executeFileEnsureZipExtracted(
            file,
            (file) {
              if (NamidaFileExtensionsWrapper.json.isPathValid(file.path)) {
                final name = file.path.getFilename;
                if (name.contains('watch-history')) files.add(file);
              }
            },
          );
        }
      }
    } else {
      for (final file in contents) {
        progress++;
        onProgress(progress, total);
        if (file is File) {
          await executeFileEnsureZipExtracted(
            file,
            (file) {
              if (NamidaFileExtensionsWrapper.csv.isPathValid(file.path)) {
                // folder shouldnt contain yt playlists/etc csv files tho, otherwise wer cooked
                final name = file.path.getFilename;
                if (name != 'subscriptions.csv') files.add(file);
              }
            },
          );
        }
      }
    }
    return files;
  }

  Future<({List<int> historyDays, List<int> ytHistoryDays, Map<_MissingListenEntry, List<int>> missingEntries})?> _parseYTHistoryJsonAndAdd({
    required List<File> files,
    required bool isMatchingTypeLink,
    required bool isMatchingTypeTitleAndArtist,
    required bool matchYT,
    required bool matchYTMusic,
    required DateTime? oldestDate,
    required DateTime? newestDate,
    required bool matchAll,
  }) async {
    final portProgressParsed = RawReceivePort((message) {
      parsedHistoryJson.value += message as int;
    });
    final portProgressAdded = RawReceivePort((message) {
      addedHistoryJsonToPlaylist.value += message as int;
    });
    final portLoadingProgress = ReceivePort();

    final params = {
      'tracks': Indexer.inst.allTracksMappedByPath.values
          .map(
            (e) => {
              'title': e.title,
              'album': e.originalAlbum,
              'artist': e.originalArtist,
              'path': e.path,
              'filename': e.filename,
              'comment': e.comment,
              'v': e.isVideo,
            },
          )
          .toFixedList(),
      'files': files,
      'isMatchingTypeLink': isMatchingTypeLink,
      'isMatchingTypeTitleAndArtist': isMatchingTypeTitleAndArtist,
      'matchYT': matchYT,
      'matchYTMusic': matchYTMusic,
      'oldestDay': oldestDate?.toDaysSince1970(),
      'newestDay': newestDate?.toDaysSince1970(),
      'matchAll': matchAll,
      'artistsSplitConfig': ArtistsSplitConfig.settings().toMap(),
      'portProgressParsed': portProgressParsed.sendPort,
      'portProgressAdded': portProgressAdded.sendPort,
      'portLoadingProgress': portLoadingProgress.sendPort,
      'localHistory': HistoryController.inst.historyMap.value,
      'ytHistory': YoutubeHistoryController.inst.historyMap.value,
    };

    StreamSubscription? portLoadingProgressSub;
    portLoadingProgressSub = portLoadingProgress.listen((message) {
      message as int;
      final shouldClosePort = !message.isNegative;
      message = message.abs();
      totalJsonToParse.value = message;
      _loadingFileProgress.value = (_loadingFileProgress.value.$2, _loadingFileProgress.value.$2);
      if (shouldClosePort) {
        portLoadingProgress.close();
        portLoadingProgressSub?.cancel();
      }
    });
    HistoryController.inst.setIdleStatus(true);
    YoutubeHistoryController.inst.setIdleStatus(true);

    final res = await _parseYTHistoryJsonAndAddIsolate.thready(params);
    portProgressParsed.close();
    portProgressAdded.close();

    if (res != null) {
      final mapOfAffectedIds = res.affectedIds;

      if (mapOfAffectedIds != null) {
        _updatingYoutubeStatsDirectoryTotal.value = mapOfAffectedIds.length;
        await _updateYoutubeStatsDirectory(
          affectedIds: mapOfAffectedIds,
          onProgress: (updatedIdsCount) {
            _updatingYoutubeStatsDirectoryProgress.value += updatedIdsCount;
            printy('updatedIds: $updatedIdsCount');
          },
        );
        YoutubeInfoController.utils.fillBackupInfoMap();
      }

      HistoryController.inst.historyMap.value = res.localHistory;
      YoutubeHistoryController.inst.historyMap.value = res.ytHistory;
      if (res.addedLocalHistoryCount > 0) {
        HistoryController.inst.totalHistoryItemsCount.value += res.addedLocalHistoryCount;
        HistoryController.inst.totalHistoryItemsCount.refresh();
      }
      if (res.addedYTHistoryCount > 0) {
        YoutubeHistoryController.inst.totalHistoryItemsCount.value += res.addedYTHistoryCount;
        YoutubeHistoryController.inst.totalHistoryItemsCount.refresh();
      }
    }

    await Future.wait([
      HistoryController.inst.setIdleStatus(false),
      YoutubeHistoryController.inst.setIdleStatus(false),
    ]);

    return res == null
        ? null
        : (
            historyDays: res.daysToSaveLocal,
            ytHistoryDays: res.daysToSaveYT,
            missingEntries: res.missingEntries,
          );
  }

  Future<(int, int)> copyYTHistoryContentToLocalHistory({required bool matchAll}) async {
    final allTracks = Indexer.inst.allTracksMappedByPath.values;
    final allInsideYTHistory = YoutubeHistoryController.inst.historyTracks;
    final tracksIdsMap = <String, List<Track>>{};

    for (final trExt in allTracks) {
      final videoId = trExt.youtubeID;
      if (videoId.isNotEmpty) {
        tracksIdsMap.addForce(videoId, trExt.asTrack());
      }
    }
    int totalCount = 0;
    int removedDuplicates = 0;
    final historyMap = HistoryController.inst.historyMap.value;
    final datesAdded = <int>[];
    for (final vh in allInsideYTHistory) {
      final match = tracksIdsMap[vh.id];
      if (match != null && match.isNotEmpty) {
        final tracks = matchAll ? match : [match.first];
        final tracksWithDates = tracks
            .map(
              (e) => TrackWithDate(
                dateAdded: vh.dateAddedMS,
                track: e,
                source: vh.watch.isYTMusic ? TrackSource.youtubeMusic : TrackSource.youtube,
              ),
            )
            .toList();
        final day = vh.dateAddedMS.toDaysSince1970();
        final dayLengthBefore = historyMap[day]?.length ?? 0;
        totalCount += tracksWithDates.length;
        final days = HistoryController.inst.addTracksToHistoryOnly(tracksWithDates, preventDuplicate: true);
        final dayLengthAfter = historyMap[day]?.length ?? 0;
        final actuallyAddedCount = (dayLengthAfter - dayLengthBefore);
        removedDuplicates += tracks.length - actuallyAddedCount;
        datesAdded.addAll(days);
      }
    }
    if (datesAdded.isNotEmpty) {
      removedDuplicates += HistoryController.inst.removeDuplicatedItems(datesAdded);
      HistoryController.inst.sortHistoryTracks(datesAdded);
      await HistoryController.inst.saveHistoryToStorage(datesAdded);
      HistoryController.inst.updateMostPlayedPlaylist();
      return (totalCount, totalCount - removedDuplicates);
    }
    return (totalCount, 0);
  }

  /// Returns [daysToSave] to be used by [sortHistoryTracks] && [saveHistoryToStorage].
  static Future<
    ({
      Map<String, YoutubeVideoHistory>? affectedIds,
      List<int> daysToSaveLocal,
      List<int> daysToSaveYT,
      int addedLocalHistoryCount,
      int addedYTHistoryCount,
      SplayTreeMap<int, List<TrackWithDate>> localHistory,
      SplayTreeMap<int, List<YoutubeID>> ytHistory,
      Map<_MissingListenEntry, List<int>> missingEntries,
    })?
  >
  _parseYTHistoryJsonAndAddIsolate(Map params) async {
    final allTracks = params['tracks'] as List<Map>;
    final files = params['files'] as List<File>;
    final isMatchingTypeLink = params['isMatchingTypeLink'] as bool;
    final isMatchingTypeTitleAndArtist = params['isMatchingTypeTitleAndArtist'] as bool;
    final matchYT = params['matchYT'] as bool;
    final matchYTMusic = params['matchYTMusic'] as bool;
    final oldestDay = params['oldestDay'] as int?;
    final newestDay = params['newestDay'] as int?;
    final matchAll = params['matchAll'] as bool;
    final artistsSplitConfig = ArtistsSplitConfig.fromMap(params['artistsSplitConfig']);

    final localHistory = params['localHistory'] as SplayTreeMap<int, List<TrackWithDate>>;
    final ytHistory = params['ytHistory'] as SplayTreeMap<int, List<YoutubeID>>;

    int addedLocalHistoryCount = 0;
    int addedYTHistoryCount = 0;

    final portProgressParsed = params['portProgressParsed'] as SendPort;
    final portProgressAdded = params['portProgressAdded'] as SendPort;
    final portLoadingProgress = params['portLoadingProgress'] as SendPort;

    Map<String, List<Track>>? tracksIdsMap;
    if (isMatchingTypeLink) {
      tracksIdsMap = <String, List<Track>>{};
      for (var trMap in allTracks) {
        String? videoId = NamidaLinkUtils.extractYoutubeId(trMap['comment'] as String? ?? '');
        videoId ??= NamidaLinkUtils.extractYoutubeId(trMap['filename'] as String? ?? '');
        if (videoId != null && videoId.isNotEmpty) {
          tracksIdsMap.addForce(videoId, Track.decide(trMap['path'], trMap['v']));
        }
      }
    }

    final reverseTitleMatcher = ReverseSearchMatcher<Map>();
    final reverseArtistMatcher = ReverseSearchMatcher<Map>();
    final reverseAlbumMatcher = ReverseSearchMatcher<Map>();
    if (isMatchingTypeTitleAndArtist) {
      for (var trMap in allTracks) {
        final title = trMap['title'] as String;
        final album = trMap['album'] as String;
        final originalArtist = trMap['artist'] as String;
        final artistsList = Indexer.splitArtist(
          title: title,
          originalArtist: originalArtist,
          config: artistsSplitConfig,
        );
        reverseTitleMatcher.addItemWithTokens(trMap, title);
        reverseAlbumMatcher.addItemWithTokens(trMap, album);
        if (artistsList.isNotEmpty) {
          reverseArtistMatcher.addItemWithTokens(trMap, artistsList.first);
        }
      }
    }

    int jsonResponseTotal = 0;
    for (final file in files) {
      jsonResponseTotal += await JsonToHistoryParser._countJsonObjectsInList(file);
    }
    portLoadingProgress.send(jsonResponseTotal); // 1

    final mapOfAffectedIds = <String, YoutubeVideoHistory>{};
    final missingEntries = <_MissingListenEntry, List<int>>{};
    int totalParsed = 0;
    int totalAdded = 0;
    final daysToSaveLocal = <int>[];
    final daysToSaveYT = <int>[];
    const chunkSize = 20;
    for (final file in files) {
      List? list = jsonDecode(file.readAsStringSync()) as List?;
      if (list == null) continue;

      for (final p in list) {
        totalParsed++;
        try {
          final link = utf8.decode((p['titleUrl']).toString().codeUnits);
          final id = link.length >= 11 ? link.substring(link.length - 11) : link;
          final z = List<Map<String, dynamic>>.from((p['subtitles'] ?? []));

          /// matching in real time, each object.
          final yth = YoutubeVideoHistory(
            id: id,
            title: (p['title'] as String).replaceFirst('Watched ', ''),
            channel: z.isNotEmpty ? z.first['name'] : '',
            channelUrl: z.isNotEmpty ? utf8.decode((z.first['url']).toString().codeUnits) : '',
            watches: [
              YTWatch(
                dateMSNull: YoutubeImportController.parseDate(p['time'] ?? '')?.millisecondsSinceEpoch,
                isYTMusic: p['header'] == "YouTube Music",
              ),
            ],
          );
          // -- updating affected ids map, used to update youtube stats
          if (mapOfAffectedIds[id] != null) {
            mapOfAffectedIds[id] = YoutubeVideoHistory.merge(current: mapOfAffectedIds[id], newRes: yth);
            mapOfAffectedIds[id]!.watches.addAllNoDuplicates(yth.watches.map((e) => YTWatch(dateMSNull: e.dateMSNull, isYTMusic: e.isYTMusic)));
          } else {
            mapOfAffectedIds[id] = yth;
          }
          // ---------------------------------------------------------
          // -- local history --
          final tracks = _matchYTVHToNamidaHistory(
            vh: yth,
            matchYT: matchYT,
            matchYTMusic: matchYTMusic,
            oldestDay: oldestDay,
            newestDay: newestDay,
            matchAll: matchAll,
            tracksIdsMap: tracksIdsMap,
            matchByTitleAndArtistIfNotFoundInMap: isMatchingTypeTitleAndArtist,
            onMissingEntries: (entries) {
              for (final e in entries) {
                missingEntries.addForce(e, e.dateMSSE);
              }
            },
            allTracks: allTracks,
            artistsSplitConfig: artistsSplitConfig,
            reverseTitleMatcher: reverseTitleMatcher,
            reverseArtistMatcher: reverseArtistMatcher,
            reverseAlbumMatcher: reverseAlbumMatcher,
          );
          totalAdded += tracks.length;
          for (var item in tracks) {
            final day = item.dateAdded.toDaysSince1970();
            final tracks = localHistory[day] ??= [];
            if (!tracks.contains(item)) {
              daysToSaveLocal.add(day);
              tracks.add(item);
              addedLocalHistoryCount++;
            }
          }

          // -- youtube history --
          for (var w in yth.watches) {
            final canAdd = _canSafelyAddToYTHistory(
              watch: w,
              matchYT: matchYT,
              matchYTMusic: matchYTMusic,
              newestDay: newestDay,
              oldestDay: oldestDay,
            );
            if (canAdd) {
              final ytid = YoutubeID(
                id: yth.id,
                source: w.isYTMusic ? TrackSource.youtubeMusic : TrackSource.youtube,
                watchNull: w,
                playlistID: null,
              );
              final day = ytid.dateAddedMS.toDaysSince1970();
              final videos = ytHistory[day] ??= [];
              if (!videos.contains(ytid)) {
                daysToSaveYT.add(day);
                videos.add(ytid);
                addedYTHistoryCount++;
              }
            }
          }

          if (totalParsed >= chunkSize) {
            portProgressParsed.send(totalParsed);
            totalParsed = 0;
          }
          if (totalAdded >= chunkSize) {
            portProgressAdded.send(totalAdded);
            totalAdded = 0;
          }
        } catch (e) {
          printo(e, isError: true);
          continue;
        }
      }
      list = null;
    }
    // jsonResponsesList.clear();

    portProgressParsed.send(totalParsed);
    portProgressAdded.send(totalAdded);

    return (
      affectedIds: mapOfAffectedIds,
      daysToSaveLocal: daysToSaveLocal,
      daysToSaveYT: daysToSaveYT,
      addedLocalHistoryCount: addedLocalHistoryCount,
      addedYTHistoryCount: addedYTHistoryCount,
      localHistory: localHistory,
      ytHistory: ytHistory,
      missingEntries: missingEntries,
    );
  }

  static bool _canSafelyAddToYTHistory({
    required YTWatch watch,
    int? oldestDay,
    int? newestDay,
    required bool matchYT,
    required bool matchYTMusic,
  }) {
    // ---- sussy checks ----

    // -- if the watch day is outside range specified
    if (oldestDay != null && newestDay != null) {
      final watchAsDSE = watch.dateMS.toDaysSince1970();
      if (watchAsDSE < oldestDay || watchAsDSE > newestDay) return false;
    }

    // -- if the type is youtube music, but the user dont want ytm.
    if (watch.isYTMusic && !matchYTMusic) return false;

    // -- if the type is youtube, but the user dont want yt.
    if (!watch.isYTMusic && !matchYT) return false;

    return true;
  }

  static List<TrackWithDate> _matchYTVHToNamidaHistory({
    required YoutubeVideoHistory vh,
    required bool matchYT,
    required bool matchYTMusic,
    required int? oldestDay,
    required int? newestDay,
    required bool matchAll,
    required Map<String, List<Track>>? tracksIdsMap,
    required bool matchByTitleAndArtistIfNotFoundInMap,
    required void Function(Iterable<_MissingListenEntry> missingEntries) onMissingEntries,
    required ArtistsSplitConfig artistsSplitConfig,
    required List<Map> allTracks,
    required ReverseSearchMatcher<Map> reverseTitleMatcher,
    required ReverseSearchMatcher<Map> reverseArtistMatcher,
    required ReverseSearchMatcher<Map> reverseAlbumMatcher,
  }) {
    Iterable<Track> tracks = <Track>[];

    if (tracksIdsMap != null) {
      final match = tracksIdsMap[vh.id];
      if (match != null && match.isNotEmpty) {
        tracks = matchAll ? match : [match.first];
      }
    }

    if (tracks.isEmpty && matchByTitleAndArtistIfNotFoundInMap) {
      final titleCleaned = vh.title.cleanUpForComparison;
      final channelCleaned = vh.channel.cleanUpForComparison;

      final titleMatches = reverseTitleMatcher.matchContainedIn(titleCleaned);
      if (titleMatches.isNotEmpty) {
        /// matching has to meet 2 conditons:
        /// 1. [json title] contains [track.title]
        /// 2. - [json title] contains [track.artistsList.first]
        ///     or
        ///    - [json channel] contains [track.album]
        ///    (useful for nightcore channels, album has to be the channel name)
        ///     or
        ///    - [json channel] contains [track.artistsList.first]
        final artistInTitle = reverseArtistMatcher.matchContainedIn(titleCleaned);
        final albumInChannel = reverseAlbumMatcher.matchContainedIn(channelCleaned);
        final artistInChannel = reverseArtistMatcher.matchContainedIn(channelCleaned);

        final secondCondition = artistInTitle.union(albumInChannel).union(artistInChannel);
        final matched = secondCondition.isEmpty ? titleMatches : titleMatches.intersection(secondCondition);

        final result = matchAll ? matched : (matched.isEmpty ? <Map>{} : {matched.first});
        tracks = result.map((e) => Track.decide(e['path'] ?? '', e['v']));
      }
    }

    final tracksToAdd = <TrackWithDate>[];
    if (tracks.isNotEmpty) {
      for (var d in vh.watches) {
        final canAdd = _canSafelyAddToYTHistory(
          watch: d,
          matchYT: matchYT,
          matchYTMusic: matchYTMusic,
          newestDay: newestDay,
          oldestDay: oldestDay,
        );
        if (canAdd) {
          tracksToAdd.addAll(
            tracks.map(
              (tr) => TrackWithDate(
                dateAdded: d.dateMS,
                track: tr,
                source: d.isYTMusic ? TrackSource.youtubeMusic : TrackSource.youtube,
              ),
            ),
          );
        }
      }
    } else {
      onMissingEntries(
        vh.watches.map(
          (e) => _MissingListenEntry(
            youtubeID: vh.id,
            dateMSSE: e.dateMS,
            source: e.isYTMusic ? TrackSource.youtubeMusic : TrackSource.youtube,
            artistOrChannel: vh.channel,
            title: vh.title,
          ),
        ),
      );
    }
    return tracksToAdd;
  }

  /// Returns [daysToSave] to be used by [sortHistoryTracks] && [saveHistoryToStorage].
  Future<({List<int> historyDays, Map<_MissingListenEntry, List<int>> missingEntries})?> _addLastFmSource({
    required List<File> files,
    required bool matchAll,
    required DateTime? oldestDate,
    required DateTime? newestDate,
  }) async {
    final portProgressParsed = RawReceivePort((message) {
      parsedHistoryJson.value += message as int;
    });
    final portProgressAdded = RawReceivePort((message) {
      addedHistoryJsonToPlaylist.value += message as int;
    });
    final portLoadingProgress = ReceivePort();

    final params = {
      'tracks': Indexer.inst.allTracksMappedByPath.values
          .map(
            (e) => {
              'title': e.title,
              'artist': e.originalArtist,
              'path': e.path,
              'v': e.isVideo,
            },
          )
          .toFixedList(),
      'oldestDay': oldestDate?.toDaysSince1970(),
      'newestDay': newestDate?.toDaysSince1970(),
      'files': files,
      'matchAll': matchAll,
      'artistsSplitConfig': ArtistsSplitConfig.settings().toMap(),
      'portProgressParsed': portProgressParsed.sendPort,
      'portProgressAdded': portProgressAdded.sendPort,
      'portLoadingProgress': portLoadingProgress.sendPort,
      'localHistory': HistoryController.inst.historyMap.value,
    };
    StreamSubscription? portLoadingProgressSub;
    portLoadingProgressSub = portLoadingProgress.listen((message) {
      message as int;
      final shouldClosePort = !message.isNegative;
      message = message.abs();
      totalJsonToParse.value = message;
      _loadingFileProgress.value = (_loadingFileProgress.value.$2, _loadingFileProgress.value.$2);
      if (shouldClosePort) {
        portLoadingProgress.close();
        portLoadingProgressSub?.cancel();
      }
    });

    HistoryController.inst.setIdleStatus(true);

    final res = await _addLastFmSourceIsolate.thready(params);

    portProgressParsed.close();
    portProgressAdded.close();

    if (res != null) {
      HistoryController.inst.historyMap.value = res.localHistory;
      if (res.addedHistoryCount > 0) {
        HistoryController.inst.totalHistoryItemsCount.value = res.addedHistoryCount;
        HistoryController.inst.totalHistoryItemsCount.refresh();
      }
    }

    await HistoryController.inst.setIdleStatus(false);

    return res == null
        ? null
        : (
            historyDays: res.daysToSaveLocal,
            missingEntries: res.missingEntries,
          );
  }

  /// Returns [daysToSave] to be used by [sortHistoryTracks] && [saveHistoryToStorage].
  static Future<
    ({
      List<int> daysToSaveLocal,
      int addedHistoryCount,
      SplayTreeMap<int, List<TrackWithDate>> localHistory,
      Map<_MissingListenEntry, List<int>> missingEntries,
    })?
  >
  _addLastFmSourceIsolate(Map params) async {
    final allTracks = params['tracks'] as List<Map>;
    final oldestDay = params['oldestDay'] as int?;
    final newestDay = params['newestDay'] as int?;
    final files = params['files'] as List<File>;
    final matchAll = params['matchAll'] as bool;
    final artistsSplitConfig = ArtistsSplitConfig.fromMap(params['artistsSplitConfig']);

    final localHistory = params['localHistory'] as SplayTreeMap<int, List<TrackWithDate>>;

    int addedHistoryCount = 0;

    final portProgressParsed = params['portProgressParsed'] as SendPort;
    final portProgressAdded = params['portProgressAdded'] as SendPort;
    final portLoadingProgress = params['portLoadingProgress'] as SendPort;

    int linesCount = 0;
    for (final file in files) {
      linesCount += await JsonToHistoryParser._countLinesInFile(file);
    }

    portLoadingProgress.send(linesCount);

    final tracksLookupTitlesMap = <String, List<Map>>{};
    final tracksLookupArtistsMap = <String, List<Map>>{};

    final reverseTitleMatcher = ReverseSearchMatcher<Map>();
    final reverseArtistMatcher = ReverseSearchMatcher<Map>();

    for (final trMap in allTracks) {
      final title = trMap['title'] as String;
      tracksLookupTitlesMap.addForce(title.cleanUpForComparison, trMap);
      reverseTitleMatcher.addItemWithTokens(trMap, title.splitFirst('(').splitFirst('['));

      final originalArtist = trMap['artist'] as String;
      final artistsList = Indexer.splitArtist(
        title: title,
        originalArtist: originalArtist,
        config: artistsSplitConfig,
      );
      for (final ar in artistsList) {
        tracksLookupArtistsMap.addForce(ar.cleanUpForComparison, trMap);
      }
      if (artistsList.isNotEmpty) {
        reverseArtistMatcher.addItemWithTokens(trMap, artistsList.first);
      }
    }

    final missingEntries = <_MissingListenEntry, List<int>>{};
    int totalParsed = 0;
    int totalAdded = 0;
    final daysToSaveLocal = <int>[];
    const chunkSize = 20;

    // used for cases where date couldnt be parsed, so it uses this one as a reference
    int? lastDate;
    for (final file in files) {
      final lines = file.readAsLinesSync();

      for (final line in lines) {
        totalParsed++;

        /// artist, album, title, (dd MMM yyyy HH:mm);
        try {
          final pieces = line.split(',');

          // success means: date == trueDate && lastDate is updated.
          // failure means: date == lastDate - 30 seconds || date == 0
          // this is used for cases where date couldn't be parsed, so it'll add the track with (date == lastDate - 30 seconds)
          int date = 0;
          try {
            date = DateFormat('dd MMM yyyy HH:mm').parseLoose(pieces.last, true).millisecondsSinceEpoch;
          } catch (e) {
            if (lastDate != null) {
              date = lastDate - 30000;
            }
          }
          lastDate = date;

          // -- skips if the date is not inside date range specified.
          if (oldestDay != null && newestDay != null) {
            final watchAsDSE = date.toDaysSince1970();
            if (watchAsDSE < oldestDay || watchAsDSE > newestDay) continue;
          }

          final tracks = <Map>[];
          final csvTitleCleaned = pieces[2].cleanUpForComparison;
          final csvArtistCleaned = pieces[0].cleanUpForComparison;

          final titleMatching = tracksLookupTitlesMap[csvTitleCleaned];
          final artistMatching = tracksLookupArtistsMap[csvArtistCleaned];
          if (titleMatching != null && titleMatching.isNotEmpty && artistMatching != null && artistMatching.isNotEmpty) {
            final intersection = titleMatching.where((track) => artistMatching.contains(track));
            if (intersection.isNotEmpty) {
              if (matchAll) {
                tracks.addAll(intersection);
              } else {
                tracks.add(intersection.first);
              }
            }
          }

          if (tracks.isEmpty) {
            /// matching has to meet 2 conditons:
            /// [csv artist] contains [track.artistsList.first]
            /// [csv title] contains [track.title], anything after ( or [ is ignored.
            final titleMatches = reverseTitleMatcher.matchContainedIn(csvTitleCleaned);
            final artistMatches = reverseArtistMatcher.matchContainedIn(csvArtistCleaned);
            final matched = titleMatches.intersection(artistMatches);
            if (matchAll) {
              tracks.addAll(matched);
            } else if (matched.isNotEmpty) {
              tracks.add(matched.first);
            }
          }

          totalAdded += tracks.length;
          if (tracks.isNotEmpty) {
            for (final trMap in tracks) {
              final twd = TrackWithDate(
                dateAdded: date,
                track: Track.decide(trMap['path'] ?? '', trMap['v']),
                source: TrackSource.lastfm,
              );
              final day = twd.dateAdded.toDaysSince1970();
              final tracks = localHistory[day] ??= [];
              if (!tracks.contains(twd)) {
                daysToSaveLocal.add(day);
                tracks.add(twd);
                addedHistoryCount++;
              }
            }
          } else {
            final me = _MissingListenEntry(
              youtubeID: null,
              dateMSSE: date,
              source: TrackSource.lastfm,
              artistOrChannel: pieces[0],
              title: pieces[2],
            );
            missingEntries.addForce(me, me.dateMSSE);
          }

          /// updates progress every [chunkSize] lines, calling on every loop affects benchmarks heavily.
          if (totalParsed >= chunkSize) {
            portProgressParsed.send(totalParsed);
            totalParsed = 0;
          }
          if (totalAdded >= chunkSize) {
            portProgressAdded.send(totalAdded);
            totalAdded = 0;
          }
        } catch (e) {
          printo(e, isError: true);
          continue;
        }
      }
    }
    // normally the loop automatically adds every [chunkSize] tracks, this one is to ensure adding any tracks left.
    portProgressParsed.send(totalParsed);
    portProgressAdded.send(totalAdded);

    return (
      daysToSaveLocal: daysToSaveLocal,
      addedHistoryCount: addedHistoryCount,
      localHistory: localHistory,
      missingEntries: missingEntries,
    );
  }

  Future<void> _updateYoutubeStatsDirectory({
    required Map<String, YoutubeVideoHistory> affectedIds,
    required void Function(int updatedIdsCount) onProgress,
  }) async {
    final progressPort = RawReceivePort((message) {
      onProgress(message as int);
    });
    await _updateYoutubeStatsDirectoryIsolate.thready({
      "affectedIds": affectedIds,
      "dirPath": AppDirs.YT_STATS,
      "progressPort": progressPort.sendPort,
    });
    progressPort.close();
  }

  static void _updateYoutubeStatsDirectoryIsolate(Map params) {
    final affectedIds = params['affectedIds'] as Map<String, YoutubeVideoHistory>;
    final progressPort = params['progressPort'] as SendPort;
    final dirPath = params['dirPath'] as String;

    // ===== Getting affected files (which are arranged by id[0])
    final fileIdentifierMap = <String, Map<String, YoutubeVideoHistory>>{}; // {id[0]: {id: YoutubeVideoHistory}}
    for (final entry in affectedIds.entries) {
      final id = entry.key;
      final video = entry.value;
      final filename = id[0];
      if (fileIdentifierMap[filename] == null) {
        fileIdentifierMap[filename] = {id: video};
      } else {
        fileIdentifierMap[filename]!.addAll({id: video});
      }
    }
    // ==================================================

    // ===== looping each file and getting all videos inside
    // then mapping all to a map for instant lookup
    // then merging affected videos inside [fileIdentifierMap]
    for (final entry in fileIdentifierMap.entries) {
      final filename = entry.key; // id[0]
      final videos = entry.value; // {id: YoutubeVideoHistory}

      final file = FileParts.join(dirPath, '$filename.json');
      final res = file.readAsJsonSync();
      final videosInStorage = (res as List?)?.map((e) => YoutubeVideoHistory.fromJson(e)) ?? [];
      final videosMapInStorage = <String, YoutubeVideoHistory>{};
      for (final videoStor in videosInStorage) {
        videosMapInStorage[videoStor.id] = videoStor;
      }

      // ===========
      final updatedIds = <String>[];
      for (final affectedv in videos.entries) {
        final id = affectedv.key;
        final video = affectedv.value;
        if (videosMapInStorage[id] != null) {
          // -- video exists inside the file, so we add only new watches
          videosMapInStorage[id] = YoutubeVideoHistory.merge(current: videosMapInStorage[id], newRes: video);
          videosMapInStorage[id]!.watches.addAllNoDuplicates(video.watches.map((e) => YTWatch(dateMSNull: e.dateMSNull, isYTMusic: e.isYTMusic)));
        } else {
          // -- video does NOT exist, so the whole video is added with all its watches.
          videosMapInStorage[id] = video;
        }
        updatedIds.add(id);
      }
      file.writeAsJsonSync(videosMapInStorage.values.toFixedList());
      progressPort.send(updatedIds.length);
    }
  }

  static Future<int> _countLinesInFile(File file) async {
    var count = 0;
    var prevByte = 0;
    const LF = 10;
    const CR = 13;

    await for (final chunk in file.openRead()) {
      for (final byte in chunk) {
        if (byte != CR) {
          if (byte == LF) {
            if (prevByte != CR) count++;
          }
          prevByte = byte;
          continue;
        }
        count++;
        prevByte = byte;
      }
    }

    if (prevByte != LF && prevByte != CR && (file.fileSizeSync() ?? 0) > 0) {
      count++;
    }
    return count;
  }

  static Future<int> _countJsonObjectsInList(File file) async {
    int count = 0;
    int depth = 0;
    bool inString = false;
    bool escape = false;

    await for (final chunk in file.openRead()) {
      for (final b in chunk) {
        if (escape) {
          escape = false;
          continue;
        }
        if (b == 0x5C) {
          escape = true;
          continue;
        } // '\'
        if (b == 0x22) {
          inString = !inString;
          continue;
        } // '"'
        if (inString) continue;
        if (b == 0x7B) depth++; // '{'
        if (b == 0x7D && --depth == 0) count++; // '}'
      }
    }
    return count;
  }
}

class _MissingListenEntry {
  final int dateMSSE;
  final TrackSource source;
  final String? youtubeID;
  final String title;
  final String artistOrChannel;

  const _MissingListenEntry({
    required this.dateMSSE,
    required this.source,
    required this.title,
    required this.youtubeID,
    required this.artistOrChannel,
  });

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    if (other is! _MissingListenEntry) return false;
    return other.dateMSSE == dateMSSE && other.source == source && other.youtubeID == youtubeID && other.title == title && other.artistOrChannel == artistOrChannel;
  }

  @override
  int get hashCode {
    return dateMSSE.hashCode ^ source.hashCode ^ youtubeID.hashCode ^ title.hashCode ^ artistOrChannel.hashCode;
  }
}

extension _YTWatchListExt on List<YTWatch> {
  void addAllNoDuplicates(Iterable<YTWatch> items) {
    for (final item in items) {
      final alrExists = this.contains(item);
      if (!alrExists) this.add(item);
    }
  }
}
