package io.delphiplatform.api.util.model;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.protobuf.BoolValue;
import com.google.protobuf.StringValue;
import com.google.protobuf.UInt64Value;

import org.apache.commons.lang3.StringUtils;
import org.openapitools.jackson.nullable.JsonNullable;

import java.math.BigDecimal;
import java.net.URI;
import java.sql.Date;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import io.delphiplatform.api.util.CollectionUtils;
import io.delphiplatform.api.v3.constant.DspConstants;
import io.delphiplatform.api.v3.model.ArtistFollower;
import io.delphiplatform.api.v3.model.DspPlaylistIdCountryCodeAwareModel;
import io.delphiplatform.api.v3.model.PublicPlaylist;
import io.delphiplatform.api.v3.model.StreamModel;
import io.delphiplatform.api.v3.model.tiktok.top.sound.TikTokTopIsrcSound;
import io.delphiplatform.api.v3.model.trackposition.PlaylistsTrackPositionsCurrentTracklist;
import io.delphiplatform.api.v3.model.trackposition.PublicPlaylistTrackPosition;
import io.delphiplatform.api.v3.rdb.entity.apple.music.AppleMusicPlaylistTrackLifetimePublicId;
import io.delphiplatform.api.v3.rdb.entity.spotify.SpotifyPlaylistTrackLifetimePublicId;
import io.delphiplatform.api.v3.rdb.service.dto.PlaylistIdBaseAndCountryCode;
import lombok.extern.slf4j.Slf4j;

import static io.delphiplatform.api.v3.constant.ApplicationConstants.ADS_UNKNOWN_CURRENCY;

@Slf4j
public abstract class ModelUtils {

    private static final String AMAZON_PLAYLIST_ID_SEPARATOR = "_";

    private static final String SPOTIFY_PLAYLIST_ID_PREFIX = "spotify_";
    private static final String APPLE_PLAYLIST_ID_PREFIX = "apple_";
    private static final String AMAZON_PLAYLIST_ID_PREFIX = "amazon_";

    private static final String SPOTIFY_PLAYLIST_OWNER_USERNAME_PREFIX = "spotify_";
    private static final String APPLE_PLAYLIST_OWNER_USERNAME_PREFIX = "apple_";

    private static final String SPOTIFY_TRACK_ID_PREFIX = "spotify_";
    private static final String APPLE_TRACK_ID_PREFIX = "apple_";
    private static final String AMAZON_TRACK_ID_PREFIX = "amazon_";

    private static final String SPOTIFY_ARTIST_URL_PREFIX = "spotify:artist:";

    private static final Pattern AMAZON_BASE_PLAYLIST_ID_PATTERN =
        Pattern.compile("amazon_([0-9]+)_([a-zA-Z]{2})", Pattern.CASE_INSENSITIVE);

    private static final Set<String> DSPS_WITH_MULTIPLE_COUNTRIES_PLAYLISTS_UNDER_SAME_PLAYLIST_ID =
        Set.of(DspConstants.APPLE);

    public static URI createUri(String uriString) {
        if (CollectionUtils.isEmpty(uriString)) {
            return null;
        }

        try {
            return URI.create(uriString);
        } catch (IllegalArgumentException e) {
            log.warn(e.getMessage());
            return null;
        }
    }

    public static Long toLong(Object v) {
        if (v == null) {
            return null;
        }
        if (v instanceof BigDecimal) {
            return ((BigDecimal) v).longValue();
        }
        if (v instanceof Double) {
            return ((Double) v).longValue();
        }
        if (v instanceof Integer) {
            return ((Integer) v).longValue();
        }
        if (v instanceof Long) {
            return (Long) v;
        }
        throw new IllegalArgumentException("Cannot convert " + v + " to Long");
    }

    public static LocalDate toDate(Object v) {
        if (v == null) {
            return null;
        }
        if (v instanceof Date) {
            return ((Date) v).toLocalDate();
        }
        throw new IllegalArgumentException("Cannot convert " + v + " to Date");
    }

    public static Map<String, BigDecimal> toMap(Object v) {
        if (v == null) {
            return null;
        }
        if (v instanceof String) {
            return toCurrencies(v);
        }
        String json = v.toString();
        return toCurrencies(json);
    }

