import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
    show ExtensionArtistScreen, ExtensionAlbumScreen;
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';

final _log = AppLogger('ClickableMetadata');

class _MetadataSearchResult {
  final String providerId;
  final List<Map<String, dynamic>> items;

  const _MetadataSearchResult({required this.providerId, required this.items});
}

Future<_MetadataSearchResult?> _searchMetadataProviders(
  BuildContext context,
  String query, {
  required String filter,
  int limit = 5,
  String? sourceProviderId,
}) async {
  final providerIds = _metadataSearchProviderCandidates(
    context,
    sourceProviderId: sourceProviderId,
  );

  for (final providerId in providerIds) {
    try {
      final items = await _searchMetadataProvider(
        providerId,
        query,
        filter: filter,
        limit: limit,
      );
      if (items.isNotEmpty) {
        return _MetadataSearchResult(providerId: providerId, items: items);
      }
    } catch (e) {
      _log.w(
        'Metadata lookup failed for provider "$providerId", filter=$filter: $e',
      );
    }
  }

  return null;
}

Future<List<Map<String, dynamic>>> _searchMetadataProvider(
  String providerId,
  String query, {
  required String filter,
  required int limit,
}) async {
  return PlatformBridge.customSearchWithExtension(
    providerId,
    query,
    options: {'filter': filter, 'limit': limit},
  );
}

List<String> _metadataSearchProviderCandidates(
  BuildContext context, {
  String? sourceProviderId,
}) {
  final container = ProviderScope.containerOf(context, listen: false);
  final extensionState = container.read(extensionProvider);
  final settings = container.read(settingsProvider);
  final extensionNotifier = container.read(extensionProvider.notifier);
  final candidates = <String>[];

  void addProvider(String? providerId) {
    final normalized = providerId?.trim();
    if (normalized == null ||
        normalized.isEmpty ||
        candidates.contains(normalized) ||
        !_canSearchMetadataProvider(normalized, extensionState)) {
      return;
    }
    candidates.add(normalized);
  }

  addProvider(sourceProviderId);
  addProvider(settings.searchProvider);

  for (final providerId in extensionState.metadataProviderPriority) {
    addProvider(providerId);
  }
  for (final providerId in extensionNotifier.getAllMetadataProviders()) {
    addProvider(providerId);
  }

  final searchExtensions = extensionState.extensions
      .where((ext) => ext.enabled && ext.hasCustomSearch)
      .toList(growable: false);
  for (final extension in searchExtensions.where(
    (ext) => ext.searchBehavior?.primary == true,
  )) {
    addProvider(extension.id);
  }
  for (final extension in searchExtensions.where(
    (ext) => ext.searchBehavior?.primary != true,
  )) {
    addProvider(extension.id);
  }

  return candidates;
}

bool _canSearchMetadataProvider(
  String providerId,
  ExtensionState extensionState,
) {
  return extensionState.extensions.any(
    (ext) => ext.enabled && ext.hasCustomSearch && ext.id == providerId,
  );
}

