/*
 * Copyright (C) 2012-2023 Apple Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
 * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
 * PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL APPLE INC. OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
 * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 
 */

#include "config.h"
#include "CaptionUserPreferencesMediaAF.h"

#if ENABLE(VIDEO)

#include "AudioTrackList.h"
#include "CSSValueKeywords.h"
#include "ColorSerialization.h"
#include "CommonAtomStrings.h"
#include "FloatConversion.h"
#include "FontCacheCoreText.h"
#include "HTMLMediaElement.h"
#include "LocalizedStrings.h"
#include "Logging.h"
#include "TextTrackList.h"
#include "UserAgentParts.h"
#include "UserStyleSheetTypes.h"
#include <algorithm>
#include <ranges>
#include <wtf/Language.h>
#include <wtf/NeverDestroyed.h>
#include <wtf/RetainPtr.h>
#include <wtf/SoftLinking.h>
#include <wtf/TZoneMallocInlines.h>
#include <wtf/URL.h>
#include <wtf/cf/VectorCF.h>
#include <wtf/text/CString.h>
#include <wtf/text/MakeString.h>
#include <wtf/text/StringBuilder.h>
#include <wtf/text/cf/StringConcatenateCF.h>
#include <wtf/unicode/Collator.h>

#if PLATFORM(COCOA)
#include <pal/spi/cf/CFNotificationCenterSPI.h>
#endif

#if PLATFORM(IOS_FAMILY)
#include "WebCoreThreadRun.h"
#endif

#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

#include <CoreText/CoreText.h>
#include <MediaAccessibility/MediaAccessibility.h>

#include "MediaAccessibilitySoftLink.h"

SOFT_LINK_FRAMEWORK_OPTIONAL(MediaToolbox)
SOFT_LINK_OPTIONAL(MediaToolbox, MTEnableCaption2015Behavior, Boolean, (), ())

#endif // HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