    public static BigDecimal toBigDecimal(Object v) {
        if (v == null) {
            return null;
        }
        if (v instanceof BigDecimal) {
            return (BigDecimal) v;
        }
        if (v instanceof Double) {
            return toBigDecimal((Double) v);
        }
        if (v instanceof Long) {
            return toBigDecimal((Long) v);
        }
        if (v instanceof Integer) {
            return toBigDecimal((Integer) v);
        }
        throw new IllegalArgumentException("Cannot convert " + v + " to BigDecimal");
    }

    public static BigDecimal toBigDecimal(Double v) {
        return v == null ? null : BigDecimal.valueOf(v);
    }

    public static BigDecimal toBigDecimal(Long v) {
        return v == null ? null : BigDecimal.valueOf(v);
    }

    public static BigDecimal toBigDecimal(Integer v) {
        return v == null ? null : BigDecimal.valueOf(v);
    }

    public static <T, R> List<R> map(Collection<T> items, Function<T, R> mapper) {
        if (items == null) {
            return null;
        }
        return items.stream().map(mapper).collect(Collectors.toList());
    }

    public static ArtistFollower mergeArtistFollowers(List<ArtistFollower> artistFollowers) {
        return artistFollowers.stream()
            .reduce(new ArtistFollower(), ModelUtils::merge);
    }

    private static ArtistFollower merge(ArtistFollower a, ArtistFollower b) {
        a.setAppleFollowers(addAndReplaceZeroWithNull(a.getAppleFollowers(), b.getAppleFollowers()));
        a.setFacebookLikes(addAndReplaceZeroWithNull(a.getFacebookLikes(), b.getFacebookLikes()));
        a.setFacebookStorytellers(addAndReplaceZeroWithNull(a.getFacebookStorytellers(), b.getFacebookStorytellers()));
        a.setInstagramFollowers(addAndReplaceZeroWithNull(a.getInstagramFollowers(), b.getInstagramFollowers()));
        a.setSpotifyFollowers(addAndReplaceZeroWithNull(a.getSpotifyFollowers(), b.getSpotifyFollowers()));
        a.setSpotifyPopularity(addAndReplaceZeroWithNull(a.getSpotifyPopularity(), b.getSpotifyPopularity()));
        a.setTwitterFollowers(addAndReplaceZeroWithNull(a.getTwitterFollowers(), b.getTwitterFollowers()));
        a.setYoutubeChannelSubscribers(
            addAndReplaceZeroWithNull(a.getYoutubeChannelSubscribers(), b.getYoutubeChannelSubscribers()));
        a.setYoutubeChannelViews(addAndReplaceZeroWithNull(a.getYoutubeChannelViews(), b.getYoutubeChannelViews()));
        //hope, we won't group by artist_id
        a.setArtistId(b.getArtistId());
        return a;
    }

    public static TikTokTopIsrcSound mergeTikTokTopIsrcSounds(List<TikTokTopIsrcSound> topIsrcSounds) {
        return topIsrcSounds.stream()
            .reduce(new TikTokTopIsrcSound(), ModelUtils::merge);
    }

    private static TikTokTopIsrcSound merge(TikTokTopIsrcSound a, TikTokTopIsrcSound b) {

        a.setReportDate(a.getReportDate());
        a.setSoundId(a.getSoundId());
        a.setContentType(a.getContentType());

        if (a.getIsrcs() == null) {
            a.setIsrcs(new HashSet<>());
        }
        a.getIsrcs().addAll(b.getIsrcs());

        a.setCreations(add(a.getCreations(), b.getCreations()));
        a.setTotalIsrcCreations(CollectionUtils.firstNotNull(a.getTotalIsrcCreations(), b.getTotalIsrcCreations()));

        return a;
    }

    public static BigDecimal calculateCreationsShare(Long creations, Long totalIsrcCreations) {
        if (creations == null || totalIsrcCreations == null || totalIsrcCreations == 0) {
            return null;
        }
        return BigDecimal.valueOf(creations / (float) totalIsrcCreations * 100);
    }

    public static long calculateTotalIsrcCreations(Long creations, Float creationsShare) {
        return Float.valueOf((creations / creationsShare) * 100).longValue();
    }

