import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value;
import 'package:http/http.dart';
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/providers/error_notifier.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart';
import 'package:rhythm_box/services/song_link/song_link.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';

final youtubeClient = YoutubeExplode();
final officialMusicRegex = RegExp(
  r'official\s(video|audio|music\svideo|lyric\svideo|visualizer)',
  caseSensitive: false,
);

class YoutubeSourceInfo extends SourceInfo {
  YoutubeSourceInfo({
    required super.id,
    required super.title,
    required super.artist,
    required super.thumbnail,
    required super.pageUrl,
    required super.duration,
    required super.artistUrl,
    required super.album,
  });
}

class YoutubeSourcedTrack extends SourcedTrack {
  YoutubeSourcedTrack({
    required super.source,
    required super.siblings,
    required super.sourceInfo,
    required super.track,
  });

  static Future<YoutubeSourcedTrack> fetchFromTrack({
    required Track track,
  }) async {
    final DatabaseProvider db = Get.find();
    final cachedSource = await (db.database.select(db.database.sourceMatchTable)
          ..where((s) => s.trackId.equals(track.id!))
          ..limit(1)
          ..orderBy([
            (s) =>
                OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
          ]))
        .get()
        .then((s) => s.firstOrNull);

    if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
      final siblings = await fetchSiblings(track: track);
      if (siblings.isEmpty) {
        throw TrackNotFoundError(track);
      }

      await db.database.into(db.database.sourceMatchTable).insert(
            SourceMatchTableCompanion.insert(
              trackId: track.id!,
              sourceId: siblings.first.info.id,
              sourceType: const Value(SourceType.youtube),
            ),
          );

      return YoutubeSourcedTrack(
        siblings: siblings.map((s) => s.info).skip(1).toList(),
        source: siblings.first.source as SourceMap,
        sourceInfo: siblings.first.info,
        track: track,
      );
    }