Future<void> navigateToArtist(
  BuildContext context, {
  required String artistName,
  String? artistId,
  String? coverUrl,
  String? extensionId,
}) async {
  if (artistName.isEmpty) return;

  final normalizedArtistId = _normalizeArtistId(artistId);

  if (normalizedArtistId != null &&
      _canNavigateArtistDirectly(
        artistId: normalizedArtistId,
        extensionId: extensionId,
      )) {
    _pushArtistScreen(
      context,
      artistId: normalizedArtistId,
      artistName: artistName,
      coverUrl: coverUrl,
      extensionId: extensionId,
    );
    return;
  }

  _showLoadingSnackBar(context, context.l10n.clickableLookingUpArtist);
  try {
    final searchResult = await _searchMetadataProviders(
      context,
      artistName,
      filter: 'artist',
      limit: 20,
      sourceProviderId: extensionId,
    );
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();

    final artistList = searchResult?.items ?? const <Map<String, dynamic>>[];
    if (artistList.isEmpty) {
      _showUnavailable(context, context.l10n.trackArtist);
      return;
    }

    final bestMatch = _pickBestResultByName(artistList, artistName);
    if (bestMatch == null) {
      _showUnavailable(context, context.l10n.trackArtist);
      return;
    }

    final resolvedId = bestMatch['id'] as String? ?? '';
    final resolvedName = bestMatch['name'] as String? ?? artistName;
    final resolvedImage = bestMatch['images'] as String?;
    final resolvedProviderId = _resolveResultProviderId(
      bestMatch,
      searchResult?.providerId,
    );

    if (resolvedId.isEmpty) {
      _showUnavailable(context, context.l10n.trackArtist);
      return;
    }

    if (!context.mounted) return;
    _pushArtistScreen(
      context,
      artistId: resolvedId,
      artistName: resolvedName,
      coverUrl: resolvedImage ?? coverUrl,
      extensionId: resolvedProviderId,
    );
  } catch (e) {
    _log.e('Failed to look up artist "$artistName": $e', e);
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();
    _showUnavailable(context, context.l10n.trackArtist);
  }
}

Future<void> navigateToAlbum(
  BuildContext context, {
  required String albumName,
  String? albumId,
  String? artistName,
  String? coverUrl,
  String? extensionId,
}) async {
  if (albumName.isEmpty) return;

  if (albumId != null && albumId.isNotEmpty && !_isUnknownResourceId(albumId)) {
    _pushAlbumScreen(
      context,
      albumId: albumId,
      albumName: albumName,
      coverUrl: coverUrl,
      extensionId: extensionId,
    );
    return;
  }

  _showLoadingSnackBar(context, 'Looking up album...');
  try {
    final query = artistName != null && artistName.isNotEmpty
        ? '$albumName $artistName'
        : albumName;

    final searchResult = await _searchMetadataProviders(
      context,
      query,
      filter: 'album',
      limit: 20,
      sourceProviderId: extensionId,
    );
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();

    final albumList = searchResult?.items ?? const <Map<String, dynamic>>[];
    if (albumList.isEmpty) {
      _showUnavailable(context, 'Album');
      return;
    }

    final bestMatch = _pickBestResultByName(albumList, albumName);
    if (bestMatch == null) {
      _showUnavailable(context, 'Album');
      return;
    }

    final resolvedId = bestMatch['id'] as String? ?? '';
    final resolvedName = bestMatch['name'] as String? ?? albumName;
    final resolvedImage = bestMatch['images'] as String?;
    final resolvedProviderId = _resolveResultProviderId(
      bestMatch,
      searchResult?.providerId,
    );

    if (resolvedId.isEmpty) {
      _showUnavailable(context, 'Album');
      return;
    }

    if (!context.mounted) return;
    _pushAlbumScreen(
      context,
      albumId: resolvedId,
      albumName: resolvedName,
      coverUrl: resolvedImage ?? coverUrl,
      extensionId: resolvedProviderId,
    );
  } catch (e) {
    _log.e('Failed to look up album "$albumName": $e', e);
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();
    _showUnavailable(context, 'Album');
  }
}