    public static <T> JsonNullable<T> merge(JsonNullable<T> a, JsonNullable<T> b, BinaryOperator<T> merger) {
        if (a.isPresent()) {
            if (b.isPresent()) {
                return JsonNullable.of(merger.apply(a.get(), b.get()));
            } else {
                return a;
            }
        } else {
            return b;
        }
    }

    public static <T> T mergeObjects(T a, T b, BinaryOperator<T> merger) {
        if (a != null) {
            if (b != null) {
                return (merger.apply(a, b));
            } else {
                return a;
            }
        } else {
            return b;
        }
    }

    public static Long addAndReplaceZeroWithNull(Long a, Long b) {
        Long result = add(a, b);
        if (result != null && result == 0) {
            return null;
        } else {
            return result;
        }
    }

    public static JsonNullable<Long> addAndReplaceZeroWithNull(JsonNullable<Long> a, JsonNullable<Long> b) {
        return merge(a, b, Long::sum);
    }

    public static JsonNullable<Float> addAndReplaceZeroWithNullFloat(JsonNullable<Float> a, JsonNullable<Float> b) {
        return merge(a, b, Float::sum);
    }

    public static JsonNullable<Float> toAverage(JsonNullable<Float> sum, Long totalCount) {
        if (sum.isPresent() && totalCount != 0) {
            return JsonNullable.of(sum.get() / totalCount);
        } else {
            return JsonNullable.undefined();
        }
    }

    public static <T> float weightedAverage(List<T> items, Function<T, Long> dailyTotalExtractor,
        Function<T, Float> dailyValueExtractor) {
        long dailyTotalSum = items.stream()
            .mapToLong(dailyTotalExtractor::apply)
            .sum();

        if (dailyTotalSum == 0) {
            return 0;
        }

        double dailyWeightedValueSum = items.stream()
            .mapToDouble(i -> dailyTotalExtractor.apply(i) * dailyValueExtractor.apply(i))
            .sum();

        return (float) (dailyWeightedValueSum / dailyTotalSum);
    }

    private static Integer addAndReplaceZeroWithNull(Integer a, Integer b) {
        Integer result = add(a, b);
        if (result != null && result == 0) {
            return null;
        } else {
            return result;
        }
    }

    private static Long add(Long a, Long b) {
        if (a != null) {
            if (b != null) {
                return a + b;
            } else {
                return a;
            }
        } else {
            return b;
        }
    }

    private static Integer add(Integer a, Integer b) {
        if (a != null) {
            if (b != null) {
                return a + b;
            } else {
                return a;
            }
        } else {
            return b;
        }
    }

    /**
     * Extracts property value using extractor param. If there is only one unique value, it is returned. Otherwise, null is returned
     *
     * @param extractor property value extractor or other mapper
     * @param <T>       type of the model
     * @param <V>       type of the extracted value
     * @return null if empty models collection or more then 1 unique element. The unique value otherwise.
     */
    public static <T, V> V getUniqueOrNull(Collection<T> models, Function<T, V> extractor) {
        Set<V> values = models.stream()
            .map(extractor)
            .collect(Collectors.toSet());
        if (values.size() == 1) {
            return values.iterator().next();
        } else {
            return null;
        }
    }

    public static <T> List<T> toRange(Map<LocalDate, T> metrics, LocalDate start, LocalDate end) {
        if (start == null || end == null) {
            if (metrics == null) {
                return null;
            } else {
                return new ArrayList<>(metrics.values());
            }
        }

        if (CollectionUtils.isEmpty(metrics)) {
            return null;
        }

        return CollectionUtils.getDateRange(start, end)
            .map(d -> metrics.getOrDefault(d, null))
            .collect(Collectors.toList());
    }

    public static Map<String, BigDecimal> toCurrencies(Object input) {
        if (input instanceof String) {
            try {
                Map<String, Object> parsed = new ObjectMapper().readValue((String) input, Map.class);
                Map<String, BigDecimal> result = new HashMap<>();
                parsed.forEach((k, v) -> {
                    if (k != null && !k.equals(ADS_UNKNOWN_CURRENCY) && v != null) {
                        result.put(k, toBigDecimal(v));
                    }
                });
                return result;
            } catch (JsonProcessingException e) {
                log.warn("JsonProcessingException while processing {}", input, e);
            }
        }
        throw new IllegalArgumentException("Cannot convert " + input + " to CurrencyDataHolder");
    }