    final item = await youtubeClient.videos.get(cachedSource.sourceId);
    final manifest = await youtubeClient.videos.streamsClient
        .getManifest(
          cachedSource.sourceId,
        )
        .timeout(
          const Duration(seconds: 5),
          onTimeout: () => throw ClientException('Timeout'),
        );
    return YoutubeSourcedTrack(
      siblings: [],
      source: toSourceMap(manifest),
      sourceInfo: YoutubeSourceInfo(
        id: item.id.value,
        artist: item.author,
        artistUrl: 'https://www.youtube.com/channel/${item.channelId}',
        pageUrl: item.url,
        thumbnail: item.thumbnails.highResUrl,
        title: item.title,
        duration: item.duration ?? Duration.zero,
        album: null,
      ),
      track: track,
    );
  }

  static SourceMap toSourceMap(StreamManifest manifest) {
    var m4a = manifest.audioOnly
        .where((audio) => audio.codec.mimeType == 'audio/mp4')
        .sortByBitrate();

    var weba = manifest.audioOnly
        .where((audio) => audio.codec.mimeType == 'audio/webm')
        .sortByBitrate();

    m4a = m4a.isEmpty ? weba.toList() : m4a;
    weba = weba.isEmpty ? m4a.toList() : weba;

    return SourceMap(
      m4a: SourceQualityMap(
        high: m4a.first.url.toString(),
        medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
        low: m4a.last.url.toString(),
      ),
      weba: SourceQualityMap(
        high: weba.first.url.toString(),
        medium:
            (weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
        low: weba.last.url.toString(),
      ),
    );
  }

  static Future<SiblingType> toSiblingType(
    int index,
    YoutubeVideoInfo item,
  ) async {
    SourceMap? sourceMap;
    if (index == 0) {
      final manifest =
          await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
                const Duration(seconds: 5),
                onTimeout: () => throw ClientException('Timeout'),
              );
      sourceMap = toSourceMap(manifest);
    }

    final SiblingType sibling = (
      info: YoutubeSourceInfo(
        id: item.id,
        artist: item.channelName,
        artistUrl: 'https://www.youtube.com/channel/${item.channelId}',
        pageUrl: 'https://www.youtube.com/watch?v=${item.id}',
        thumbnail: item.thumbnailUrl,
        title: item.title,
        duration: item.duration,
        album: null,
      ),
      source: sourceMap,
    );

    return sibling;
  }

  static List<YoutubeVideoInfo> rankResults(
      List<YoutubeVideoInfo> results, Track track) {
    final artists = (track.artists ?? [])
        .map((ar) => ar.name)
        .toList()
        .whereNotNull()
        .toList();

    return results
        .sorted((a, b) => b.views.compareTo(a.views))
        .map((sibling) {
          int score = 0;

          for (final artist in artists) {
            final isSameChannelArtist =
                sibling.channelName.toLowerCase() == artist.toLowerCase();
            final channelContainsArtist = sibling.channelName
                .toLowerCase()
                .contains(artist.toLowerCase());

            if (isSameChannelArtist || channelContainsArtist) {
              score += 1;
            }

            final titleContainsArtist =
                sibling.title.toLowerCase().contains(artist.toLowerCase());

            if (titleContainsArtist) {
              score += 1;
            }
          }

          final titleContainsTrackName =
              sibling.title.toLowerCase().contains(track.name!.toLowerCase());

          final hasOfficialFlag =
              officialMusicRegex.hasMatch(sibling.title.toLowerCase());

          if (titleContainsTrackName) {
            score += 3;
          }

          if (hasOfficialFlag) {
            score += 1;
          }

          if (hasOfficialFlag && titleContainsTrackName) {
            score += 2;
          }

          return (sibling: sibling, score: score);
        })
        .sorted((a, b) => b.score.compareTo(a.score))
        .map((e) => e.sibling)
        .toList();
  }

  static Future<List<SiblingType>> fetchSiblings({
    required Track track,
  }) async {
    final links = await SongLinkService.links(track.id!);
    final ytLink = links.firstWhereOrNull((link) => link.platform == 'youtube');

    if (ytLink?.url != null
        // allows to fetch siblings more results for already sourced track
        &&
        track is! SourcedTrack) {
      try {
        return [
          await toSiblingType(
            0,
            YoutubeVideoInfo.fromVideo(
              await youtubeClient.videos.get(ytLink!.url!),
            ),
          )
        ];
      } on VideoUnplayableException catch (e) {
        // Ignore this error and continue with the search
        Get.find<ErrorNotifier>().logError(
            '[Source][YoutubeMusic] Unable to play stream on youtube: $e');
      }
    }

    final query = SourcedTrack.getSearchTerm(track);

    final searchResults = await youtubeClient.search.search(
      query,
      filter: TypeFilters.video,
    );

    if (ServiceUtils.onlyContainsEnglish(query)) {
      return await Future.wait(searchResults
          .map(YoutubeVideoInfo.fromVideo)
          .mapIndexed(toSiblingType));
    }

    final rankedSiblings = rankResults(
      searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
      track,
    );

    return await Future.wait(rankedSiblings.mapIndexed(toSiblingType));
  }

  @override
  Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
    if (sibling.id == sourceInfo.id) {
      return null;
    }

    // a sibling source that was fetched from the search results
    final isStepSibling = siblings.none((s) => s.id == sibling.id);

    final newSourceInfo = isStepSibling
        ? sibling
        : siblings.firstWhere((s) => s.id == sibling.id);
    final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
      ..insert(0, sourceInfo);

    final manifest = await youtubeClient.videos.streamsClient
        .getManifest(newSourceInfo.id)
        .timeout(
          const Duration(seconds: 5),
          onTimeout: () => throw ClientException('Timeout'),
        );

    final DatabaseProvider db = Get.find();

    await db.database.into(db.database.sourceMatchTable).insert(
          SourceMatchTableCompanion.insert(
            trackId: id!,
            sourceId: newSourceInfo.id,
            sourceType: const Value(SourceType.youtube),
            // Because we're sorting by createdAt in the query
            // we have to update it to indicate priority
            createdAt: Value(DateTime.now()),
          ),
          mode: InsertMode.replace,
        );

    return YoutubeSourcedTrack(
      siblings: newSiblings,
      source: toSourceMap(manifest),
      sourceInfo: newSourceInfo,
      track: this,
    );
  }

  @override
  Future<YoutubeSourcedTrack> copyWithSibling() async {
    if (siblings.isNotEmpty) {
      return this;
    }
    final fetchedSiblings = await fetchSiblings(track: this);

    return YoutubeSourcedTrack(
      siblings: fetchedSiblings
          .where((s) => s.info.id != sourceInfo.id)
          .map((s) => s.info)
          .toList(),
      source: source,
      sourceInfo: sourceInfo,
      track: this,
    );
  }
}