/// Opens the album for a track, enriching sparse search results first when
/// they do not include album identity. This keeps collection actions generic:
/// any metadata extension can supply the missing track fields through search.
Future<void> navigateToTrackAlbum(BuildContext context, Track track) async {
  final albumName = track.albumName.trim();
  final albumId = track.albumId?.trim();
  final hasAlbumId = albumId != null && !_isUnknownResourceId(albumId);

  if (albumName.isNotEmpty || hasAlbumId) {
    await navigateToAlbum(
      context,
      albumName: albumName.isNotEmpty ? albumName : context.l10n.trackAlbum,
      albumId: hasAlbumId ? albumId : null,
      artistName: _preferredAlbumArtist(track),
      coverUrl: track.coverUrl,
      extensionId: track.source,
    );
    return;
  }

  final query = [
    track.name.trim(),
    track.artistName.trim(),
  ].where((part) => part.isNotEmpty).join(' ');
  if (query.isEmpty) {
    _showUnavailable(context, context.l10n.trackAlbum);
    return;
  }

  _showLoadingSnackBar(context, 'Looking up album...');
  try {
    final searchResult = await _searchMetadataProviders(
      context,
      query,
      filter: 'track',
      limit: 20,
      sourceProviderId: track.source,
    );
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();

    final matches = searchResult?.items ?? const <Map<String, dynamic>>[];
    final bestMatch = _pickBestResultByName(matches, track.name);
    if (bestMatch == null) {
      _showUnavailable(context, context.l10n.trackAlbum);
      return;
    }

    final providerId = _resolveResultProviderId(
      bestMatch,
      searchResult?.providerId,
    );
    final resolvedTrack = Track.fromBackendMap(bestMatch, source: providerId);
    final resolvedAlbumName = resolvedTrack.albumName.trim();
    final resolvedAlbumId = resolvedTrack.albumId?.trim();
    final hasResolvedAlbumId =
        resolvedAlbumId != null && !_isUnknownResourceId(resolvedAlbumId);
    if (resolvedAlbumName.isEmpty && !hasResolvedAlbumId) {
      _showUnavailable(context, context.l10n.trackAlbum);
      return;
    }

    await navigateToAlbum(
      context,
      albumName: resolvedAlbumName.isNotEmpty
          ? resolvedAlbumName
          : context.l10n.trackAlbum,
      albumId: hasResolvedAlbumId ? resolvedAlbumId : null,
      artistName: _preferredAlbumArtist(resolvedTrack),
      coverUrl: resolvedTrack.coverUrl ?? track.coverUrl,
      extensionId: providerId,
    );
  } catch (e) {
    _log.e('Failed to resolve album for track "${track.name}": $e', e);
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();
    _showUnavailable(context, context.l10n.trackAlbum);
  }
}

String _preferredAlbumArtist(Track track) {
  final albumArtist = (track.albumArtist ?? '').trim();
  return albumArtist.isNotEmpty ? albumArtist : track.artistName;
}

void _pushArtistScreen(
  BuildContext context, {
  required String artistId,
  required String artistName,
  String? coverUrl,
  String? extensionId,
}) {
  final isExtension = extensionId != null;
  final resolvedProviderId = extensionId;

  pushViaPreferredNavigator(
    context,
    (context) => isExtension && resolvedProviderId != null
        ? ExtensionArtistScreen(
            extensionId: resolvedProviderId,
            artistId: artistId,
            artistName: artistName,
            coverUrl: coverUrl,
          )
        : ArtistScreen(
            artistId: artistId,
            artistName: artistName,
            coverUrl: coverUrl,
            extensionId: resolvedProviderId,
          ),
  );
}

void _pushAlbumScreen(
  BuildContext context, {
  required String albumId,
  required String albumName,
  String? coverUrl,
  String? extensionId,
}) {
  final isExtension = extensionId != null;
  final resolvedExtensionId = extensionId;

  pushViaPreferredNavigator(
    context,
    (context) => isExtension && resolvedExtensionId != null
        ? ExtensionAlbumScreen(
            extensionId: resolvedExtensionId,
            albumId: albumId,
            albumName: albumName,
            coverUrl: coverUrl,
          )
        : AlbumScreen(
            albumId: albumId,
            albumName: albumName,
            coverUrl: coverUrl,
            extensionId: resolvedExtensionId,
            tracks: const [],
          ),
  );
}