    public static String getPlaylistIdWithCountryCode(String playlistId, String countryCode) {
        if (playlistId == null || countryCode == null) {
            return null;
        }

        return String.format("%s_%s", playlistId, countryCode);
    }

    public static String getPlaylistIdWithDspPrefix(String dsp, String playlistId) {
        if (dsp == null || playlistId == null) {
            return null;
        }

        return String.format("%s_%s", dsp, playlistId);
    }

    public static String getPlaylistIdWithIsrc(String playlistId, String isrc) {
        if (playlistId == null || isrc == null) {
            return null;
        }

        return String.format("%s_%s", playlistId, isrc);
    }

    public static String getPlaylistIdWithIsrc(StreamModel streamModel) {
        return getPlaylistIdWithIsrc(streamModel.getPlaylistId(), streamModel.getIsrc());
    }

    public static boolean isAmazonFullPlaylistId(@Nonnull String playlistId) {
        return AMAZON_BASE_PLAYLIST_ID_PATTERN.matcher(playlistId).matches();
    }

    @Nullable
    public static String getAmazonTrackIdUnPrefixed(@Nullable String amazonTrackIdWithPrefix) {
        if (amazonTrackIdWithPrefix == null) {
            return null;
        }

        return amazonTrackIdWithPrefix.startsWith(AMAZON_TRACK_ID_PREFIX)
            ? amazonTrackIdWithPrefix.replace(AMAZON_TRACK_ID_PREFIX, StringUtils.EMPTY)
            : amazonTrackIdWithPrefix;
    }

    /**
     * For each passed 'full' Amazon playlistId (like 'amazon_123_us') will contain entry like {'amazon_123_us': {'amazon_123', 'us'}}
     * <p>
     * Ignores other types of playlistIds, including 'base' Amazon playlist ids (like 'amazon_123')
     */
    public static Map<String, PlaylistIdBaseAndCountryCode> getParsedAmazonFullPlaylistIdsMap(@Nonnull Set<String> playlistIds) {
        return playlistIds.stream()
            .filter(ModelUtils::isAmazonFullPlaylistId)
            .map(playlistId ->
                new PlaylistIdBaseAndCountryCode(
                    playlistId,
                    getBasePlaylistIdFromAmazonFullPlaylistId(playlistId),
                    getCountryCodeFromAmazonFullPlaylistId(playlistId)
                ))
            .collect(Collectors.toMap(
                PlaylistIdBaseAndCountryCode::getOriginalPlaylistId,
                Function.identity()
            ));
    }

    public static String getBasePlaylistIdFromAmazonFullPlaylistId(String fullPlaylistId) {
        return fullPlaylistId.substring(0, fullPlaylistId.lastIndexOf(AMAZON_PLAYLIST_ID_SEPARATOR));
    }

    public static String getCountryCodeFromAmazonFullPlaylistId(String fullPlaylistId) {
        return fullPlaylistId.substring(fullPlaylistId.lastIndexOf(AMAZON_PLAYLIST_ID_SEPARATOR) + 1);
    }


    /**
     * Returns initial playlistIds list passed as param with (only) Amazon full playlistIds cut to base playlistIds E.g. for ['amazon_123_us',
     * 'amazon_456', 'spotify_qwerty'] will return ['amazon_123', 'amazon_456', 'spotify_qwerty']
     */
    public static Optional<List<String>> cutFullAmazonPlaylistIdsToBasePart(@Nullable List<String> requestedPlaylistIds) {
        if (requestedPlaylistIds != null) {
            //will contain entries like {'amazon_123_us': {'amazon_123', 'us'}}
            Map<String, PlaylistIdBaseAndCountryCode> amazonParsedPlaylistIdByFullPlaylistIdMap =
                getParsedAmazonFullPlaylistIdsMap(new HashSet<>(requestedPlaylistIds));

            return Optional.of(
                requestedPlaylistIds.stream()
                    .map(playlistId -> {
                        if (amazonParsedPlaylistIdByFullPlaylistIdMap.containsKey(playlistId)) {
                            return amazonParsedPlaylistIdByFullPlaylistIdMap.get(playlistId).getPlaylistIdBase();
                        } else {
                            return playlistId;
                        }
                    })
                    .collect(Collectors.toList())
            );
        }

        return Optional.empty();
    }