namespace WebCore {

WTF_MAKE_TZONE_ALLOCATED_IMPL(CaptionUserPreferencesMediaAF);

#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

static std::unique_ptr<CaptionPreferencesDelegate>& captionPreferencesDelegate()
{
    static NeverDestroyed<std::unique_ptr<CaptionPreferencesDelegate>> delegate;
    return delegate.get();
}

static std::optional<CaptionUserPreferencesMediaAF::CaptionDisplayMode>& cachedCaptionDisplayMode()
{
    static NeverDestroyed<std::optional<CaptionUserPreferencesMediaAF::CaptionDisplayMode>> captionDisplayMode;
    return captionDisplayMode;
}

static std::optional<Vector<String>>& cachedPreferredLanguages()
{
    static NeverDestroyed<std::optional<Vector<String>>> preferredLanguages;
    return preferredLanguages;
}

static void userCaptionPreferencesChangedNotificationCallback(CFNotificationCenterRef, void* observer, CFStringRef, const void*, CFDictionaryRef)
{
    RefPtr userPreferences = CaptionUserPreferencesMediaAF::extractCaptionUserPreferencesMediaAF(observer);
    if (userPreferences) {
#if !PLATFORM(IOS_FAMILY)
        userPreferences->captionPreferencesChanged();
#else
        WebThreadRun(^{
            userPreferences->captionPreferencesChanged();
        });
#endif
    }
}

#endif // HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

Ref<CaptionUserPreferencesMediaAF> CaptionUserPreferencesMediaAF::create(PageGroup& group)
{
    return adoptRef(*new CaptionUserPreferencesMediaAF(group));
}

CaptionUserPreferencesMediaAF::CaptionUserPreferencesMediaAF(PageGroup& group)
    : CaptionUserPreferences(group)
#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)
    , m_updateStyleSheetTimer(*this, &CaptionUserPreferencesMediaAF::updateTimerFired)
#endif
{
    static bool initialized;
    if (!initialized) {
        initialized = true;

        if (!MediaToolboxLibrary())
            return;

        MTEnableCaption2015BehaviorPtrType function = MTEnableCaption2015BehaviorPtr();
        if (!function || !function())
            return;

        beginBlockingNotifications();
        CaptionUserPreferences::setCaptionDisplayMode(CaptionDisplayMode::Manual);
        setUserPrefersCaptions(false);
        setUserPrefersSubtitles(false);
        setUserPrefersTextDescriptions(false);
        endBlockingNotifications();
    }
}

CaptionUserPreferencesMediaAF::~CaptionUserPreferencesMediaAF()
{
#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)
    if (m_observer) {
        auto center = CFNotificationCenterGetLocalCenter();
        if (kMAXCaptionAppearanceSettingsChangedNotification)
            CFNotificationCenterRemoveObserver(center, m_observer.get(), kMAXCaptionAppearanceSettingsChangedNotification, 0);
        if (kMAAudibleMediaSettingsChangedNotification)
            CFNotificationCenterRemoveObserver(center, m_observer.get(), kMAAudibleMediaSettingsChangedNotification, 0);
    }
#endif // HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)
}

#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

CaptionUserPreferences::CaptionDisplayMode CaptionUserPreferencesMediaAF::captionDisplayMode() const
{
    CaptionDisplayMode internalMode = CaptionUserPreferences::captionDisplayMode();
    if (internalMode == CaptionDisplayMode::Manual || testingMode() || !MediaAccessibilityLibrary())
        return internalMode;

    if (cachedCaptionDisplayMode().has_value())
        return cachedCaptionDisplayMode().value();

    return platformCaptionDisplayMode();
}

void CaptionUserPreferencesMediaAF::platformSetCaptionDisplayMode(CaptionDisplayMode mode)
{
    MACaptionAppearanceDisplayType displayType = kMACaptionAppearanceDisplayTypeForcedOnly;
    switch (mode) {
    case CaptionDisplayMode::Automatic:
        displayType = kMACaptionAppearanceDisplayTypeAutomatic;
        break;
    case CaptionDisplayMode::ForcedOnly:
        displayType = kMACaptionAppearanceDisplayTypeForcedOnly;
        break;
    case CaptionDisplayMode::AlwaysOn:
        displayType = kMACaptionAppearanceDisplayTypeAlwaysOn;
        break;
    default:
        ASSERT_NOT_REACHED();
        break;
    }

    MACaptionAppearanceSetDisplayType(kMACaptionAppearanceDomainUser, displayType);
}

void CaptionUserPreferencesMediaAF::setCaptionDisplayMode(CaptionUserPreferences::CaptionDisplayMode mode)
{
    if (testingMode() || !MediaAccessibilityLibrary()) {
        CaptionUserPreferences::setCaptionDisplayMode(mode);
        return;
    }

    if (captionDisplayMode() == CaptionDisplayMode::Manual)
        return;

    if (captionPreferencesDelegate()) {
        captionPreferencesDelegate()->setDisplayMode(mode);
        return;
    }
    
    platformSetCaptionDisplayMode(mode);
}

CaptionUserPreferences::CaptionDisplayMode CaptionUserPreferencesMediaAF::platformCaptionDisplayMode()
{
    MACaptionAppearanceDisplayType displayType = MACaptionAppearanceGetDisplayType(kMACaptionAppearanceDomainUser);
    switch (displayType) {
    case kMACaptionAppearanceDisplayTypeForcedOnly:
        return CaptionDisplayMode::ForcedOnly;

    case kMACaptionAppearanceDisplayTypeAutomatic:
        return CaptionDisplayMode::Automatic;

    case kMACaptionAppearanceDisplayTypeAlwaysOn:
        return CaptionDisplayMode::AlwaysOn;
    }

    ASSERT_NOT_REACHED();
    return CaptionDisplayMode::ForcedOnly;
}

void CaptionUserPreferencesMediaAF::setCachedCaptionDisplayMode(CaptionDisplayMode captionDisplayMode)
{
    cachedCaptionDisplayMode() = captionDisplayMode;
}

bool CaptionUserPreferencesMediaAF::userPrefersCaptions() const
{
    bool captionSetting = CaptionUserPreferences::userPrefersCaptions();
    if (captionSetting || testingMode() || !MediaAccessibilityLibrary())
        return captionSetting;

    RetainPtr captioningMediaCharacteristics = adoptCF(MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(kMACaptionAppearanceDomainUser));
    return captioningMediaCharacteristics && CFArrayGetCount(captioningMediaCharacteristics.get());
}

bool CaptionUserPreferencesMediaAF::userPrefersSubtitles() const
{
    bool subtitlesSetting = CaptionUserPreferences::userPrefersSubtitles();
    if (subtitlesSetting || testingMode() || !MediaAccessibilityLibrary())
        return subtitlesSetting;
    
    RetainPtr captioningMediaCharacteristics = adoptCF(MACaptionAppearanceCopyPreferredCaptioningMediaCharacteristics(kMACaptionAppearanceDomainUser));
    return !(captioningMediaCharacteristics && CFArrayGetCount(captioningMediaCharacteristics.get()));
}

bool CaptionUserPreferencesMediaAF::userPrefersTextDescriptions() const
{
    bool prefersTextDescriptions = CaptionUserPreferences::userPrefersTextDescriptions();
    if (prefersTextDescriptions || testingMode() || !MediaAccessibilityLibrary())
        return prefersTextDescriptions;

    RetainPtr preferDescriptiveVideo = adoptCF(MAAudibleMediaPrefCopyPreferDescriptiveVideo());
    return preferDescriptiveVideo && CFBooleanGetValue(preferDescriptiveVideo.get());
}

void CaptionUserPreferencesMediaAF::updateTimerFired()
{
    updateCaptionStyleSheetOverride();
}

void CaptionUserPreferencesMediaAF::setInterestedInCaptionPreferenceChanges()
{
    if (m_listeningForPreferenceChanges)
        return;

    if (!MediaAccessibilityLibrary())
        return;

    if (!kMAXCaptionAppearanceSettingsChangedNotification && !canLoad_MediaAccessibility_kMAAudibleMediaSettingsChangedNotification())
        return;

    m_listeningForPreferenceChanges = true;
    m_registeringForNotification = true;

    if (!m_observer)
        lazyInitialize(m_observer, createWeakObserver(this));
    auto suspensionBehavior = static_cast<CFNotificationSuspensionBehavior>(CFNotificationSuspensionBehaviorCoalesce | _CFNotificationObserverIsObjC);
    auto center = CFNotificationCenterGetLocalCenter();
    if (kMAXCaptionAppearanceSettingsChangedNotification)
        CFNotificationCenterAddObserver(center, m_observer.get(), userCaptionPreferencesChangedNotificationCallback, kMAXCaptionAppearanceSettingsChangedNotification, 0, suspensionBehavior);

    if (canLoad_MediaAccessibility_kMAAudibleMediaSettingsChangedNotification())
        CFNotificationCenterAddObserver(center, m_observer.get(), userCaptionPreferencesChangedNotificationCallback, kMAAudibleMediaSettingsChangedNotification, 0, suspensionBehavior);
    m_registeringForNotification = false;

    // Generating and registering the caption stylesheet can be expensive and this method is called indirectly when the parser creates an audio or
    // video element, so do it after a brief pause.
    m_updateStyleSheetTimer.startOneShot(0_s);
}

void CaptionUserPreferencesMediaAF::captionPreferencesChanged()
{
    if (m_registeringForNotification)
        return;

    if (m_listeningForPreferenceChanges)
        updateCaptionStyleSheetOverride();

    CaptionUserPreferences::captionPreferencesChanged();
}

void CaptionUserPreferencesMediaAF::setCaptionPreferencesDelegate(std::unique_ptr<CaptionPreferencesDelegate>&& delegate)
{
    captionPreferencesDelegate() = WTFMove(delegate);
}

static bool behaviorShouldNotBeOverriden(MACaptionAppearanceBehavior behavior)
{
    if (!canLoad_MediaAccessibility_MACaptionAppearanceIsCustomized())
        return behavior == kMACaptionAppearanceBehaviorUseValue;

    return (behavior == kMACaptionAppearanceBehaviorUseValue) && MACaptionAppearanceIsCustomized(kMACaptionAppearanceDomainUser);
}

String CaptionUserPreferencesMediaAF::captionsWindowCSS() const
{
    MACaptionAppearanceBehavior behavior;
    RetainPtr color = adoptCF(MACaptionAppearanceCopyWindowColor(kMACaptionAppearanceDomainUser, &behavior));

    Color windowColor(roundAndClampToSRGBALossy(color.get()));
    if (!windowColor.isValid())
        windowColor = Color::transparentBlack;

    bool important = behaviorShouldNotBeOverriden(behavior);
    CGFloat opacity = MACaptionAppearanceGetWindowOpacity(kMACaptionAppearanceDomainUser, &behavior);
    if (!important)
        important = behaviorShouldNotBeOverriden(behavior);
    return colorPropertyCSS(CSSPropertyBackgroundColor, windowColor.colorWithAlpha(opacity), important);
}

String CaptionUserPreferencesMediaAF::captionsBackgroundCSS() const
{
    // This must match the ::cue background color of WebCore/Modules/modern-media-controls/controls/text-tracks.css
    constexpr auto defaultBackgroundColor = Color::black.colorWithAlphaByte(204);

    MACaptionAppearanceBehavior behavior;

    RetainPtr color = adoptCF(MACaptionAppearanceCopyBackgroundColor(kMACaptionAppearanceDomainUser, &behavior));
    Color backgroundColor(roundAndClampToSRGBALossy(color.get()));
    if (!backgroundColor.isValid())
        backgroundColor = defaultBackgroundColor;

    bool important = behaviorShouldNotBeOverriden(behavior);
    CGFloat opacity = MACaptionAppearanceGetBackgroundOpacity(kMACaptionAppearanceDomainUser, &behavior);
    if (!important)
        important = behaviorShouldNotBeOverriden(behavior);
    return colorPropertyCSS(CSSPropertyBackgroundColor, backgroundColor.colorWithAlpha(opacity), important);
}

Color CaptionUserPreferencesMediaAF::captionsTextColor(bool& important) const
{
    MACaptionAppearanceBehavior behavior;
    RetainPtr color = adoptCF(MACaptionAppearanceCopyForegroundColor(kMACaptionAppearanceDomainUser, &behavior)).get();
    Color textColor(roundAndClampToSRGBALossy(color.get()));
    if (!textColor.isValid()) {
        // This must match the ::cue text color of WebCore/Modules/modern-media-controls/controls/text-tracks.css
        textColor = Color::white;
    }
    important = behaviorShouldNotBeOverriden(behavior);
    CGFloat opacity = MACaptionAppearanceGetForegroundOpacity(kMACaptionAppearanceDomainUser, &behavior);
    if (!important)
        important = behaviorShouldNotBeOverriden(behavior);
    return textColor.colorWithAlpha(opacity);
}

String CaptionUserPreferencesMediaAF::captionsTextColorCSS() const
{
    bool important;
    auto textColor = captionsTextColor(important);
    if (!textColor.isValid())
        return emptyString();
    return colorPropertyCSS(CSSPropertyColor, textColor, important);
}

template<typename... Types> void appendCSS(StringBuilder& builder, CSSPropertyID id, bool important, const Types&... values)
{
    builder.append(nameLiteral(id), ':', values..., important ? " !important;"_s : ";"_s);
}

String CaptionUserPreferencesMediaAF::windowRoundedCornerRadiusCSS() const
{
    MACaptionAppearanceBehavior behavior;
    CGFloat radius = MACaptionAppearanceGetWindowRoundedCornerRadius(kMACaptionAppearanceDomainUser, &behavior);
    if (!radius)
        return emptyString();

    StringBuilder builder;
    appendCSS(builder, CSSPropertyBorderRadius, behaviorShouldNotBeOverriden(behavior), radius, "px"_s);
    appendCSS(builder, CSSPropertyPadding, behaviorShouldNotBeOverriden(behavior), radius / 4, "px"_s);
    return builder.toString();
}

String CaptionUserPreferencesMediaAF::colorPropertyCSS(CSSPropertyID id, const Color& color, bool important) const
{
    StringBuilder builder;
    // FIXME: Seems like this should be using serializationForCSS instead?
    appendCSS(builder, id, important, serializationForHTML(color));
    return builder.toString();
}

bool CaptionUserPreferencesMediaAF::captionStrokeWidthForFont(float fontSize, const String& language, float& strokeWidth, bool& important) const
{
    if (!canLoad_MediaAccessibility_MACaptionAppearanceCopyFontDescriptorWithStrokeForStyle())
        return false;
    
    MACaptionAppearanceBehavior behavior;
    auto trackLanguage = language.createCFString();
    CGFloat strokeWidthPt;
    
    RetainPtr fontDescriptor = adoptCF(MACaptionAppearanceCopyFontDescriptorWithStrokeForStyle(kMACaptionAppearanceDomainUser, &behavior, kMACaptionAppearanceFontStyleDefault, trackLanguage.get(), fontSize, &strokeWidthPt));

    if (!fontDescriptor)
        return false;

    // Since only half of the stroke is visible because the stroke is drawn before the fill, we double the stroke width here.
    strokeWidth = strokeWidthPt * 2;
    important = behaviorShouldNotBeOverriden(behavior);
    return true;
}

String CaptionUserPreferencesMediaAF::captionsTextEdgeCSS() const
{
    MACaptionAppearanceBehavior behavior;
    MACaptionAppearanceTextEdgeStyle textEdgeStyle = MACaptionAppearanceGetTextEdgeStyle(kMACaptionAppearanceDomainUser, &behavior);
    
    if (textEdgeStyle == kMACaptionAppearanceTextEdgeStyleUndefined || textEdgeStyle == kMACaptionAppearanceTextEdgeStyleNone)
        return emptyString();

    StringBuilder builder;
    bool important = behaviorShouldNotBeOverriden(behavior);
    if (textEdgeStyle == kMACaptionAppearanceTextEdgeStyleRaised)
        appendCSS(builder, CSSPropertyTextShadow, important, "-.1em -.1em .16em black"_s);
    else if (textEdgeStyle == kMACaptionAppearanceTextEdgeStyleDepressed)
        appendCSS(builder, CSSPropertyTextShadow, important, ".1em .1em .16em black"_s);
    else if (textEdgeStyle == kMACaptionAppearanceTextEdgeStyleDropShadow)
        appendCSS(builder, CSSPropertyTextShadow, important, "0 .1em .16em black"_s);

    if (textEdgeStyle == kMACaptionAppearanceTextEdgeStyleDropShadow || textEdgeStyle == kMACaptionAppearanceTextEdgeStyleUniform) {
        appendCSS(builder, CSSPropertyStrokeColor, important, "black"_s);
        appendCSS(builder, CSSPropertyPaintOrder, important, nameLiteral(CSSValueStroke));
        appendCSS(builder, CSSPropertyStrokeLinejoin, important, nameLiteral(CSSValueRound));
        appendCSS(builder, CSSPropertyStrokeLinecap, important, nameLiteral(CSSValueRound));
    }
    
    return builder.toString();
}

String CaptionUserPreferencesMediaAF::captionsDefaultFontCSS() const
{
    MACaptionAppearanceBehavior behavior;
    
    RetainPtr font = adoptCF(MACaptionAppearanceCopyFontDescriptorForStyle(kMACaptionAppearanceDomainUser, &behavior, kMACaptionAppearanceFontStyleDefault));
    if (!font)
        return emptyString();

    RetainPtr name = adoptCF(static_cast<CFStringRef>(CTFontDescriptorCopyAttribute(font.get(), kCTFontNameAttribute)));
    if (!name)
        return emptyString();

    if (fontNameIsSystemFont(name.get())) {
        if (CFStringHasPrefix(CFSTR(".AppleSystemUIFontMonospaced"), name.get()))
            name = CFSTR("system-ui-monospaced");
        else if (CFStringHasPrefix(CFSTR(".AppleSystemUIFont"), name.get()))
            name = CFSTR("system-ui");
        else {
            // FIXME: Add more fallbacks for system font names
            // Default to "system-ui" for all other disallowed system fonts
            name = CFSTR("system-ui");
        }
    }

    StringBuilder builder;
    builder.append("font-family: \""_s, name.get(), '"');
    if (RetainPtr cascadeList = adoptCF(static_cast<CFArrayRef>(CTFontDescriptorCopyAttribute(font.get(), kCTFontCascadeListAttribute)))) {
        for (CFIndex i = 0; i < CFArrayGetCount(cascadeList.get()); i++) {
            auto fontCascade = static_cast<CTFontDescriptorRef>(CFArrayGetValueAtIndex(cascadeList.get(), i));
            if (!fontCascade)
                continue;
            RetainPtr fontCascadeName = adoptCF(static_cast<CFStringRef>(CTFontDescriptorCopyAttribute(fontCascade, kCTFontNameAttribute)));
            if (!fontCascadeName)
                continue;
            builder.append(", \""_s, fontCascadeName.get(), '"');
        }
    }
    builder.append(behaviorShouldNotBeOverriden(behavior) ? " !important;"_s : ";"_s);
    return builder.toString();
}

float CaptionUserPreferencesMediaAF::captionFontSizeScaleAndImportance(bool& important) const
{
    if (testingMode() || !MediaAccessibilityLibrary())
        return CaptionUserPreferences::captionFontSizeScaleAndImportance(important);

    MACaptionAppearanceBehavior behavior;
    CGFloat characterScale = CaptionUserPreferences::captionFontSizeScaleAndImportance(important);
    CGFloat scaleAdjustment = MACaptionAppearanceGetRelativeCharacterSize(kMACaptionAppearanceDomainUser, &behavior);

    if (!scaleAdjustment)
        return characterScale;

    important = behaviorShouldNotBeOverriden(behavior);
#if defined(__LP64__) && __LP64__
    return narrowPrecisionToFloat(scaleAdjustment * characterScale);
#else
    return scaleAdjustment * characterScale;
#endif
}

void CaptionUserPreferencesMediaAF::platformSetPreferredLanguage(const String& language)
{
    MACaptionAppearanceAddSelectedLanguage(kMACaptionAppearanceDomainUser, language.createCFString().get());
}

void CaptionUserPreferencesMediaAF::setPreferredLanguage(const String& language)
{
    if (CaptionUserPreferences::captionDisplayMode() == CaptionDisplayMode::Manual)
        return;

    if (testingMode() || !MediaAccessibilityLibrary()) {
        CaptionUserPreferences::setPreferredLanguage(language);
        return;
    }

    if (captionPreferencesDelegate()) {
        captionPreferencesDelegate()->setPreferredLanguage(language);
        return;
    }

    platformSetPreferredLanguage(language);
}

Vector<String> CaptionUserPreferencesMediaAF::preferredLanguages() const
{
    auto preferredLanguages = CaptionUserPreferences::preferredLanguages();
    if (testingMode() || !MediaAccessibilityLibrary())
        return preferredLanguages;

    if (cachedPreferredLanguages().has_value())
        return cachedPreferredLanguages().value();

    auto captionLanguages = platformPreferredLanguages();
    if (!captionLanguages.size())
        return preferredLanguages;

    Vector<String> captionAndPreferredLanguages;
    captionAndPreferredLanguages.reserveInitialCapacity(captionLanguages.size() + preferredLanguages.size());
    captionAndPreferredLanguages.appendVector(WTFMove(captionLanguages));
    captionAndPreferredLanguages.appendVector(WTFMove(preferredLanguages));
    return captionAndPreferredLanguages;
}

Vector<String> CaptionUserPreferencesMediaAF::platformPreferredLanguages()
{
    RetainPtr captionLanguages = adoptCF(MACaptionAppearanceCopySelectedLanguages(kMACaptionAppearanceDomainUser));
    return captionLanguages ? makeVector<String>(captionLanguages.get()) : Vector<String> { };
}

void CaptionUserPreferencesMediaAF::setCachedPreferredLanguages(const Vector<String>& preferredLanguages)
{
    cachedPreferredLanguages() = preferredLanguages;
}

void CaptionUserPreferencesMediaAF::setPreferredAudioCharacteristic(const String& characteristic)
{
    if (testingMode() || !MediaAccessibilityLibrary())
        CaptionUserPreferences::setPreferredAudioCharacteristic(characteristic);
}

Vector<String> CaptionUserPreferencesMediaAF::preferredAudioCharacteristics() const
{
    if (testingMode() || !MediaAccessibilityLibrary() || !canLoad_MediaAccessibility_MAAudibleMediaCopyPreferredCharacteristics())
        return CaptionUserPreferences::preferredAudioCharacteristics();

    CFIndex characteristicCount = 0;
    RetainPtr characteristics = adoptCF(MAAudibleMediaCopyPreferredCharacteristics());
    if (characteristics)
        characteristicCount = CFArrayGetCount(characteristics.get());

    if (!characteristicCount)
        return CaptionUserPreferences::preferredAudioCharacteristics();

    return makeVector<String>(characteristics.get());
}
#endif // HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

String CaptionUserPreferencesMediaAF::captionsStyleSheetOverride() const
{
    if (testingMode())
        return CaptionUserPreferences::captionsStyleSheetOverride();
    
    StringBuilder captionsOverrideStyleSheet;

#if HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)
    if (!MediaAccessibilityLibrary())
        return CaptionUserPreferences::captionsStyleSheetOverride();

    String captionsColor = captionsTextColorCSS();
    String edgeStyle = captionsTextEdgeCSS();
    String fontName = captionsDefaultFontCSS();
    String background = captionsBackgroundCSS();
    if (!background.isEmpty() || !captionsColor.isEmpty() || !edgeStyle.isEmpty() || !fontName.isEmpty()) {
        captionsOverrideStyleSheet.append(" ::"_s, UserAgentParts::cue(), '{', background, captionsColor, edgeStyle, fontName, '}');
        captionsOverrideStyleSheet.append(" ::"_s, UserAgentParts::cue(), "(rt) {"_s, background, captionsColor, edgeStyle, fontName, '}');
    }
    String windowColor = captionsWindowCSS();
    String windowCornerRadius = windowRoundedCornerRadiusCSS();
    if (!windowColor.isEmpty() || !windowCornerRadius.isEmpty())
        captionsOverrideStyleSheet.append(" ::"_s, UserAgentParts::webkitMediaTextTrackDisplayBackdrop(), '{', windowColor, windowCornerRadius, '}');
#endif // HAVE(MEDIA_ACCESSIBILITY_FRAMEWORK)

    LOG(Media, "CaptionUserPreferencesMediaAF::captionsStyleSheetOverrideSetting style to:\n%s", captionsOverrideStyleSheet.toString().utf8().data());

    return captionsOverrideStyleSheet.toString();
}

static String languageIdentifier(const String& languageCode)
{
    if (languageCode.isEmpty())
        return languageCode;

    String lowercaseLanguageCode;
    // Need 2U here to disambiguate String::operator[] from operator(NSString*, int)[] in a production build.
    if (languageCode.length() >= 3 && (languageCode[2U] == '_' || languageCode[2U] == '-'))
        lowercaseLanguageCode = StringView(languageCode).left(2).convertToASCIILowercase();
    else
        lowercaseLanguageCode = languageCode.convertToASCIILowercase();

    return lowercaseLanguageCode;
}

static String addTextTrackKindDisplayNameIfNeeded(const TextTrack& track, const String& text)
{
    String result;

    if (track.isClosedCaptions()) {
        if (!text.contains(textTrackKindClosedCaptionsDisplayName()))
            result = addTextTrackKindClosedCaptionsSuffix(text);
    } else {
        switch (track.kind()) {
        case TextTrack::Kind::Subtitles:
            // Subtitle text tracks are only shown in a dedicated "Subtitle" grouping, meaning it
            // would look odd to add it as a suffix (e.g. "Subtitles > English Subtitles").
            break;

        case TextTrack::Kind::Captions:
            if (!text.contains(textTrackKindCaptionsDisplayName()))
                result = addTextTrackKindCaptionsSuffix(text);
            break;

        case TextTrack::Kind::Descriptions:
            if (!text.contains(textTrackKindDescriptionsDisplayName()))
                result = addTextTrackKindDescriptionsSuffix(text);
            break;

        case TextTrack::Kind::Chapters:
            if (!text.contains(textTrackKindChaptersDisplayName()))
                result = addTextTrackKindChaptersSuffix(text);
            break;

        case TextTrack::Kind::Metadata:
            if (!text.contains(textTrackKindMetadataDisplayName()))
                result = addTextTrackKindMetadataSuffix(text);
            break;

        case TextTrack::Kind::Forced:
            // Handled below.
            break;
        }
    }

    if (result.isEmpty())
        result = text;

    if (track.isSDH() && !text.contains(textTrackKindSDHDisplayName()))
        result = addTextTrackKindSDHSuffix(result);

    if (track.isEasyToRead() && !text.contains(textTrackKindEasyReaderDisplayName()))
        result = addTextTrackKindEasyReaderSuffix(result);

    if ((track.containsOnlyForcedSubtitles() || track.kind() == TextTrack::Kind::Forced) && !text.contains(textTrackKindForcedDisplayName()))
        result = addTextTrackKindForcedSuffix(result);

    return result;
}

static String addAudioTrackKindDisplayNameIfNeeded(const AudioTrack& track, const String& text)
{
    // Audio tracks are only shown in a dedicated "Languages" grouping, meaning it would look odd to
    // add it as a suffix (e.g. "Languages > English Language").

    if ((track.kind() == AudioTrack::descriptionKeyword() || track.kind() == AudioTrack::mainDescKeyword()) && !text.contains(audioTrackKindDescriptionsDisplayName()))
        return addAudioTrackKindDescriptionsSuffix(text);

    if (track.kind() == commentaryAtom() && !text.contains(audioTrackKindCommentaryDisplayName()))
        return addAudioTrackKindCommentarySuffix(text);

    return text;
}

static String addTrackKindDisplayNameIfNeeded(const TrackBase& track, const String& text)
{
    switch (track.type()) {
    case TrackBase::Type::TextTrack:
        return addTextTrackKindDisplayNameIfNeeded(downcast<TextTrack>(track), text);

    case TrackBase::Type::AudioTrack:
        return addAudioTrackKindDisplayNameIfNeeded(downcast<AudioTrack>(track), text);

    case TrackBase::Type::VideoTrack:
    case TrackBase::Type::BaseTrack:
        ASSERT_NOT_REACHED();
        break;
    }

    return text;
}

static String trackDisplayName(const TrackBase& track)
{
    if (&track == &TextTrack::captionMenuOffItem())
        return textTrackOffMenuItemText();
    if (&track == &TextTrack::captionMenuAutomaticItem())
        return textTrackAutomaticMenuItemText();

    String result;

    String label = track.label().string().trim(isASCIIWhitespace);
    String trackLanguageIdentifier = track.validBCP47Language();

    auto preferredLanguages = userPreferredLanguages(ShouldMinimizeLanguages::No);
    auto defaultLanguage = !preferredLanguages.isEmpty() ? preferredLanguages[0] : emptyString(); // This matches `defaultLanguage`.
    RetainPtr currentLocale = adoptCF(CFLocaleCreate(kCFAllocatorDefault, defaultLanguage.createCFString().get()));
    RetainPtr localeIdentifier = adoptCF(CFLocaleCreateCanonicalLocaleIdentifierFromString(kCFAllocatorDefault, trackLanguageIdentifier.createCFString().get()));
    String languageDisplayName = adoptCF(CFLocaleCopyDisplayNameForPropertyValue(currentLocale.get(), kCFLocaleLanguageCode, localeIdentifier.get())).get();

    bool exactMatch;
    bool matchesDefaultLanguage = !indexOfBestMatchingLanguageInList(trackLanguageIdentifier, { defaultLanguage }, exactMatch);

    // Only add the language if the track isn't for the default language or the track's label doesn't already contain the language.
    if (!label.isEmpty() && (matchesDefaultLanguage || (!languageDisplayName.isEmpty() && label.contains(languageDisplayName))))
        result = addTrackKindDisplayNameIfNeeded(track, label);
    else {
        String languageAndLocale = adoptCF(CFLocaleCopyDisplayNameForPropertyValue(currentLocale.get(), kCFLocaleIdentifier, trackLanguageIdentifier.createCFString().get())).get();
        if (!languageAndLocale.isEmpty())
            result = languageAndLocale;
        else if (!languageDisplayName.isEmpty())
            result = languageDisplayName;
        else
            result = localeIdentifier.get();

        result = addTrackKindDisplayNameIfNeeded(track, result);

        if (!label.isEmpty()) {
            if (result.isEmpty())
                result = label;
            else if (!result.contains(label))
                result = addTrackLabelAsSuffix(result, label);
        }
    }

    if (result.isEmpty())
        return trackNoLabelText();

    return result;
}

String CaptionUserPreferencesMediaAF::displayNameForTrack(AudioTrack* track) const
{
    return trackDisplayName(*track);
}

String CaptionUserPreferencesMediaAF::displayNameForTrack(TextTrack* track) const
{
    return trackDisplayName(*track);
}

static bool textTrackCompare(const RefPtr<TextTrack>& a, const RefPtr<TextTrack>& b)
{
    auto preferredLanguages = userPreferredLanguages(ShouldMinimizeLanguages::No);

    bool aExactMatch;
    auto aUserLanguageIndex = indexOfBestMatchingLanguageInList(a->validBCP47Language(), preferredLanguages, aExactMatch);

    bool bExactMatch;
    auto bUserLanguageIndex = indexOfBestMatchingLanguageInList(b->validBCP47Language(), preferredLanguages, bExactMatch);

    if (aUserLanguageIndex != bUserLanguageIndex)
        return aUserLanguageIndex < bUserLanguageIndex;
    if (aExactMatch != bExactMatch)
        return aExactMatch;

    String aLanguageDisplayName = displayNameForLanguageLocale(languageIdentifier(a->validBCP47Language()));
    String bLanguageDisplayName = displayNameForLanguageLocale(languageIdentifier(b->validBCP47Language()));

    Collator collator;

    // Tracks not in the user's preferred language sort first by language ...
    if (auto languageDisplayNameComparison = collator.collate(aLanguageDisplayName, bLanguageDisplayName))
        return languageDisplayNameComparison < 0;

    // ... but when tracks have the same language, main program content sorts next highest ...
    bool aIsMainContent = a->isMainProgramContent();
    bool bIsMainContent = b->isMainProgramContent();
    if (aIsMainContent != bIsMainContent)
        return aIsMainContent;

    // ... and main program tracks sort higher than CC tracks ...
    bool aIsCC = a->isClosedCaptions();
    bool bIsCC = b->isClosedCaptions();
    if (aIsCC != bIsCC)
        return aIsCC;

    // ... and tracks of the same type and language sort by the menu item text.
    if (auto trackDisplayComparison = collator.collate(trackDisplayName(*a), trackDisplayName(*b)))
        return trackDisplayComparison < 0;

    // ... and if the menu item text is the same, compare the unique IDs
    return a->uniqueId() < b->uniqueId();
}

Vector<RefPtr<AudioTrack>> CaptionUserPreferencesMediaAF::sortedTrackListForMenu(AudioTrackList* trackList)
{
    ASSERT(trackList);
    
    Vector<RefPtr<AudioTrack>> tracksForMenu;
    
    for (unsigned i = 0, length = trackList->length(); i < length; ++i) {
        AudioTrack* track = trackList->item(i);
        String language = displayNameForLanguageLocale(track->validBCP47Language());
        tracksForMenu.append(track);
    }

    Collator collator;

    std::ranges::sort(tracksForMenu, [&] (auto& a, auto& b) {
        if (auto trackDisplayComparison = collator.collate(trackDisplayName(*a), trackDisplayName(*b)))
            return trackDisplayComparison < 0;

        return a->uniqueId() < b->uniqueId();
    });
    
    return tracksForMenu;
}

Vector<RefPtr<TextTrack>> CaptionUserPreferencesMediaAF::sortedTrackListForMenu(TextTrackList* trackList, HashSet<TextTrack::Kind> kinds)
{
    ASSERT(trackList);

    Vector<RefPtr<TextTrack>> tracksForMenu;
    HashSet<String> languagesIncluded;
    CaptionDisplayMode displayMode = captionDisplayMode();
    bool prefersAccessibilityTracks = userPrefersCaptions();
    bool filterTrackList = shouldFilterTrackMenu();
    bool requestingCaptionsOrDescriptionsOrSubtitles = kinds.contains(TextTrack::Kind::Subtitles) || kinds.contains(TextTrack::Kind::Captions) || kinds.contains(TextTrack::Kind::Descriptions);

    for (unsigned i = 0, length = trackList->length(); i < length; ++i) {
        RefPtr track = trackList->item(i);
        if (!kinds.contains(track->kind()))
            continue;

        String language = displayNameForLanguageLocale(track->validBCP47Language());

        if (displayMode == CaptionDisplayMode::Manual) {
            LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - adding '%s' track with language '%s' because selection mode is 'manual'", track->kindKeyword().string().utf8().data(), language.utf8().data());
            tracksForMenu.append(track);
            continue;
        }

        if (requestingCaptionsOrDescriptionsOrSubtitles) {
            if (track->containsOnlyForcedSubtitles()) {
                LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - skipping '%s' track with language '%s' because it contains only forced subtitles", track->kindKeyword().string().utf8().data(), language.utf8().data());
                continue;
            }

            if (track->isEasyToRead()) {
                LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - adding '%s' track with language '%s' because it is 'easy to read'", track->kindKeyword().string().utf8().data(), language.utf8().data());
                if (!language.isEmpty())
                    languagesIncluded.add(language);
                tracksForMenu.append(track);
                continue;
            }

            if (track->mode() == TextTrack::Mode::Showing) {
                LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - adding '%s' track with language '%s' because it is already visible", track->kindKeyword().string().utf8().data(), language.utf8().data());
                if (!language.isEmpty())
                    languagesIncluded.add(language);
                tracksForMenu.append(track);
                continue;
            }

            if (!language.isEmpty() && track->isMainProgramContent()) {
                bool isAccessibilityTrack = track->kind() == TextTrack::Kind::Captions;
                if (prefersAccessibilityTracks) {
                    // In the first pass, include only caption tracks if the user prefers accessibility tracks.
                    if (!isAccessibilityTrack && filterTrackList) {
                        LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - skipping '%s' track with language '%s' because it is NOT an accessibility track", track->kindKeyword().string().utf8().data(), language.utf8().data());
                        continue;
                    }
                } else {
                    // In the first pass, only include the first non-CC or SDH track with each language if the user prefers translation tracks.
                    if (isAccessibilityTrack && filterTrackList) {
                        LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - skipping '%s' track with language '%s' because it is an accessibility track", track->kindKeyword().string().utf8().data(), language.utf8().data());
                        continue;
                    }
                    if (languagesIncluded.contains(language) && filterTrackList) {
                        LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - skipping '%s' track with language '%s' because it is not the first with this language", track->kindKeyword().string().utf8().data(), language.utf8().data());
                        continue;
                    }
                }
            }

            if (!language.isEmpty())
                languagesIncluded.add(language);
        }

        tracksForMenu.append(track);

        LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - adding '%s' track with language '%s', is%s main program content", track->kindKeyword().string().utf8().data(), language.utf8().data(), track->isMainProgramContent() ? "" : " NOT");
    }

    if (requestingCaptionsOrDescriptionsOrSubtitles) {
        // Now that we have filtered for the user's accessibility/translation preference, add  all tracks with a unique language without regard to track type.
        for (unsigned i = 0, length = trackList->length(); i < length; ++i) {
            RefPtr track = trackList->item(i);
            String language = displayNameForLanguageLocale(track->language());

            if (tracksForMenu.contains(track))
                continue;

            if (!kinds.contains(track->kind()))
                continue;

            // All candidates with no languge were added the first time through.
            if (language.isEmpty())
                continue;

            if (track->containsOnlyForcedSubtitles())
                continue;

            if (!languagesIncluded.contains(language) && track->isMainProgramContent()) {
                languagesIncluded.add(language);
                tracksForMenu.append(track);
                LOG(Media, "CaptionUserPreferencesMediaAF::sortedTrackListForMenu - adding '%s' track with language '%s' because it is the only track with this language", track->kindKeyword().string().utf8().data(), language.utf8().data());
            }
        }
    }

    if (tracksForMenu.isEmpty())
        return tracksForMenu;

    std::ranges::sort(tracksForMenu, textTrackCompare);

    if (requestingCaptionsOrDescriptionsOrSubtitles) {
        tracksForMenu.insert(0, &TextTrack::captionMenuOffItem());
        tracksForMenu.insert(1, &TextTrack::captionMenuAutomaticItem());
    }

    return tracksForMenu;
}
    
}

#endif // ENABLE(VIDEO)