/// Pushes detail pages into the active tab navigator when invoked from a root
/// modal such as Now Playing. The root modal is dismissed first so the album
/// is immediately visible instead of being hidden behind it.
void pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) {
  final currentNavigator = Navigator.of(context);
  final rootNavigator = Navigator.of(context, rootNavigator: true);
  final activeTabNavigator = ShellNavigationService.activeTabNavigator();

  final shouldRouteToTabNavigator =
      identical(currentNavigator, rootNavigator) && activeTabNavigator != null;

  if (!shouldRouteToTabNavigator) {
    currentNavigator.push(MaterialPageRoute<void>(builder: builder));
    return;
  }

  final currentRoute = ModalRoute.of(context);
  final shouldPopCurrentRoute =
      currentRoute != null && currentRoute.isFirst == false;

  if (shouldPopCurrentRoute && currentNavigator.canPop()) {
    currentNavigator.pop();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!activeTabNavigator.mounted) return;
      activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
    });
    return;
  }

  activeTabNavigator.push(MaterialPageRoute<void>(builder: builder));
}

void _showLoadingSnackBar(BuildContext context, String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Row(
        children: [
          const SizedBox(
            width: 16,
            height: 16,
            child: CircularProgressIndicator(strokeWidth: 2),
          ),
          const SizedBox(width: 12),
          Text(message),
        ],
      ),
      duration: const Duration(seconds: 10),
    ),
  );
}

void _showUnavailable(BuildContext context, String type) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text(context.l10n.clickableInformationUnavailable(type))),
  );
}

class ClickableArtistName extends StatefulWidget {
  final String artistName;
  final String? artistId;
  final String? coverUrl;
  final String? extensionId;
  final TextStyle? style;
  final int? maxLines;
  final TextOverflow? overflow;
  final TextAlign? textAlign;

  const ClickableArtistName({
    super.key,
    required this.artistName,
    this.artistId,
    this.coverUrl,
    this.extensionId,
    this.style,
    this.maxLines,
    this.overflow,
    this.textAlign,
  });

  @override
  State<ClickableArtistName> createState() => _ClickableArtistNameState();
}

class _ClickableArtistNameState extends State<ClickableArtistName> {
  List<_ArtistTapTarget> _artistTargets = const [];
  final List<TapGestureRecognizer> _recognizers = [];

  @override
  void initState() {
    super.initState();
    _rebuildArtistTargets();
  }

  @override
  void didUpdateWidget(covariant ClickableArtistName oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.artistName != widget.artistName ||
        oldWidget.artistId != widget.artistId ||
        oldWidget.coverUrl != widget.coverUrl ||
        oldWidget.extensionId != widget.extensionId) {
      _rebuildArtistTargets();
    }
  }

  @override
  void dispose() {
    _disposeRecognizers();
    super.dispose();
  }

  void _disposeRecognizers() {
    for (final recognizer in _recognizers) {
      recognizer.dispose();
    }
    _recognizers.clear();
  }

  void _rebuildArtistTargets() {
    _disposeRecognizers();
    _artistTargets = _buildArtistTapTargets(widget.artistName, widget.artistId);
    if (_artistTargets.length <= 1) return;

    for (final target in _artistTargets) {
      final recognizer = TapGestureRecognizer()
        ..onTap = () => navigateToArtist(
          context,
          artistName: target.name,
          artistId: target.artistId,
          coverUrl: widget.coverUrl,
          extensionId: _extensionIdForTarget(target),
        );
      _recognizers.add(recognizer);
    }
  }

  String? _extensionIdForTarget(_ArtistTapTarget target) {
    if (widget.extensionId == null) return null;
    if (_artistTargets.length == 1) return widget.extensionId;
    return target.artistId != null ? widget.extensionId : null;
  }

  List<InlineSpan> _buildMultiArtistSpans() {
    final spans = <InlineSpan>[];
    for (var i = 0; i < _artistTargets.length; i++) {
      final target = _artistTargets[i];
      spans.add(
        TextSpan(
          text: target.name,
          style: widget.style,
          recognizer: _recognizers[i],
        ),
      );
      if (i < _artistTargets.length - 1) {
        spans.add(TextSpan(text: ', ', style: widget.style));
      }
    }
    return spans;
  }

  @override
  Widget build(BuildContext context) {
    if (_artistTargets.isEmpty) {
      return Text(
        widget.artistName,
        style: widget.style,
        maxLines: widget.maxLines,
        overflow: widget.overflow,
        textAlign: widget.textAlign,
      );
    }

    if (_artistTargets.length == 1) {
      final target = _artistTargets.first;
      return GestureDetector(
        onTap: () => navigateToArtist(
          context,
          artistName: target.name,
          artistId: target.artistId,
          coverUrl: widget.coverUrl,
          extensionId: _extensionIdForTarget(target),
        ),
        child: Text(
          target.name,
          style: widget.style,
          maxLines: widget.maxLines,
          overflow: widget.overflow,
          textAlign: widget.textAlign,
        ),
      );
    }

    return Text.rich(
      TextSpan(style: widget.style, children: _buildMultiArtistSpans()),
      maxLines: widget.maxLines,
      overflow: widget.overflow ?? TextOverflow.clip,
      textAlign: widget.textAlign ?? TextAlign.start,
    );
  }
}