    @Nullable
    public static Long extractLong(@Nullable UInt64Value protobufLong) {
        if (protobufLong == null || protobufLong == UInt64Value.getDefaultInstance()) {
            return null;
        }

        return protobufLong.getValue();
    }

    @Nullable
    public static String extractString(@Nullable StringValue protobufString) {
        if (protobufString == null || protobufString == StringValue.getDefaultInstance()) {
            return null;
        }

        return protobufString.getValue();
    }

    @Nullable
    public static Boolean extractBoolean(@Nullable BoolValue protobufBool) {
        if (protobufBool == null || protobufBool == BoolValue.getDefaultInstance()) {
            return null;
        }

        return protobufBool.getValue();
    }

    @Nullable
    public static Long getTrackDaysOnPlaylist(LocalDate earliestDate, LocalDate latestDate) {
        if (earliestDate == null || latestDate == null) {
            return null;
        }

        return ChronoUnit.DAYS.between(earliestDate, latestDate);
    }

    @Nullable
    public static Long getTrackDaysOnPlaylist(LocalDateTime earliestDateTime, LocalDateTime latestDateTime) {
        if (earliestDateTime == null || latestDateTime == null) {
            return null;
        }
        return getTrackDaysOnPlaylist(earliestDateTime.toLocalDate(), latestDateTime.toLocalDate());
    }

    @Nullable
    public static Long calculatePlaylistTrackPositionTrend(@Nullable Long previous, @Nullable Long current) {
        if (previous != null && current != null) {
            return current - previous;
        }

        return null;
    }

    private static String getPlaylistDspPrefixForDsp(String dsp) {
        String dspPrefix;

        switch (dsp) {
            case DspConstants.SPOTIFY:
                dspPrefix = SPOTIFY_PLAYLIST_ID_PREFIX;
                break;
            case DspConstants.APPLE:
                dspPrefix = APPLE_PLAYLIST_ID_PREFIX;
                break;
            case DspConstants.AMAZON:
                dspPrefix = AMAZON_PLAYLIST_ID_PREFIX;
                break;
            default:
                throw new IllegalArgumentException("unsupported dsp: " + dsp);
        }
        return dspPrefix;
    }

    private static String getTrackDspPrefixForDsp(String dsp) {
        String dspPrefix;

        switch (dsp) {
            case DspConstants.SPOTIFY:
                dspPrefix = SPOTIFY_TRACK_ID_PREFIX;
                break;
            case DspConstants.APPLE:
                dspPrefix = APPLE_TRACK_ID_PREFIX;
                break;
            case DspConstants.AMAZON:
                dspPrefix = AMAZON_TRACK_ID_PREFIX;
                break;
            default:
                throw new IllegalArgumentException("unsupported dsp: " + dsp);
        }
        return dspPrefix;
    }

    public static boolean isPlaylistOfDsp(String playlistId, String dsp) {
        if (playlistId == null || dsp == null) {
            return false;
        }

        return playlistId.startsWith(getPlaylistDspPrefixForDsp(dsp));
    }

    public static boolean isTrackOfDsp(String trackId, String dsp) {
        if (trackId == null || dsp == null) {
            return false;
        }

        return trackId.startsWith(getTrackDspPrefixForDsp(dsp));
    }

    public static List<String> filterPlaylistsByDsp(List<String> playlistIds, Collection<String> dsp) {
        if (CollectionUtils.isEmpty(dsp) || CollectionUtils.isEmpty(playlistIds)) {
            return playlistIds;
        }
        Set<String> dspToFilter = dsp.stream()
            .map(String::toLowerCase)
            .collect(Collectors.toSet());

        return playlistIds.stream()
            .filter(playlistId -> dspToFilter.stream().anyMatch(dspId -> isPlaylistOfDsp(playlistId, dspId)))
            .collect(Collectors.toList());
    }