class _ArtistTapTarget {
  final String name;
  final String? artistId;

  const _ArtistTapTarget({required this.name, this.artistId});
}

List<_ArtistTapTarget> _buildArtistTapTargets(
  String rawArtistNames,
  String? rawArtistIds,
) {
  final parsedNames = splitArtistNames(rawArtistNames);
  if (parsedNames.isEmpty) return const [];

  final uniqueNames = <String>[];
  final seen = <String>{};
  for (final parsed in parsedNames) {
    final key = parsed.toLowerCase().replaceAll(RegExp(r'\s+'), ' ').trim();
    if (key.isEmpty || !seen.add(key)) continue;
    uniqueNames.add(parsed);
  }
  if (uniqueNames.isEmpty) return const [];

  if (uniqueNames.length == 1) {
    return [
      _ArtistTapTarget(
        name: uniqueNames.first,
        artistId: _normalizeArtistId(rawArtistIds),
      ),
    ];
  }

  final parsedIds = _parseArtistIds(rawArtistIds);
  if (parsedIds.isEmpty || !parsedIds.any((id) => id != null)) {
    return uniqueNames
        .map((name) => _ArtistTapTarget(name: name))
        .toList(growable: false);
  }

  // Providers may return one id per artist (aligned with the names) or only
  // the primary artist's id. Map ids to names positionally, preserving empty
  // slots as null, so each tapped artist navigates by its own id when known.
  return List<_ArtistTapTarget>.generate(
    uniqueNames.length,
    (index) => _ArtistTapTarget(
      name: uniqueNames[index],
      artistId: index < parsedIds.length ? parsedIds[index] : null,
    ),
    growable: false,
  );
}

List<String?> _parseArtistIds(String? rawArtistIds) {
  final raw = rawArtistIds?.trim();
  if (raw == null || raw.isEmpty) return const [];

  return raw
      .split(RegExp(r'\s*,\s*'))
      .map(_normalizeArtistId)
      .toList(growable: false);
}

String? _normalizeArtistId(String? artistId) {
  final id = artistId?.trim();
  if (id == null || _isUnknownResourceId(id)) {
    return null;
  }
  return id;
}

bool _isUnknownResourceId(String id) {
  final normalized = id.trim().toLowerCase();
  return normalized.isEmpty ||
      normalized == 'unknown' ||
      normalized.endsWith(':unknown');
}

String? _resolveResultProviderId(
  Map<String, dynamic> result,
  String? fallbackProviderId,
) {
  final providerId = result['provider_id']?.toString().trim();
  if (providerId != null && providerId.isNotEmpty) return providerId;
  final source = result['source']?.toString().trim();
  if (source != null && source.isNotEmpty) return source;
  final fallback = fallbackProviderId?.trim();
  return fallback != null && fallback.isNotEmpty ? fallback : null;
}

bool _canNavigateArtistDirectly({
  required String artistId,
  required String? extensionId,
}) {
  if (extensionId != null) return true;
  return _spotifyArtistIdPattern.hasMatch(artistId);
}

/// Selects the result whose name best matches [query] instead of blindly
/// trusting the provider's first result. This prevents tapping an artist like
/// "creo" from opening a completely unrelated artist (e.g. "Tyler, the
/// Creator") just because the provider ranked it first.
Map<String, dynamic>? _pickBestResultByName(
  List<Map<String, dynamic>> results,
  String query,
) {
  if (results.isEmpty) return null;

  final normalizedQuery = _normalizeForMatch(query);
  if (normalizedQuery.isEmpty) return results.first;

  Map<String, dynamic>? best;
  double bestScore = -1;
  for (final result in results) {
    final name = result['name'] as String? ?? '';
    final normalizedName = _normalizeForMatch(name);
    if (normalizedName.isEmpty) continue;

    if (normalizedName == normalizedQuery) {
      return result;
    }

    final score = _nameMatchScore(normalizedQuery, normalizedName);
    if (score > bestScore) {
      bestScore = score;
      best = result;
    }
  }

  // Accept a fuzzy match only when it is reasonably close. Otherwise fall back
  // to the provider's own top result so we never crash on an empty match.
  if (best != null && bestScore >= 0.5) {
    if (bestScore < 0.85) {
      _log.w(
        'No exact match for "$query"; using closest result '
        '"${best['name']}" (score ${bestScore.toStringAsFixed(2)})',
      );
    }
    return best;
  }

  _log.w(
    'No close match for "$query" among ${results.length} results; '
    'falling back to first result "${results.first['name']}"',
  );
  return results.first;
}

String _normalizeForMatch(String value) {
  return value
      .toLowerCase()
      .replaceAll(RegExp(r'[^\p{L}\p{N}\s]', unicode: true), ' ')
      .replaceAll(RegExp(r'\s+'), ' ')
      .trim();
}

/// Returns a similarity score in [0, 1] between two normalized names.
double _nameMatchScore(String query, String candidate) {
  if (query == candidate) return 1.0;

  final queryTokens = query.split(' ').where((t) => t.isNotEmpty).toSet();
  final candidateTokens = candidate
      .split(' ')
      .where((t) => t.isNotEmpty)
      .toSet();
  if (queryTokens.isEmpty || candidateTokens.isEmpty) return 0;

  final intersection = queryTokens.intersection(candidateTokens).length;
  final union = queryTokens.union(candidateTokens).length;
  final jaccard = union == 0 ? 0.0 : intersection / union;

  // Reward full substring containment of the (shorter) query in the candidate.
  double containment = 0;
  if (candidate.contains(query) || query.contains(candidate)) {
    final shorter = query.length < candidate.length ? query : candidate;
    final longer = query.length < candidate.length ? candidate : query;
    containment = longer.isEmpty ? 0 : shorter.length / longer.length;
  }

  return jaccard > containment ? jaccard : containment;
}

final RegExp _spotifyArtistIdPattern = RegExp(r'^[A-Za-z0-9]{22}$');

class ClickableAlbumName extends StatelessWidget {
  final String albumName;
  final String? albumId;
  final String? artistName;
  final String? coverUrl;
  final String? extensionId;
  final TextStyle? style;
  final int? maxLines;
  final TextOverflow? overflow;
  final TextAlign? textAlign;

  const ClickableAlbumName({
    super.key,
    required this.albumName,
    this.albumId,
    this.artistName,
    this.coverUrl,
    this.extensionId,
    this.style,
    this.maxLines,
    this.overflow,
    this.textAlign,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => navigateToAlbum(
        context,
        albumName: albumName,
        albumId: albumId,
        artistName: artistName,
        coverUrl: coverUrl,
        extensionId: extensionId,
      ),
      child: Text(
        albumName,
        style: style,
        maxLines: maxLines,
        overflow: overflow,
        textAlign: textAlign,
      ),
    );
  }
}