    public static String addDspPrefixForPlaylistId(String playlistId, String dsp) {
        if (playlistId == null || dsp == null) {
            return null;
        }

        return isPlaylistOfDsp(playlistId, dsp) ?
            playlistId :
            getPlaylistDspPrefixForDsp(dsp) + playlistId;
    }

    public static String removeDspPrefixFromPlaylistId(String playlistId) {
        if (playlistId == null) {
            return null;
        }
        return playlistId.replaceFirst(
            String.format("%s|%s|%s", SPOTIFY_PLAYLIST_ID_PREFIX, APPLE_PLAYLIST_ID_PREFIX, AMAZON_PLAYLIST_ID_PREFIX), "");
    }

    public static Set<String> filterPlaylistIdsByDsp(Collection<String> playlistIds, String dsp) {
        if (playlistIds == null || dsp == null) {
            return Collections.emptySet();
        }

        return playlistIds.stream()
            .filter(pId -> isPlaylistOfDsp(pId, dsp))
            .collect(Collectors.toSet());
    }

    public static Set<PlaylistIdCountryCodeNullableKey> filterPlaylistKeysByDsp(
        Collection<PlaylistIdCountryCodeNullableKey> playlistKeys, String dsp
    ) {
        if (playlistKeys == null || dsp == null) {
            return Collections.emptySet();
        }

        return playlistKeys.stream()
            .filter(key -> isPlaylistOfDsp(key.getPlaylistId(), dsp))
            .collect(Collectors.toSet());
    }

    public static <P extends DspPlaylistIdCountryCodeAwareModel> PlaylistIdCountryCodeNullableKey composePlaylistKeyFromModel(
        P model
    ) {
        return composePlaylistKeyFromValuesAndDsp(
            model.getPlaylistId(),
            model.getCountryCode(),
            model.getDsp()
        );
    }

    public static PlaylistIdCountryCodeNullableKey composePlaylistKeyFromPlaylist(PublicPlaylist playlist) {
        return composePlaylistKeyFromValuesAndDsp(
            playlist.getPlaylistId(),
            playlist.getCountryCode().orElse(null),
            playlist.getDsp().getDspId()
        );
    }

    public static PlaylistIdCountryCodeNullableKey composePlaylistKeyFromValuesAndDsp(
        String playlistIdVal, String countryCodeVal, String dspId
    ) {
        String countryCode = isDspWithMultipleCountriesPlaylistsUnderSamePlaylistId(dspId)
            ? countryCodeVal
            : null;

        return composePlaylistKeyFromValues(playlistIdVal, countryCode);
    }

    public static PlaylistIdCountryCodeNullableKey composePlaylistKeyForSingleCountryPlaylistDsp(String playlistIdVal, String dspId) {
        if (isDspWithMultipleCountriesPlaylistsUnderSamePlaylistId(dspId)) {
            throw new IllegalArgumentException(String.format("DSP '%s' is not a single-country per playlistId dsp", dspId));
        }

        return composePlaylistKeyFromValues(playlistIdVal, null);
    }

    public static PlaylistIdCountryCodeNullableKey composePlaylistKeyFromValues(
        String playlistId, @Nullable String countryCode
    ) {
        return new PlaylistIdCountryCodeNullableKey(playlistId, countryCode);
    }

    public static boolean isDspWithMultipleCountriesPlaylistsUnderSamePlaylistId(String dsp) {
        return DSPS_WITH_MULTIPLE_COUNTRIES_PLAYLISTS_UNDER_SAME_PLAYLIST_ID.contains(dsp);
    }

    public static String publicSpotifyCountryCodeDataToRepresentationState(String spotifyCountryCode) {
        //DAPD-2428 - for Spotify we have to transform particular countryCodes to null or empty line on API side before returning to user.
        //This is just for representation, internally country codes exist in initial form in all DBs etc.
        if ("not_defined".equals(spotifyCountryCode)) {
            return null;
        } else if ("empty".equals(spotifyCountryCode)) {
            return StringUtils.EMPTY;
        } else {
            return spotifyCountryCode;
        }
    }

    public static SpotifyPlaylistTrackLifetimePublicId composeSpotifyPlaylistTrackLifetimePublicIdFromPosition(
        PublicPlaylistTrackPosition position
    ) {
        return composeSpotifyPlaylistTrackLifetimePublicIdFromValues(
            position.getIsrc(),
            position.getPlaylistId()
        );
    }

    public static SpotifyPlaylistTrackLifetimePublicId composeSpotifyPlaylistTrackLifetimePublicIdFromCurrentTracklist(
        PlaylistsTrackPositionsCurrentTracklist currentTracklist
    ) {
        return composeSpotifyPlaylistTrackLifetimePublicIdFromValues(
            currentTracklist.getIsrc(),
            currentTracklist.getPlaylistId()
        );
    }

    public static SpotifyPlaylistTrackLifetimePublicId composeSpotifyPlaylistTrackLifetimePublicIdFromValues(
        String isrc,
        String playlistId
    ) {
        return new SpotifyPlaylistTrackLifetimePublicId(isrc, playlistId);
    }

    public static AppleMusicPlaylistTrackLifetimePublicId composeApplePlaylistTrackLifetimePublicIdFromPosition(
        PublicPlaylistTrackPosition position
    ) {
        return composeApplePlaylistTrackLifetimePublicIdFromValues(
            position.getIsrc(),
            position.getPlaylistId(),
            position.getCountryCode()
        );
    }

    public static AppleMusicPlaylistTrackLifetimePublicId composeApplePlaylistTrackLifetimePublicIdFromCurrentTracklist(
        PlaylistsTrackPositionsCurrentTracklist currentTracklist
    ) {
        return composeApplePlaylistTrackLifetimePublicIdFromValues(
            currentTracklist.getIsrc(),
            currentTracklist.getPlaylistId(),
            currentTracklist.getCountryCode()
        );
    }

    public static AppleMusicPlaylistTrackLifetimePublicId composeApplePlaylistTrackLifetimePublicIdFromValues(
        String isrc,
        String playlistId,
        String countryCode
    ) {
        return new AppleMusicPlaylistTrackLifetimePublicId(isrc, playlistId, countryCode);
    }

    public static String removeDspFromPlaylistOwnerUserName(String username) {
        if (username == null) {
            return null;
        }

        String dspPrefix = null;

        if (username.startsWith(SPOTIFY_PLAYLIST_OWNER_USERNAME_PREFIX)) {
            dspPrefix = SPOTIFY_PLAYLIST_OWNER_USERNAME_PREFIX;
        } else if (username.startsWith(APPLE_PLAYLIST_OWNER_USERNAME_PREFIX)) {
            dspPrefix = APPLE_PLAYLIST_OWNER_USERNAME_PREFIX;
        }

        if (dspPrefix != null) {
            return username.substring(dspPrefix.length());
        } else {
            return username;
        }
    }

    public static PlaylistIdIsrcKey composePlaylistIsrcKeyFromValues(String playlistId, String isrc) {
        return new PlaylistIdIsrcKey(playlistId, isrc);
    }

    public static PlaylistIdIsrcCountryCodeKey composePlaylistIsrcCountryCodeKeyFromValues(String playlistId,
        String isrc, String countryCode) {
        return new PlaylistIdIsrcCountryCodeKey(playlistId, isrc, countryCode);
    }

    public static TrackIdCountryCodeKey composeTrackIdCountryCodeKeyFromValues(String trackId, String countryCode) {
        return new TrackIdCountryCodeKey(trackId, countryCode);
    }

    public static <T> Stream<T> filterForLatestDate(Stream<T> models,
        Function<? super T, String> keyMapper,
        Function<? super T, LocalDate> valueMapper) {
        List<T> modelList = models.collect(Collectors.toList());
        Map<String, LocalDate> latestDateForKey = modelList.stream()
            .collect(Collectors.toMap(keyMapper::apply, valueMapper::apply,
                (d1, d2) -> d1.isAfter(d2) ? d1 : d2));

        return modelList.stream()
            .filter(p -> Objects.equals(valueMapper.apply(p), latestDateForKey.get(keyMapper.apply(p))));
    }

    public static String formatSpotifyArtistUrl(String artistId) {
        return SPOTIFY_ARTIST_URL_PREFIX + artistId;
    }
}
