use crate::app_playurl;
use crate::download::DownloadMode;
use crate::feed_list::{
    FeedListFetchMode, feed_list_fetch_mode, feed_list_info_fetch_mode, push_unique_feed_list_item,
    renumber_feed_list_items, select_feed_list_items, select_items_by_index,
};
use crate::models::{
    DanmakuTrack, DownloadEntry, DownloadPlan, EpisodeMetadata, FlvSegment, MediaStream, Owner,
    PageMetadata, ResolvedContent, SeasonMetadata, SeasonResolution, StreamDiagnostics,
    StreamQuality, StreamResolverAttempt, StreamResolverOutcome, StreamSet, StreamSource,
    SubtitleFormat, SubtitleTrack, Tag, VideoCollectionItem, VideoCollectionKind,
    VideoCollectionMetadata, VideoCollectionResolution, VideoMetadata,
};
use crate::playback::{PlaybackPlan, header_specs_from_map};
use crate::{
    CredentialHealthProbe, CredentialHealthReport, CredentialHealthScope, CredentialKind,
    Credentials, Error, Input, Result, Selection,
};
use http_body_util::BodyExt as _;
use md5::{Digest, Md5};
use reqwest::header::{COOKIE, HeaderMap, HeaderValue, REFERER, USER_AGENT};
use serde::Deserialize;
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use url::Url;

const TV_PLAYURL_APPKEY: &str = "4409e2ce8ffd12b8";
const TV_PLAYURL_APP_SECRET: &str = "59b43e04ad6965f34319062b478f83dd";
const INTL_OGV_APPKEY: &str = "7d089525d3611b1c";
const INTL_OGV_APP_SECRET: &str = "acd495b248ec528c2eed1e862d393126";
const DYNAMIC_FEED_FEATURES: &str = "itemOpusStyle,listOnlyfans,opusBigCover,onlyfansVote,forwardListHidden,decorationCard,commentsNewVersion,onlyfansAssetsV2,ugcDelete,onlyfansQaCard";
const RECOMMENDATION_MIN_PAGE_SIZE: u32 = 20;
const RECOMMENDATION_MAX_PAGE_SIZE: u32 = 30;
const RECOMMENDATION_MAX_REFRESH_PAGES: u32 = 10;

#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct EndpointConfig {
    pub api_base: String,
    pub pgc_base: String,
    pub intl_base: String,
    pub comment_base: String,
    pub passport_base: String,
    pub tv_api_base: String,
    pub app_grpc_base: String,
    pub app_pgc_grpc_base: String,
    pub tv_passport_base: String,
    pub tv_passport_poll_base: String,
}

impl Default for EndpointConfig {
    fn default() -> Self {
        Self {
            api_base: "https://api.bilibili.com".to_owned(),
            pgc_base: "https://api.bilibili.com".to_owned(),
            intl_base: "https://api.bilibili.tv".to_owned(),
            comment_base: "https://comment.bilibili.com".to_owned(),
            passport_base: "https://passport.bilibili.com".to_owned(),
            tv_api_base: "https://api.snm0516.aisee.tv".to_owned(),
            app_grpc_base: "https://grpc.biliapi.net".to_owned(),
            app_pgc_grpc_base: "https://grpc.biliapi.net".to_owned(),
            tv_passport_base: "https://passport.snm0516.aisee.tv".to_owned(),
            tv_passport_poll_base: "https://passport.bilibili.com".to_owned(),
        }
    }
}

impl EndpointConfig {
    #[must_use]
    pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
        self.api_base = api_base.into();
        self
    }

    #[must_use]
    pub fn with_pgc_base(mut self, pgc_base: impl Into<String>) -> Self {
        self.pgc_base = pgc_base.into();
        self
    }

    #[must_use]
    pub fn with_intl_base(mut self, intl_base: impl Into<String>) -> Self {
        self.intl_base = intl_base.into();
        self
    }

    #[must_use]
    pub fn with_comment_base(mut self, comment_base: impl Into<String>) -> Self {
        self.comment_base = comment_base.into();
        self
    }

    #[must_use]
    pub fn with_passport_base(mut self, passport_base: impl Into<String>) -> Self {
        self.passport_base = passport_base.into();
        self
    }

    #[must_use]
    pub fn with_tv_api_base(mut self, tv_api_base: impl Into<String>) -> Self {
        self.tv_api_base = tv_api_base.into();
        self
    }

    #[must_use]
    pub fn with_app_grpc_base(mut self, app_grpc_base: impl Into<String>) -> Self {
        self.app_grpc_base = app_grpc_base.into();
        self
    }

    #[must_use]
    pub fn with_app_pgc_grpc_base(mut self, app_pgc_grpc_base: impl Into<String>) -> Self {
        self.app_pgc_grpc_base = app_pgc_grpc_base.into();
        self
    }

    #[must_use]
    pub fn with_tv_passport_base(mut self, tv_passport_base: impl Into<String>) -> Self {
        self.tv_passport_base = tv_passport_base.into();
        self
    }

    #[must_use]
    pub fn with_tv_passport_poll_base(mut self, tv_passport_poll_base: impl Into<String>) -> Self {
        self.tv_passport_poll_base = tv_passport_poll_base.into();
        self
    }
}

#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PlayurlMode {
    #[default]
    Web,
    Tv,
    App,
}

#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct ClientConfig {
    pub endpoints: EndpointConfig,
    pub credentials: Credentials,
    pub restricted_area: RestrictedAreaConfig,
    pub playurl_mode: PlayurlMode,
    pub user_agent: String,
    pub request_timeout: Duration,
}

impl Default for ClientConfig {
    fn default() -> Self {
        Self {
            endpoints: EndpointConfig::default(),
            credentials: Credentials::default(),
            restricted_area: RestrictedAreaConfig::default(),
            playurl_mode: PlayurlMode::default(),
            user_agent: "bbdown-rs/0.1".to_owned(),
            request_timeout: Duration::from_secs(30),
        }
    }
}

impl ClientConfig {
    #[must_use]
    pub fn new(endpoints: EndpointConfig, credentials: Credentials) -> Self {
        Self {
            endpoints,
            credentials,
            ..Self::default()
        }
    }

    #[must_use]
    pub fn with_endpoints(mut self, endpoints: EndpointConfig) -> Self {
        self.endpoints = endpoints;
        self
    }

    #[must_use]
    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
        self.credentials = credentials;
        self
    }

    #[must_use]
    pub fn with_restricted_area(mut self, restricted_area: RestrictedAreaConfig) -> Self {
        self.restricted_area = restricted_area;
        self
    }

    #[must_use]
    pub fn with_playurl_mode(mut self, playurl_mode: PlayurlMode) -> Self {
        self.playurl_mode = playurl_mode;
        self
    }

    #[must_use]
    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
        self.user_agent = user_agent.into();
        self
    }

    #[must_use]
    pub fn with_request_timeout(mut self, request_timeout: Duration) -> Self {
        self.request_timeout = request_timeout;
        self
    }
}

#[non_exhaustive]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct RestrictedAreaConfig {
    pub area_hint: Option<RestrictedArea>,
    pub proxies: Vec<RestrictedAreaProxy>,
}

impl RestrictedAreaConfig {
    #[must_use]
    pub fn new(
        area_hint: Option<RestrictedArea>,
        proxies: impl IntoIterator<Item = RestrictedAreaProxy>,
    ) -> Self {
        Self {
            area_hint,
            proxies: proxies.into_iter().collect(),
        }
    }

    #[must_use]
    pub fn with_area_hint(mut self, area_hint: RestrictedArea) -> Self {
        self.area_hint = Some(area_hint);
        self
    }

    #[must_use]
    pub fn with_proxy(mut self, proxy: RestrictedAreaProxy) -> Self {
        self.proxies.push(proxy);
        self
    }

    #[must_use]
    pub fn with_proxies(mut self, proxies: impl IntoIterator<Item = RestrictedAreaProxy>) -> Self {
        self.proxies.extend(proxies);
        self
    }

    #[must_use]
    pub fn ordered_proxies(&self) -> Vec<RestrictedAreaProxy> {
        let mut ordered = Vec::new();
        let mut priorities = self
            .proxies
            .iter()
            .map(|proxy| proxy.order_priority)
            .collect::<Vec<_>>();
        priorities.sort_unstable();
        priorities.dedup();
        for priority in priorities {
            if let Some(area_hint) = self.area_hint {
                self.push_matching(&mut ordered, |proxy| {
                    proxy.order_priority == priority && proxy.area == Some(area_hint)
                });
            }
            self.push_matching(&mut ordered, |proxy| {
                proxy.order_priority == priority && proxy.area.is_none()
            });
            for area in [
                RestrictedArea::Cn,
                RestrictedArea::Th,
                RestrictedArea::Hk,
                RestrictedArea::Tw,
            ] {
                self.push_matching(&mut ordered, |proxy| {
                    proxy.order_priority == priority && proxy.area == Some(area)
                });
            }
            self.push_matching(&mut ordered, |proxy| proxy.order_priority == priority);
        }
        ordered
    }

    fn push_matching(
        &self,
        ordered: &mut Vec<RestrictedAreaProxy>,
        mut predicate: impl FnMut(&RestrictedAreaProxy) -> bool,
    ) {
        for proxy in self.proxies.iter().filter(|proxy| predicate(proxy)) {
            if !ordered.iter().any(|candidate| candidate == proxy) {
                ordered.push(proxy.clone());
            }
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestrictedArea {
    Cn,
    Th,
    Hk,
    Tw,
}

impl RestrictedArea {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Cn => "cn",
            Self::Th => "th",
            Self::Hk => "hk",
            Self::Tw => "tw",
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RestrictedAreaProxyKind {
    PlayUrl,
    BilibiliApi,
}

#[derive(Clone, Eq)]
pub struct RestrictedAreaProxy {
    pub base_url: String,
    pub area: Option<RestrictedArea>,
    pub kind: RestrictedAreaProxyKind,
    order_priority: u8,
}

impl PartialEq for RestrictedAreaProxy {
    fn eq(&self, other: &Self) -> bool {
        self.base_url == other.base_url && self.area == other.area && self.kind == other.kind
    }
}

impl fmt::Debug for RestrictedAreaProxy {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("RestrictedAreaProxy")
            .field("base_url", &redact_url_string(&self.base_url))
            .field("area", &self.area.map(RestrictedArea::as_str))
            .field("kind", &self.kind)
            .field("order_priority", &self.order_priority)
            .finish()
    }
}

impl RestrictedAreaProxy {
    #[must_use]
    pub fn playurl(base_url: impl Into<String>, area: Option<RestrictedArea>) -> Self {
        Self {
            base_url: base_url.into(),
            area,
            kind: RestrictedAreaProxyKind::PlayUrl,
            order_priority: 0,
        }
    }

    #[must_use]
    pub fn bilibili_api(base_url: impl Into<String>, area: Option<RestrictedArea>) -> Self {
        Self {
            base_url: base_url.into(),
            area,
            kind: RestrictedAreaProxyKind::BilibiliApi,
            order_priority: 0,
        }
    }

    #[doc(hidden)]
    #[must_use]
    pub fn with_order_priority(mut self, order_priority: u8) -> Self {
        self.order_priority = order_priority;
        self
    }
}

#[derive(Clone, Debug)]
pub struct BiliClient {
    pub(crate) http: reqwest::Client,
    pub(crate) config: ClientConfig,
}

impl BiliClient {
    #[must_use]
    pub fn new(config: ClientConfig) -> Self {
        Self {
            http: reqwest::Client::new(),
            config,
        }
    }

    pub async fn check_credential_health(&self) -> CredentialHealthReport {
        let credentials = self.config.credentials.redacted_summary();
        let probes = vec![
            self.check_cookie_health().await,
            self.check_access_token_health(
                CredentialKind::AccessKey,
                CredentialHealthScope::IntlBstar,
            )
            .await,
            self.check_access_token_health(CredentialKind::TvAccessKey, CredentialHealthScope::Tv)
                .await,
        ];
        CredentialHealthReport {
            credentials,
            probes,
        }
    }

    pub async fn resolve_input(
        &self,
        raw: &str,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let input = self.parse_input(raw).await?;
        self.resolve(input, selection).await
    }

    pub async fn resolve(
        &self,
        input: Input,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        match input {
            Input::Aid(aid) => self.resolve_video_by_aid(aid, selection.as_ref()).await,
            Input::Bvid(bvid) => self.resolve_video_by_bvid(&bvid, selection.as_ref()).await,
            Input::Episode(epid) => self
                .fetch_season_by_ep(epid, selection.or(Some(Selection::Current)))
                .await
                .map(ResolvedContent::Season),
            Input::Season(season_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "season",
                })?;
                self.fetch_season_by_season_id(season_id, selection)
                    .await
                    .map(ResolvedContent::Season)
            }
            Input::Media(media_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "media",
                })?;
                self.fetch_season_by_media_id(media_id, selection)
                    .await
                    .map(ResolvedContent::Season)
            }
            Input::CheeseEpisode(epid) => self
                .fetch_pugv_season_by_ep(epid, selection.or(Some(Selection::Current)))
                .await
                .map(ResolvedContent::Season),
            Input::CheeseSeason(season_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "cheese season",
                })?;
                self.fetch_pugv_season_by_season_id(season_id, selection)
                    .await
                    .map(ResolvedContent::Season)
            }
            Input::SpaceVideos(mid) => self.resolve_space_videos(mid, selection).await,
            Input::FavoriteList {
                media_id,
                owner_mid,
            } => {
                self.resolve_favorite_list(media_id, owner_mid, selection)
                    .await
            }
            Input::CollectionList(list_id) => {
                self.resolve_medialist_input(list_id, MediaListKind::Collection, selection)
                    .await
            }
            Input::SeriesList(list_id) => {
                self.resolve_medialist_input(list_id, MediaListKind::Series, selection)
                    .await
            }
            Input::SpaceCollectionList { list_id, owner_mid } => {
                self.resolve_space_list(owner_mid, list_id, SpaceListKind::Collection, selection)
                    .await
            }
            Input::SpaceSeriesList { list_id, owner_mid } => {
                self.resolve_space_list(owner_mid, list_id, SpaceListKind::Series, selection)
                    .await
            }
            Input::RecommendationFeed => self.resolve_recommendations(selection).await,
            Input::FollowingFeed => {
                self.resolve_dynamic_feed(DynamicFeedKind::Following, selection)
                    .await
            }
            Input::SpaceDynamic(mid) => {
                self.resolve_dynamic_feed(DynamicFeedKind::Space { mid }, selection)
                    .await
            }
            Input::History => self.resolve_history(selection).await,
            Input::WatchLater => self.resolve_watch_later(selection).await,
            Input::IntlEpisode(epid) => self
                .fetch_intl_season_by_ep(epid, selection.or(Some(Selection::Current)))
                .await
                .map(ResolvedContent::Season),
            Input::ShortLink(raw) => {
                let input = self.resolve_short_link_input(&raw).await?;
                Box::pin(self.resolve(input, selection)).await
            }
        }
    }

    async fn check_cookie_health(&self) -> CredentialHealthProbe {
        const ENDPOINT: &str = "web_nav";
        const SCOPE: CredentialHealthScope = CredentialHealthScope::WebCookie;
        let Some(cookie) = self
            .config
            .credentials
            .cookie
            .as_deref()
            .filter(|value| !value.is_empty())
        else {
            return CredentialHealthProbe::missing(CredentialKind::Cookie, SCOPE);
        };
        match self.fetch_cookie_login_state(cookie).await {
            Ok(true) => CredentialHealthProbe::valid(CredentialKind::Cookie, SCOPE, ENDPOINT),
            Ok(false) => CredentialHealthProbe::rejected(
                CredentialKind::Cookie,
                SCOPE,
                ENDPOINT,
                Some(0),
                "not logged in",
            ),
            Err(error) => {
                credential_health_error(CredentialKind::Cookie, SCOPE, ENDPOINT, error, cookie)
            }
        }
    }

    async fn check_access_token_health(
        &self,
        kind: CredentialKind,
        scope: CredentialHealthScope,
    ) -> CredentialHealthProbe {
        const ENDPOINT: &str = "oauth2_info";
        let token = match kind {
            CredentialKind::Cookie => return CredentialHealthProbe::missing(kind, scope),
            CredentialKind::AccessKey => self.config.credentials.access_key.as_deref(),
            CredentialKind::TvAccessKey => self.config.credentials.tv_access_key.as_deref(),
        }
        .filter(|value| !value.is_empty());
        let Some(token) = token else {
            return CredentialHealthProbe::missing(kind, scope);
        };
        match self.fetch_oauth2_info(kind, scope, token).await {
            Ok(()) => CredentialHealthProbe::valid(kind, scope, ENDPOINT),
            Err(error) => credential_health_error(kind, scope, ENDPOINT, error, token),
        }
    }

    async fn fetch_cookie_login_state(&self, cookie: &str) -> Result<bool> {
        let url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/nav")?;
        let response = self
            .http
            .get(url)
            .headers(self.headers(false)?)
            .header(
                COOKIE,
                HeaderValue::from_str(cookie)
                    .map_err(|_| Error::InvalidInput("invalid cookie header".to_owned()))?,
            )
            .timeout(self.config.request_timeout)
            .send()
            .await
            .map_err(Self::http_error_without_url)?;
        let response = response
            .error_for_status()
            .map_err(Self::http_error_without_url)?;
        let response = response
            .json::<ApiData<NavLoginData>>()
            .await
            .map_err(Self::http_error_without_url)?;
        Ok(response.into_data()?.is_login)
    }

    async fn fetch_oauth2_info(
        &self,
        kind: CredentialKind,
        scope: CredentialHealthScope,
        access_key: &str,
    ) -> Result<()> {
        let endpoint_base = match kind {
            CredentialKind::AccessKey => &self.config.endpoints.passport_base,
            CredentialKind::TvAccessKey => &self.config.endpoints.tv_passport_poll_base,
            CredentialKind::Cookie => {
                return Err(Error::InvalidInput(
                    "cookie credential cannot use oauth2 info probe".to_owned(),
                ));
            }
        };
        let mut url = Self::endpoint_url(endpoint_base, "/x/passport-login/oauth2/info")?;
        let params = oauth2_info_params(scope, access_key, current_unix_timestamp())?;
        {
            let mut query = url.query_pairs_mut();
            for (key, value) in params {
                query.append_pair(key, &value);
            }
        }
        let response: ApiData<serde_json::Value> = self.get_json_without_cookie(url).await?;
        response.into_data()?;
        Ok(())
    }

    async fn resolve_space_videos(
        &self,
        mid: u64,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_space_video_collection(mid, selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_favorite_list(
        &self,
        media_id: Option<u64>,
        owner_mid: Option<u64>,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_favorite_collection(media_id, owner_mid, selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_medialist_input(
        &self,
        list_id: u64,
        kind: MediaListKind,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_medialist_collection(list_id, kind, selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_dynamic_feed(
        &self,
        kind: DynamicFeedKind,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_dynamic_video_collection(kind, selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_recommendations(
        &self,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
        self.fetch_recommendation_collection(selection, fetch_mode, false)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_history(&self, selection: Option<Selection>) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_history_collection(selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn resolve_watch_later(&self, selection: Option<Selection>) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_watch_later_collection(selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    pub async fn plan_download(
        &self,
        raw: &str,
        selection: Option<Selection>,
    ) -> Result<DownloadPlan> {
        let input = self.parse_input(raw).await?;
        self.plan(input, selection).await
    }

    pub async fn plan_download_with_mode(
        &self,
        raw: &str,
        selection: Option<Selection>,
        mode: DownloadMode,
    ) -> Result<DownloadPlan> {
        let input = self.parse_input(raw).await?;
        self.plan_with_download_mode(input, selection, mode).await
    }

    pub async fn plan(&self, input: Input, selection: Option<Selection>) -> Result<DownloadPlan> {
        self.plan_with_mode(input, selection, PlanningMode::Full)
            .await
    }

    pub async fn plan_with_download_mode(
        &self,
        input: Input,
        selection: Option<Selection>,
        mode: DownloadMode,
    ) -> Result<DownloadPlan> {
        self.plan_with_mode(input, selection, PlanningMode::from_download_mode(mode))
            .await
    }

    pub async fn plan_playback(
        &self,
        raw: &str,
        selection: Option<Selection>,
    ) -> Result<PlaybackPlan> {
        let input = self.parse_input(raw).await?;
        self.plan_playback_input(input, selection).await
    }

    pub async fn plan_playback_input(
        &self,
        input: Input,
        selection: Option<Selection>,
    ) -> Result<PlaybackPlan> {
        let plan = self
            .plan_with_mode(input, selection, PlanningMode::StreamsOnly)
            .await?;
        let headers = header_specs_from_map(&self.media_headers()?)?;
        Ok(PlaybackPlan::from_download_plan(&plan, &headers))
    }

    pub(crate) async fn plan_for_download(
        &self,
        input: Input,
        selection: Option<Selection>,
        mode: DownloadMode,
    ) -> Result<DownloadPlan> {
        self.plan_with_download_mode(input, selection, mode).await
    }

    async fn plan_with_mode(
        &self,
        input: Input,
        selection: Option<Selection>,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        match input {
            Input::Aid(aid) => {
                let video = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
                self.plan_video(video, selection, planning_mode).await
            }
            Input::Bvid(bvid) => {
                let video = self.fetch_video_by_bvid(&bvid, TagPolicy::Skip).await?;
                self.plan_video(video, selection, planning_mode).await
            }
            Input::Episode(epid) => {
                let season = self
                    .fetch_season_by_ep(epid, selection.or(Some(Selection::Current)))
                    .await?;
                self.plan_season(season, self.pgc_stream_source(), planning_mode)
                    .await
            }
            Input::Season(season_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "season",
                })?;
                let season = self.fetch_season_by_season_id(season_id, selection).await?;
                self.plan_season(season, self.pgc_stream_source(), planning_mode)
                    .await
            }
            Input::Media(media_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "media",
                })?;
                let season = self.fetch_season_by_media_id(media_id, selection).await?;
                self.plan_season(season, self.pgc_stream_source(), planning_mode)
                    .await
            }
            Input::CheeseEpisode(epid) => {
                let season = self
                    .fetch_pugv_season_by_ep(epid, selection.or(Some(Selection::Current)))
                    .await?;
                self.plan_season(season, StreamSource::PugvWeb, planning_mode)
                    .await
            }
            Input::CheeseSeason(season_id) => {
                let selection = selection.ok_or(Error::SelectionRequired {
                    input_kind: "cheese season",
                })?;
                let season = self
                    .fetch_pugv_season_by_season_id(season_id, selection)
                    .await?;
                self.plan_season(season, StreamSource::PugvWeb, planning_mode)
                    .await
            }
            collection_input @ (Input::SpaceVideos(_)
            | Input::FavoriteList { .. }
            | Input::CollectionList(_)
            | Input::SeriesList(_)
            | Input::SpaceCollectionList { .. }
            | Input::SpaceSeriesList { .. }
            | Input::RecommendationFeed
            | Input::FollowingFeed
            | Input::SpaceDynamic(_)
            | Input::History
            | Input::WatchLater) => {
                self.plan_collection_input(collection_input, selection, planning_mode)
                    .await
            }
            Input::IntlEpisode(epid) => {
                let season = self
                    .fetch_intl_season_by_ep(epid, selection.or(Some(Selection::Current)))
                    .await?;
                self.plan_season(season, StreamSource::IntlWeb, planning_mode)
                    .await
            }
            Input::ShortLink(raw) => {
                let input = self.resolve_short_link_input(&raw).await?;
                Box::pin(self.plan_with_mode(input, selection, planning_mode)).await
            }
        }
    }

    async fn resolve_video_by_aid(
        &self,
        aid: u64,
        selection: Option<&Selection>,
    ) -> Result<ResolvedContent> {
        let video = self.fetch_video_by_aid(aid, TagPolicy::Fetch).await?;
        Ok(ResolvedContent::Video(Self::select_video_metadata(
            video, selection,
        )?))
    }

    async fn resolve_video_by_bvid(
        &self,
        bvid: &str,
        selection: Option<&Selection>,
    ) -> Result<ResolvedContent> {
        let video = self.fetch_video_by_bvid(bvid, TagPolicy::Fetch).await?;
        Ok(ResolvedContent::Video(Self::select_video_metadata(
            video, selection,
        )?))
    }

    async fn resolve_space_list(
        &self,
        owner_mid: u64,
        list_id: u64,
        kind: SpaceListKind,
        selection: Option<Selection>,
    ) -> Result<ResolvedContent> {
        let fetch_mode = Self::collection_info_fetch_mode(selection.as_ref())?;
        self.fetch_space_list_collection(owner_mid, list_id, kind, selection, fetch_mode)
            .await
            .map(ResolvedContent::Collection)
    }

    async fn plan_collection_input(
        &self,
        input: Input,
        selection: Option<Selection>,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        match input {
            Input::SpaceVideos(mid) => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_space_video_collection(mid, selection, fetch_mode)
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::FavoriteList {
                media_id,
                owner_mid,
            } => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_favorite_collection(media_id, owner_mid, selection, fetch_mode)
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::CollectionList(list_id) => {
                self.plan_medialist(list_id, MediaListKind::Collection, selection, planning_mode)
                    .await
            }
            Input::SeriesList(list_id) => {
                self.plan_medialist(list_id, MediaListKind::Series, selection, planning_mode)
                    .await
            }
            Input::SpaceCollectionList { list_id, owner_mid } => {
                self.plan_space_list(
                    owner_mid,
                    list_id,
                    SpaceListKind::Collection,
                    selection,
                    planning_mode,
                )
                .await
            }
            Input::SpaceSeriesList { list_id, owner_mid } => {
                self.plan_space_list(
                    owner_mid,
                    list_id,
                    SpaceListKind::Series,
                    selection,
                    planning_mode,
                )
                .await
            }
            Input::RecommendationFeed => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_recommendation_collection(selection, fetch_mode, true)
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::FollowingFeed => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_dynamic_video_collection(
                        DynamicFeedKind::Following,
                        selection,
                        fetch_mode,
                    )
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::SpaceDynamic(mid) => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_dynamic_video_collection(
                        DynamicFeedKind::Space { mid },
                        selection,
                        fetch_mode,
                    )
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::History => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self.fetch_history_collection(selection, fetch_mode).await?;
                self.plan_collection(collection, planning_mode).await
            }
            Input::WatchLater => {
                let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
                let collection = self
                    .fetch_watch_later_collection(selection, fetch_mode)
                    .await?;
                self.plan_collection(collection, planning_mode).await
            }
            _ => unreachable!("non-collection input routed to collection planner"),
        }
    }

    async fn plan_medialist(
        &self,
        list_id: u64,
        kind: MediaListKind,
        selection: Option<Selection>,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
        let collection = self
            .fetch_medialist_collection(list_id, kind, selection, fetch_mode)
            .await?;
        self.plan_collection(collection, planning_mode).await
    }

    async fn plan_space_list(
        &self,
        owner_mid: u64,
        list_id: u64,
        kind: SpaceListKind,
        selection: Option<Selection>,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        let fetch_mode = Self::collection_fetch_mode(selection.as_ref())?;
        let collection = self
            .fetch_space_list_collection(owner_mid, list_id, kind, selection, fetch_mode)
            .await?;
        self.plan_collection(collection, planning_mode).await
    }

    async fn parse_input(&self, raw: &str) -> Result<Input> {
        match Input::parse(raw)? {
            Input::ShortLink(short_link) => self.resolve_short_link_input(&short_link).await,
            input => Ok(input),
        }
    }

    async fn resolve_short_link_input(&self, raw: &str) -> Result<Input> {
        let url = Url::parse(raw)?;
        let response = self
            .http
            .get(url)
            .headers(self.anonymous_headers()?)
            .timeout(self.config.request_timeout)
            .send()
            .await
            .map_err(Self::http_error_without_url)?;
        let final_url = response.url().clone();
        if final_url.as_str() == raw {
            return Err(Error::InvalidInput(
                "short link did not redirect to a supported Bilibili URL".to_owned(),
            ));
        }
        match Input::parse(final_url.as_str())? {
            Input::ShortLink(_) => Err(Error::InvalidInput(
                "short link redirected to another short link".to_owned(),
            )),
            input => Ok(input),
        }
    }

    async fn fetch_video_by_aid(&self, aid: u64, tag_policy: TagPolicy) -> Result<VideoMetadata> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/view")?;
        url.query_pairs_mut().append_pair("aid", &aid.to_string());
        self.fetch_video(url, tag_policy).await
    }

    async fn fetch_video_by_bvid(
        &self,
        bvid: &str,
        tag_policy: TagPolicy,
    ) -> Result<VideoMetadata> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/view")?;
        url.query_pairs_mut().append_pair("bvid", bvid);
        self.fetch_video(url, tag_policy).await
    }

    async fn fetch_video(&self, url: Url, tag_policy: TagPolicy) -> Result<VideoMetadata> {
        let response: ApiData<ViewData> = self.get_json(url).await?;
        let data = response.into_data()?;
        let aid = data.aid.ok_or(Error::MissingField("data.aid"))?;
        let tags = match tag_policy {
            TagPolicy::Fetch => self.fetch_tags(aid).await?,
            TagPolicy::Skip => Vec::new(),
        };
        let pages = data
            .pages
            .into_iter()
            .map(|page| PageMetadata {
                index: page.page,
                aid,
                cid: page.cid,
                epid: None,
                title: page.part.unwrap_or_else(|| data.title.clone()),
                duration_seconds: page.duration,
            })
            .collect();

        Ok(VideoMetadata {
            aid,
            bvid: data.bvid,
            title: data.title,
            description: data.desc.unwrap_or_default(),
            cover_url: data.pic,
            pub_time: data.pubdate,
            owner: data.owner.map(|owner| Owner {
                mid: owner.mid,
                name: owner.name,
            }),
            tags,
            pages,
        })
    }

    async fn fetch_tags(&self, aid: u64) -> Result<Vec<Tag>> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/tag/archive/tags")?;
        url.query_pairs_mut().append_pair("aid", &aid.to_string());
        let response: ApiData<Vec<TagData>> = self.get_json(url).await?;
        Ok(response
            .into_data()?
            .into_iter()
            .filter_map(|tag| {
                tag.tag_id
                    .zip(tag.tag_name)
                    .map(|(id, name)| Tag { id, name })
            })
            .collect())
    }

    async fn fetch_season_by_ep(
        &self,
        epid: u64,
        selection: Option<Selection>,
    ) -> Result<SeasonResolution> {
        let mut url = Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/view/web/season")?;
        url.query_pairs_mut()
            .append_pair("ep_id", &epid.to_string());
        let season = self.fetch_pgc_season(url).await?;
        let selection = selection.unwrap_or(Selection::Current);
        Self::resolve_season_selection(season, Some(&selection), Some(epid), "episode")
    }

    async fn fetch_season_by_season_id(
        &self,
        season_id: u64,
        selection: Selection,
    ) -> Result<SeasonResolution> {
        let mut url = Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/view/web/season")?;
        url.query_pairs_mut()
            .append_pair("season_id", &season_id.to_string());
        let season = self.fetch_pgc_season(url).await?;
        Self::resolve_season_selection(season, Some(&selection), None, "season")
    }

    async fn fetch_season_by_media_id(
        &self,
        media_id: u64,
        selection: Selection,
    ) -> Result<SeasonResolution> {
        let mut review_url =
            Self::endpoint_url(&self.config.endpoints.pgc_base, "/pgc/review/user")?;
        review_url
            .query_pairs_mut()
            .append_pair("media_id", &media_id.to_string());
        let review: ApiResult<PgcReviewResult> = self.get_json(review_url).await?;
        let epid = review
            .into_result()?
            .media
            .and_then(|media| media.new_ep)
            .map(|episode| episode.id)
            .ok_or(Error::MissingField("result.media.new_ep.id"))?;
        let mut resolution = self.fetch_season_by_ep(epid, Some(selection)).await?;
        resolution.season.media_id = Some(media_id);
        Ok(resolution)
    }

    async fn fetch_pugv_season_by_ep(
        &self,
        epid: u64,
        selection: Option<Selection>,
    ) -> Result<SeasonResolution> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/pugv/view/web/season")?;
        url.query_pairs_mut()
            .append_pair("ep_id", &epid.to_string());
        let selection = selection.unwrap_or(Selection::Current);
        let season = self.fetch_pugv_season_from_beginning(url).await?;
        Self::resolve_season_selection(season, Some(&selection), Some(epid), "cheese episode")
    }

    async fn fetch_pugv_season_by_season_id(
        &self,
        season_id: u64,
        selection: Selection,
    ) -> Result<SeasonResolution> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/pugv/view/web/season")?;
        url.query_pairs_mut()
            .append_pair("season_id", &season_id.to_string());
        let season = self.fetch_pugv_season(url).await?;
        Self::resolve_season_selection(season, Some(&selection), None, "cheese season")
    }

    async fn fetch_pugv_season(&self, url: Url) -> Result<SeasonMetadata> {
        let mut data = self.fetch_pugv_season_data(url.clone()).await?;
        self.fetch_remaining_pugv_episodes(&mut data).await?;
        Ok(season_from_pugv(data))
    }

    async fn fetch_pugv_season_from_beginning(&self, url: Url) -> Result<SeasonMetadata> {
        let data = self.fetch_pugv_season_data(url.clone()).await?;
        let page_number = data
            .episode_page
            .and_then(|episode_page| episode_page.num)
            .unwrap_or(1);
        if page_number <= 1 {
            let mut data = data;
            self.fetch_remaining_pugv_episodes(&mut data).await?;
            return Ok(season_from_pugv(data));
        }

        let start_url = if let Some(season_id) = data.season_id {
            let mut url =
                Self::endpoint_url(&self.config.endpoints.api_base, "/pugv/view/web/season")?;
            url.query_pairs_mut()
                .append_pair("season_id", &season_id.to_string());
            url
        } else {
            url
        };
        let mut data = self.fetch_pugv_season_data(start_url.clone()).await?;
        let page_number = data
            .episode_page
            .and_then(|episode_page| episode_page.num)
            .unwrap_or(1);
        if page_number > 1 {
            return Err(Error::MissingField("data.episode_page first page"));
        }
        self.fetch_remaining_pugv_episodes(&mut data).await?;
        Ok(season_from_pugv(data))
    }

    async fn fetch_pugv_season_data(&self, url: Url) -> Result<PugvSeasonData> {
        let response: ApiData<PugvSeasonData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_remaining_pugv_episodes(&self, data: &mut PugvSeasonData) -> Result<()> {
        let Some(mut page) = data.episode_page else {
            return Ok(());
        };
        let season_id = data
            .season_id
            .ok_or(Error::MissingField("data.season_id"))?;
        let page_size = pugv_episode_page_size(&page, data.episodes.len());
        let mut last_page_number = page.num.unwrap_or(1);
        while pugv_page_has_next(&page, page_size, last_page_number) {
            let Some(next_page_number) = last_page_number.checked_add(1) else {
                return Err(Error::MissingField("data.episode_page.num"));
            };
            if let Some(total_pages) = page.total.map(|total| total.div_ceil(page_size))
                && next_page_number > total_pages
            {
                break;
            }
            let next_data = self
                .fetch_pugv_episode_list_page(season_id, next_page_number, page_size)
                .await?;
            let returned_page_number = next_data
                .page
                .as_ref()
                .and_then(|episode_page| episode_page.num)
                .unwrap_or(next_page_number);
            if returned_page_number <= last_page_number {
                return Err(Error::MissingField("data.episode_page pagination cursor"));
            }
            last_page_number = returned_page_number;
            data.episodes.extend(next_data.items);
            let Some(next_page) = next_data.page else {
                break;
            };
            page = next_page;
        }
        Ok(())
    }

    async fn fetch_pugv_episode_list_page(
        &self,
        season_id: u64,
        page_number: u32,
        page_size: u32,
    ) -> Result<PugvEpisodeListData> {
        let mut url =
            Self::endpoint_url(&self.config.endpoints.api_base, "/pugv/view/web/ep/list")?;
        url.query_pairs_mut()
            .append_pair("season_id", &season_id.to_string())
            .append_pair("pn", &page_number.to_string())
            .append_pair("ps", &page_size.to_string());
        let response: ApiData<PugvEpisodeListData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_favorite_collection(
        &self,
        media_id: Option<u64>,
        owner_mid: Option<u64>,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let media_id = match media_id {
            Some(media_id) => media_id,
            None => {
                self.fetch_default_favorite_id(owner_mid.ok_or(Error::MissingField("space mid"))?)
                    .await?
            }
        };
        let collection = self
            .fetch_favorite_collection_all(media_id, fetch_mode)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_default_favorite_id(&self, owner_mid: u64) -> Result<u64> {
        let mut url = Self::endpoint_url(
            &self.config.endpoints.api_base,
            "/x/v3/fav/folder/created/list-all",
        )?;
        url.query_pairs_mut()
            .append_pair("up_mid", &owner_mid.to_string());
        let response: ApiData<FavoriteFolderListData> = self.get_json(url).await?;
        response
            .into_data()?
            .list
            .into_iter()
            .find_map(|folder| folder.id)
            .ok_or(Error::MissingField("data.list[0].id"))
    }

    async fn fetch_favorite_collection_all(
        &self,
        media_id: u64,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let page_size = 20_u32;
        let mut page_number = 1_u32;
        let first = self
            .fetch_favorite_collection_page(media_id, page_number, page_size)
            .await?;
        let info = first.info;
        let total_count = info.media_count.unwrap_or(first.medias.len());
        let total_pages = total_count.div_ceil(page_size as usize);
        let mut page_medias = first.medias;
        let mut items = Vec::new();
        loop {
            for media in page_medias {
                self.push_favorite_media_items(&mut items, media).await?;
                if fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if fetch_mode.is_satisfied_by(items.len()) || (page_number as usize) >= total_pages {
                break;
            }
            page_number += 1;
            page_medias = self
                .fetch_favorite_collection_page(media_id, page_number, page_size)
                .await?
                .medias;
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: Some(media_id),
            kind: VideoCollectionKind::Favorite,
            title: info.title.unwrap_or_else(|| format!("Favorite {media_id}")),
            description: info.intro.unwrap_or_default(),
            cover_url: info.cover,
            pub_time: info.ctime,
            owner: info.upper.and_then(FavoriteUpper::into_owner),
            items,
        })
    }

    async fn fetch_favorite_collection_page(
        &self,
        media_id: u64,
        page_number: u32,
        page_size: u32,
    ) -> Result<FavoriteResourceListData> {
        let mut url =
            Self::endpoint_url(&self.config.endpoints.api_base, "/x/v3/fav/resource/list")?;
        url.query_pairs_mut()
            .append_pair("media_id", &media_id.to_string())
            .append_pair("pn", &page_number.to_string())
            .append_pair("ps", &page_size.to_string())
            .append_pair("order", "mtime")
            .append_pair("type", "0")
            .append_pair("tid", "0")
            .append_pair("platform", "web");
        let response: ApiData<FavoriteResourceListData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn push_favorite_media_items(
        &self,
        items: &mut Vec<VideoCollectionItem>,
        media: FavoriteMedia,
    ) -> Result<()> {
        if media.media_type.is_some_and(|media_type| media_type != 2) {
            return Ok(());
        }
        if media.attr.is_some_and(|attr| attr != 0) {
            return Ok(());
        }
        let aid = media.id.ok_or(Error::MissingField("data.medias[].id"))?;
        let first_cid = media.ugc.as_ref().and_then(|ugc| ugc.first_cid);
        if media.page.unwrap_or(1) > 1 || first_cid.is_none() {
            let video = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
            let page_count = media.page.unwrap_or_else(|| {
                u32::try_from(video.pages.len())
                    .ok()
                    .filter(|count| *count > 0)
                    .unwrap_or(1)
            });
            for page in video.pages {
                push_unique_collection_item(
                    items,
                    VideoCollectionItem {
                        index: 0,
                        aid: page.aid,
                        bvid: video.bvid.clone().or_else(|| media.bvid.clone()),
                        cid: page.cid,
                        title: if page_count == 1 {
                            media.title.clone().unwrap_or_else(|| video.title.clone())
                        } else {
                            format_collection_page_title(
                                media.title.as_deref().unwrap_or(&video.title),
                                page.index,
                                &page.title,
                            )
                        },
                        cover_url: video.cover_url.clone().or_else(|| media.cover.clone()),
                        description: media
                            .intro
                            .clone()
                            .unwrap_or_else(|| video.description.clone()),
                        pub_time: media.pubtime.or(video.pub_time),
                        owner: media
                            .upper
                            .clone()
                            .and_then(FavoriteUpper::into_owner)
                            .or(video.owner.clone()),
                        duration_seconds: page.duration_seconds,
                    },
                );
            }
        } else {
            let cid = first_cid.ok_or(Error::MissingField("data.medias[].ugc.first_cid"))?;
            push_unique_collection_item(
                items,
                VideoCollectionItem {
                    index: 0,
                    aid,
                    bvid: media.bvid,
                    cid,
                    title: media.title.unwrap_or_else(|| aid.to_string()),
                    cover_url: media.cover,
                    description: media.intro.unwrap_or_default(),
                    pub_time: media.pubtime,
                    owner: media.upper.and_then(FavoriteUpper::into_owner),
                    duration_seconds: media.duration,
                },
            );
        }
        Ok(())
    }

    async fn fetch_medialist_collection(
        &self,
        list_id: u64,
        kind: MediaListKind,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let collection = self
            .fetch_medialist_collection_all(list_id, kind, fetch_mode)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_medialist_collection_all(
        &self,
        list_id: u64,
        kind: MediaListKind,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let info = self.fetch_medialist_info(list_id, kind).await?;
        let mut items = Vec::new();
        let mut oid = None;
        loop {
            let page = self.fetch_medialist_page(list_id, kind, oid).await?;
            let has_more = page.has_more.unwrap_or(false);
            let mut next_oid = oid;
            let mut selected_item_found = false;
            for media in page.media_list {
                if media.id.is_some() {
                    next_oid = media.id;
                }
                if media.attr.is_some_and(|attr| attr != 0) {
                    continue;
                }
                self.push_medialist_media_items(&mut items, media).await?;
                if fetch_mode.is_satisfied_by(items.len()) {
                    selected_item_found = true;
                    break;
                }
            }
            if selected_item_found || !has_more {
                break;
            }
            if next_oid == oid {
                return Err(Error::MissingField("data.media_list pagination cursor"));
            }
            oid = next_oid;
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: Some(list_id),
            kind: kind.into_collection_kind(),
            title: info
                .title
                .unwrap_or_else(|| format!("{} {list_id}", kind.title())),
            description: info.intro.unwrap_or_default(),
            cover_url: info.cover,
            pub_time: info.ctime,
            owner: None,
            items,
        })
    }

    async fn fetch_medialist_info(
        &self,
        list_id: u64,
        kind: MediaListKind,
    ) -> Result<MediaListInfoData> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/v1/medialist/info")?;
        url.query_pairs_mut()
            .append_pair("type", kind.type_id())
            .append_pair("biz_id", &list_id.to_string())
            .append_pair("tid", "0");
        let response: ApiData<MediaListInfoData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_medialist_page(
        &self,
        list_id: u64,
        kind: MediaListKind,
        oid: Option<u64>,
    ) -> Result<MediaListResourcePageData> {
        let mut url = Self::endpoint_url(
            &self.config.endpoints.api_base,
            "/x/v2/medialist/resource/list",
        )?;
        url.query_pairs_mut()
            .append_pair("type", kind.type_id())
            .append_pair(
                "oid",
                &oid.map_or_else(String::new, |value| value.to_string()),
            )
            .append_pair("otype", "2")
            .append_pair("biz_id", &list_id.to_string())
            .append_pair("with_current", if oid.is_some() { "false" } else { "true" })
            .append_pair("mobi_app", "web")
            .append_pair("ps", "20")
            .append_pair("direction", "false")
            .append_pair("sort_field", "1")
            .append_pair("tid", "0")
            .append_pair("desc", kind.desc_value());
        if kind == MediaListKind::Series {
            url.query_pairs_mut().append_pair("bvid", "");
        }
        let response: ApiData<MediaListResourcePageData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_space_list_collection(
        &self,
        owner_mid: u64,
        list_id: u64,
        kind: SpaceListKind,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let newest_first = matches!(selection, Some(Selection::Latest));
        let collection = self
            .fetch_space_list_collection_all(owner_mid, list_id, kind, fetch_mode, newest_first)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_space_list_collection_all(
        &self,
        owner_mid: u64,
        list_id: u64,
        kind: SpaceListKind,
        fetch_mode: FeedListFetchMode,
        newest_first: bool,
    ) -> Result<VideoCollectionMetadata> {
        let page_size = 30_u32;
        let mut page_number = 1_u32;
        let first = self
            .fetch_space_archive_page(
                owner_mid,
                list_id,
                kind,
                page_number,
                page_size,
                newest_first,
            )
            .await?;
        let meta = match kind {
            SpaceListKind::Collection => first.meta,
            SpaceListKind::Series => self.fetch_space_series_meta(list_id).await?.meta,
        };
        let total_count = first
            .page
            .total
            .or_else(|| meta.as_ref().and_then(|meta| meta.total))
            .unwrap_or(first.archives.len());
        let total_pages = total_count.div_ceil(page_size as usize);
        let mut archives = first.archives;
        let mut items = Vec::new();
        loop {
            for archive in archives {
                self.push_space_archive_items(&mut items, archive).await?;
                if fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if fetch_mode.is_satisfied_by(items.len()) || (page_number as usize) >= total_pages {
                break;
            }
            page_number += 1;
            let next_data = self
                .fetch_space_archive_page(
                    owner_mid,
                    list_id,
                    kind,
                    page_number,
                    page_size,
                    newest_first,
                )
                .await?;
            archives = next_data.archives;
        }
        renumber_collection_items(&mut items);
        let title = meta
            .as_ref()
            .and_then(SpaceArchiveMeta::title)
            .unwrap_or_else(|| format!("{} {list_id}", kind.title()));
        Ok(VideoCollectionMetadata {
            id: Some(list_id),
            kind: kind.into_collection_kind(),
            title,
            description: meta
                .as_ref()
                .and_then(|meta| meta.description.clone())
                .unwrap_or_default(),
            cover_url: meta.as_ref().and_then(|meta| meta.cover.clone()),
            pub_time: meta.as_ref().and_then(SpaceArchiveMeta::pub_time),
            owner: Some(Owner {
                mid: meta.as_ref().and_then(|meta| meta.mid).unwrap_or(owner_mid),
                name: String::new(),
            }),
            items,
        })
    }

    async fn fetch_space_archive_page(
        &self,
        owner_mid: u64,
        list_id: u64,
        kind: SpaceListKind,
        page_number: u32,
        page_size: u32,
        newest_first: bool,
    ) -> Result<SpaceArchiveListData> {
        let path = match kind {
            SpaceListKind::Collection => "/x/polymer/web-space/seasons_archives_list",
            SpaceListKind::Series => "/x/series/archives",
        };
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, path)?;
        match kind {
            SpaceListKind::Collection => {
                url.query_pairs_mut()
                    .append_pair("mid", &owner_mid.to_string())
                    .append_pair("season_id", &list_id.to_string())
                    .append_pair("sort_reverse", if newest_first { "true" } else { "false" })
                    .append_pair("page_num", &page_number.to_string())
                    .append_pair("page_size", &page_size.to_string());
            }
            SpaceListKind::Series => {
                url.query_pairs_mut()
                    .append_pair("mid", &owner_mid.to_string())
                    .append_pair("current_mid", "0")
                    .append_pair("series_id", &list_id.to_string())
                    .append_pair("only_normal", "true")
                    .append_pair("sort", "desc")
                    .append_pair("pn", &page_number.to_string())
                    .append_pair("ps", &page_size.to_string());
            }
        }
        let response: ApiData<SpaceArchiveListData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_space_series_meta(&self, list_id: u64) -> Result<SpaceSeriesMetaData> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/series/series")?;
        url.query_pairs_mut()
            .append_pair("series_id", &list_id.to_string());
        let response: ApiData<SpaceSeriesMetaData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn push_space_archive_items(
        &self,
        items: &mut Vec<VideoCollectionItem>,
        archive: SpaceArchive,
    ) -> Result<()> {
        let aid = archive
            .aid
            .ok_or(Error::MissingField("data.archives[].aid"))?;
        let metadata = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
        let page_count = u32::try_from(metadata.pages.len())
            .ok()
            .filter(|count| *count > 0)
            .unwrap_or(1);
        for page in metadata.pages {
            push_unique_collection_item(
                items,
                VideoCollectionItem {
                    index: 0,
                    aid: page.aid,
                    bvid: metadata.bvid.clone().or_else(|| archive.bvid.clone()),
                    cid: page.cid,
                    title: if page_count == 1 {
                        archive
                            .title
                            .clone()
                            .unwrap_or_else(|| metadata.title.clone())
                    } else {
                        format_collection_page_title(
                            archive.title.as_deref().unwrap_or(&metadata.title),
                            page.index,
                            &page.title,
                        )
                    },
                    cover_url: metadata.cover_url.clone().or_else(|| archive.pic.clone()),
                    description: metadata.description.clone(),
                    pub_time: metadata.pub_time.or(archive.pubdate).or(archive.ctime),
                    owner: metadata.owner.clone(),
                    duration_seconds: page.duration_seconds.or(archive.duration),
                },
            );
        }
        Ok(())
    }

    async fn push_medialist_media_items(
        &self,
        items: &mut Vec<VideoCollectionItem>,
        media: MediaListMedia,
    ) -> Result<()> {
        let aid = media
            .id
            .ok_or(Error::MissingField("data.media_list[].id"))?;
        if media.pages.is_empty() {
            let metadata = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
            let page_count = u32::try_from(metadata.pages.len())
                .ok()
                .filter(|count| *count > 0)
                .unwrap_or(1);
            for page in metadata.pages {
                push_unique_collection_item(
                    items,
                    VideoCollectionItem {
                        index: 0,
                        aid: page.aid,
                        bvid: metadata.bvid.clone().or_else(|| media.bvid.clone()),
                        cid: page.cid,
                        title: if page_count == 1 {
                            media
                                .title
                                .clone()
                                .unwrap_or_else(|| metadata.title.clone())
                        } else {
                            format_collection_page_title(
                                media.title.as_deref().unwrap_or(&metadata.title),
                                page.index,
                                &page.title,
                            )
                        },
                        cover_url: metadata.cover_url.clone().or_else(|| media.cover.clone()),
                        description: media
                            .intro
                            .clone()
                            .unwrap_or_else(|| metadata.description.clone()),
                        pub_time: media.pubtime.or(metadata.pub_time),
                        owner: media
                            .upper
                            .clone()
                            .and_then(FavoriteUpper::into_owner)
                            .or(metadata.owner.clone()),
                        duration_seconds: page.duration_seconds,
                    },
                );
            }
            return Ok(());
        }
        push_medialist_media_pages(items, media, aid)
    }

    async fn fetch_space_video_collection(
        &self,
        mid: u64,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let collection = self
            .fetch_space_video_collection_all(mid, fetch_mode)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_space_video_collection_all(
        &self,
        mid: u64,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let page_size = 50_u32;
        let mut page_number = 1_u32;
        let first = self
            .fetch_space_video_page(mid, page_number, page_size)
            .await?;
        let total_count = first.page.count.unwrap_or(first.videos.len());
        let total_pages = total_count.div_ceil(page_size as usize);
        let mut page_videos = first.videos;
        let mut items = Vec::new();
        loop {
            for video in page_videos {
                let aid = video
                    .aid
                    .ok_or(Error::MissingField("data.list.vlist[].aid"))?;
                let metadata = self.fetch_video_by_aid(aid, TagPolicy::Skip).await?;
                let page_count = u32::try_from(metadata.pages.len())
                    .ok()
                    .filter(|count| *count > 0)
                    .unwrap_or(1);
                for page in metadata.pages {
                    items.push(VideoCollectionItem {
                        index: 0,
                        aid: page.aid,
                        bvid: metadata.bvid.clone().or_else(|| video.bvid.clone()),
                        cid: page.cid,
                        title: if page_count == 1 {
                            metadata.title.clone()
                        } else {
                            format_collection_page_title(&metadata.title, page.index, &page.title)
                        },
                        cover_url: metadata.cover_url.clone().or_else(|| video.pic.clone()),
                        description: metadata.description.clone(),
                        pub_time: metadata.pub_time.or(video.created),
                        owner: metadata.owner.clone(),
                        duration_seconds: page.duration_seconds,
                    });
                    if fetch_mode.is_satisfied_by(items.len()) {
                        break;
                    }
                }
                if fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if fetch_mode.is_satisfied_by(items.len()) || (page_number as usize) >= total_pages {
                break;
            }
            page_number += 1;
            page_videos = self
                .fetch_space_video_page(mid, page_number, page_size)
                .await?
                .videos;
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: Some(mid),
            kind: VideoCollectionKind::Space,
            title: format!("Space {mid} videos"),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: Some(Owner {
                mid,
                name: mid.to_string(),
            }),
            items,
        })
    }

    async fn fetch_space_video_page(
        &self,
        mid: u64,
        page_number: u32,
        page_size: u32,
    ) -> Result<SpaceArcSearchData> {
        let mixin_key = self.fetch_wbi_mixin_key().await?;
        let params = vec![
            ("mid", mid.to_string()),
            ("order", "pubdate".to_owned()),
            ("pn", page_number.to_string()),
            ("ps", page_size.to_string()),
            ("tid", "0".to_owned()),
            ("wts", current_unix_timestamp().to_string()),
        ];
        let mut url =
            Self::endpoint_url(&self.config.endpoints.api_base, "/x/space/wbi/arc/search")?;
        url.set_query(Some(&wbi_signed_query(params, &mixin_key)));
        let response: ApiData<SpaceArcSearchRootData> = self.get_json(url).await?;
        let data = response.into_data()?;
        Ok(SpaceArcSearchData {
            videos: data.list.videos,
            page: data.page,
        })
    }

    async fn fetch_recommendation_collection(
        &self,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
        stop_when_satisfied: bool,
    ) -> Result<VideoCollectionResolution> {
        let collection = self
            .fetch_recommendation_collection_all(fetch_mode, stop_when_satisfied)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_recommendation_collection_all(
        &self,
        fetch_mode: FeedListFetchMode,
        stop_when_satisfied: bool,
    ) -> Result<VideoCollectionMetadata> {
        let mixin_key = self.fetch_wbi_mixin_key().await?;
        let mut page_size = recommendation_initial_page_size(fetch_mode);
        let mut fresh_idx = 1;
        let mut items = Vec::new();
        loop {
            let page = self
                .fetch_recommendation_page(page_size, fresh_idx, &mixin_key)
                .await?;
            let raw_count = page.items.len();
            for item in page.items {
                push_recommendation_item(&mut items, item);
                if stop_when_satisfied && fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if !recommendation_should_retry(
                fetch_mode,
                items.len(),
                raw_count,
                page_size,
                fresh_idx,
            ) {
                break;
            }
            if page_size < RECOMMENDATION_MAX_PAGE_SIZE {
                page_size = RECOMMENDATION_MAX_PAGE_SIZE;
            } else {
                fresh_idx += 1;
            }
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: None,
            kind: VideoCollectionKind::Recommendation,
            title: "Recommendations".to_owned(),
            description: "Bilibili homepage recommendations".to_owned(),
            cover_url: items.first().and_then(|item| item.cover_url.clone()),
            pub_time: items.first().and_then(|item| item.pub_time),
            owner: None,
            items,
        })
    }

    async fn fetch_recommendation_page(
        &self,
        page_size: u32,
        fresh_idx: u32,
        mixin_key: &str,
    ) -> Result<RecommendationFeedData> {
        let mut url = Self::endpoint_url(
            &self.config.endpoints.api_base,
            "/x/web-interface/wbi/index/top/feed/rcmd",
        )?;
        let params = vec![
            ("fresh_idx", fresh_idx.to_string()),
            ("ps", page_size.to_string()),
            ("wts", current_unix_timestamp().to_string()),
        ];
        url.set_query(Some(&wbi_signed_query(params, mixin_key)));
        let response: ApiData<RecommendationFeedData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_history_collection(
        &self,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let collection = self.fetch_history_collection_all(fetch_mode).await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_history_collection_all(
        &self,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let page_size = 20_u32;
        let mut cursor = HistoryCursorQuery::default();
        let mut items = Vec::new();
        loop {
            let page = self
                .fetch_history_collection_page(&cursor, page_size)
                .await?;
            if page.list.is_empty() {
                break;
            }
            for entry in page.list {
                push_history_entry_item(&mut items, entry);
                if fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if fetch_mode.is_satisfied_by(items.len()) {
                break;
            }
            let next_cursor = page
                .cursor
                .map_or_else(|| cursor.clone(), |next| next.into_query(&cursor));
            if next_cursor == cursor {
                break;
            }
            cursor = next_cursor;
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: None,
            kind: VideoCollectionKind::History,
            title: "History".to_owned(),
            description: "Bilibili watch history".to_owned(),
            cover_url: items.first().and_then(|item| item.cover_url.clone()),
            pub_time: items.first().and_then(|item| item.pub_time),
            owner: None,
            items,
        })
    }

    async fn fetch_history_collection_page(
        &self,
        cursor: &HistoryCursorQuery,
        page_size: u32,
    ) -> Result<HistoryCursorData> {
        let mut url = Self::endpoint_url(
            &self.config.endpoints.api_base,
            "/x/web-interface/history/cursor",
        )?;
        url.query_pairs_mut()
            .append_pair("max", &cursor.max.to_string())
            .append_pair("view_at", &cursor.view_at.to_string())
            .append_pair("business", &cursor.business)
            .append_pair("type", "archive")
            .append_pair("ps", &page_size.to_string());
        let response: ApiData<HistoryCursorData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn fetch_watch_later_collection(
        &self,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let collection = self.fetch_watch_later_collection_all(fetch_mode).await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_watch_later_collection_all(
        &self,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let page = self.fetch_watch_later_collection_page().await?;
        let mut items = Vec::new();
        for item in page.list {
            self.push_watch_later_item(&mut items, item).await?;
            if fetch_mode.is_satisfied_by(items.len()) {
                break;
            }
        }
        renumber_collection_items(&mut items);
        Ok(VideoCollectionMetadata {
            id: None,
            kind: VideoCollectionKind::WatchLater,
            title: "Watch later".to_owned(),
            description: "Bilibili watch-later list".to_owned(),
            cover_url: items.first().and_then(|item| item.cover_url.clone()),
            pub_time: items.first().and_then(|item| item.pub_time),
            owner: None,
            items,
        })
    }

    async fn fetch_watch_later_collection_page(&self) -> Result<WatchLaterData> {
        let url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/v2/history/toview")?;
        let response: ApiData<WatchLaterData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn push_watch_later_item(
        &self,
        items: &mut Vec<VideoCollectionItem>,
        item: WatchLaterItem,
    ) -> Result<()> {
        let Some(item) = self.watch_later_item(item).await? else {
            return Ok(());
        };
        push_unique_collection_item(items, item);
        Ok(())
    }

    async fn watch_later_item(&self, item: WatchLaterItem) -> Result<Option<VideoCollectionItem>> {
        let WatchLaterItem {
            aid,
            bvid,
            cid,
            title,
            desc,
            pic,
            cover43,
            pubdate,
            add_at,
            duration,
            page,
            videos,
            index_title,
            long_title,
            owner,
        } = item;
        let Some(aid) = aid.and_then(FlexibleU64::into_u64) else {
            return Ok(None);
        };
        let page_index = page
            .as_ref()
            .and_then(WatchLaterPageField::page_index)
            .filter(|page| *page > 0)
            .unwrap_or(1);
        let page_cid = page.as_ref().and_then(WatchLaterPageField::cid);
        let top_level_cid = cid.and_then(FlexibleU64::into_u64).filter(|cid| *cid > 0);

        let mut fallback = None;
        let cid = if let Some(cid) = page_cid.or(top_level_cid) {
            cid
        } else {
            let detail = match self
                .fetch_watch_later_item_fallback(aid, bvid.as_deref(), page_index)
                .await
            {
                Ok(detail) => detail,
                Err(error) if is_skippable_watch_later_fallback_error(&error) => return Ok(None),
                Err(error) => return Err(error),
            };
            let cid = detail.page.cid;
            fallback = Some(detail);
            cid
        };

        Ok(Some(build_watch_later_item(WatchLaterItemBuild {
            aid,
            bvid,
            cid,
            title,
            desc,
            pic,
            cover43,
            pubdate,
            add_at,
            duration,
            page,
            page_index,
            videos,
            index_title,
            long_title,
            owner,
            fallback,
        })))
    }

    async fn fetch_watch_later_item_fallback(
        &self,
        aid: u64,
        bvid: Option<&str>,
        page_index: u32,
    ) -> Result<WatchLaterItemFallback> {
        let metadata = match bvid {
            Some(bvid) => self.fetch_video_by_bvid(bvid, TagPolicy::Skip).await?,
            None => self.fetch_video_by_aid(aid, TagPolicy::Skip).await?,
        };
        let page = metadata
            .pages
            .iter()
            .find(|page| page.index == page_index)
            .cloned()
            .ok_or(Error::MissingField("data.pages[]"))?;
        Ok(WatchLaterItemFallback { metadata, page })
    }

    async fn fetch_dynamic_video_collection(
        &self,
        kind: DynamicFeedKind,
        selection: Option<Selection>,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionResolution> {
        let collection = self
            .fetch_dynamic_video_collection_all(kind, fetch_mode)
            .await?;
        Self::resolve_collection_selection(collection, selection.as_ref())
    }

    async fn fetch_dynamic_video_collection_all(
        &self,
        kind: DynamicFeedKind,
        fetch_mode: FeedListFetchMode,
    ) -> Result<VideoCollectionMetadata> {
        let mut offset = None;
        let mut items = Vec::new();
        loop {
            let page = self
                .fetch_dynamic_feed_page(kind, offset.as_deref())
                .await?;
            if page.items.is_empty() {
                break;
            }
            for item in page.items {
                self.push_dynamic_feed_item(&mut items, item, fetch_mode)
                    .await?;
                if fetch_mode.is_satisfied_by(items.len()) {
                    break;
                }
            }
            if fetch_mode.is_satisfied_by(items.len()) || !page.has_more.unwrap_or(false) {
                break;
            }
            let Some(next_offset) = page.offset.filter(|value| !value.is_empty()) else {
                break;
            };
            if offset.as_deref() == Some(next_offset.as_str()) {
                break;
            }
            offset = Some(next_offset);
        }
        renumber_collection_items(&mut items);
        let owner = match kind {
            DynamicFeedKind::Following => None,
            DynamicFeedKind::Space { mid } => items
                .first()
                .and_then(|item| item.owner.clone())
                .filter(|owner| owner.mid == mid)
                .or_else(|| {
                    Some(Owner {
                        mid,
                        name: mid.to_string(),
                    })
                }),
        };
        Ok(VideoCollectionMetadata {
            id: kind.collection_id(),
            kind: kind.collection_kind(),
            title: kind.collection_title(),
            description: kind.collection_description(),
            cover_url: items.first().and_then(|item| item.cover_url.clone()),
            pub_time: items.first().and_then(|item| item.pub_time),
            owner,
            items,
        })
    }

    async fn fetch_dynamic_feed_page(
        &self,
        kind: DynamicFeedKind,
        offset: Option<&str>,
    ) -> Result<DynamicFeedData> {
        let mut url = Self::endpoint_url(&self.config.endpoints.api_base, kind.endpoint_path())?;
        {
            let mut query = url.query_pairs_mut();
            query
                .append_pair("platform", "web")
                .append_pair("features", DYNAMIC_FEED_FEATURES);
            match kind {
                DynamicFeedKind::Following => {
                    query.append_pair("type", "video");
                }
                DynamicFeedKind::Space { mid } => {
                    query.append_pair("host_mid", &mid.to_string());
                }
            }
            if let Some(offset) = offset.filter(|value| !value.is_empty()) {
                query.append_pair("offset", offset);
            }
        }
        let response: ApiData<DynamicFeedData> = self.get_json(url).await?;
        response.into_data()
    }

    async fn push_dynamic_feed_item(
        &self,
        items: &mut Vec<VideoCollectionItem>,
        item: DynamicFeedItem,
        fetch_mode: FeedListFetchMode,
    ) -> Result<()> {
        let Some(seed) = dynamic_archive_seed(item) else {
            return Ok(());
        };
        let metadata = self.fetch_video_by_aid(seed.aid, TagPolicy::Skip).await?;
        let page_count = u32::try_from(metadata.pages.len())
            .ok()
            .filter(|count| *count > 0)
            .unwrap_or(1);
        for page in metadata.pages {
            push_unique_collection_item(
                items,
                VideoCollectionItem {
                    index: 0,
                    aid: page.aid,
                    bvid: metadata.bvid.clone().or_else(|| seed.bvid.clone()),
                    cid: page.cid,
                    title: if page_count == 1 {
                        metadata.title.clone()
                    } else {
                        format_collection_page_title(&metadata.title, page.index, &page.title)
                    },
                    cover_url: metadata
                        .cover_url
                        .clone()
                        .or_else(|| seed.cover_url.clone()),
                    description: metadata.description.clone(),
                    pub_time: metadata.pub_time.or(seed.pub_time),
                    owner: metadata.owner.clone().or_else(|| seed.owner.clone()),
                    duration_seconds: page.duration_seconds,
                },
            );
            if fetch_mode.is_satisfied_by(items.len()) {
                break;
            }
        }
        Ok(())
    }

    async fn fetch_wbi_mixin_key(&self) -> Result<String> {
        let url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/web-interface/nav")?;
        let response: ApiData<NavData> = self.get_json(url).await?;
        let data = response.into_data()?;
        wbi_mixin_key(
            data.wbi_img
                .img_url
                .as_deref()
                .ok_or(Error::MissingField("data.wbi_img.img_url"))?,
            data.wbi_img
                .sub_url
                .as_deref()
                .ok_or(Error::MissingField("data.wbi_img.sub_url"))?,
        )
    }

    async fn fetch_intl_season_by_ep(
        &self,
        epid: u64,
        selection: Option<Selection>,
    ) -> Result<SeasonResolution> {
        let mut url = Self::endpoint_url(
            &self.config.endpoints.intl_base,
            "/intl/gateway/v2/ogv/view/app/season",
        )?;
        {
            let mut query = url.query_pairs_mut();
            query
                .append_pair("ep_id", &epid.to_string())
                .append_pair("platform", "android")
                .append_pair("s_locale", "zh_SG")
                .append_pair("mobi_app", "bstar_a");
            if let Some(access_key) = self.config.credentials.access_key.as_deref() {
                query.append_pair("access_key", access_key);
            }
        }
        let response: IntlSeasonRoot = self.get_json(url).await?;
        let result = response.into_result()?;
        if let Some(message) = result.access_limit_message() {
            return Err(Error::AccessRestricted(message));
        }
        let season = season_from_intl(result, Some(epid));
        let selection = selection.unwrap_or(Selection::Current);
        Self::resolve_season_selection(season, Some(&selection), Some(epid), "intl episode")
    }

    async fn fetch_pgc_season(&self, url: Url) -> Result<SeasonMetadata> {
        let response: ApiResult<PgcSeasonResult> = self.get_json(url).await?;
        Ok(season_from_pgc(response.into_result()?))
    }

    fn resolve_season_selection(
        season: SeasonMetadata,
        selection: Option<&Selection>,
        current_epid: Option<u64>,
        input_kind: &'static str,
    ) -> Result<SeasonResolution> {
        let selected_episodes = match selection {
            Some(Selection::All) => season.episodes.clone(),
            Some(Selection::Latest) => season.episodes[..season.main_episode_count]
                .last()
                .cloned()
                .into_iter()
                .collect(),
            Some(Selection::Episode(epid)) => season
                .episodes
                .iter()
                .find(|episode| episode.epid == *epid)
                .cloned()
                .into_iter()
                .collect(),
            Some(Selection::Page(page)) => season
                .episodes
                .iter()
                .find(|episode| episode.index == *page)
                .cloned()
                .into_iter()
                .collect(),
            Some(Selection::Indices(indices)) => select_items_by_index(
                &season.episodes,
                indices,
                |episode| episode.index,
                "selected episode",
            )?,
            Some(Selection::Current) | None => {
                let epid = current_epid.ok_or(Error::SelectionRequired { input_kind })?;
                season
                    .episodes
                    .iter()
                    .find(|episode| episode.epid == epid)
                    .cloned()
                    .into_iter()
                    .collect()
            }
        };

        if selected_episodes.is_empty() {
            return Err(Error::MissingField("selected episode"));
        }

        Ok(SeasonResolution {
            season,
            selected_episodes,
        })
    }

    async fn plan_video(
        &self,
        video: VideoMetadata,
        selection: Option<Selection>,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        let pages = Self::select_video_pages(&video, selection.as_ref())?;
        let mut entries = Vec::new();
        for page in pages {
            entries.push(
                self.plan_entry(PlanEntrySeed {
                    index: page.index,
                    aid: page.aid,
                    bvid: video.bvid.clone(),
                    cid: page.cid,
                    epid: None,
                    title: page.title,
                    cover_url: video.cover_url.clone(),
                    source: self.normal_stream_source(),
                    planning_mode,
                })
                .await?,
            );
        }
        Ok(DownloadPlan {
            title: video.title,
            entries,
        })
    }

    async fn plan_season(
        &self,
        season: SeasonResolution,
        source: StreamSource,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        let mut entries = Vec::new();
        for episode in season.selected_episodes {
            entries.push(
                self.plan_entry(PlanEntrySeed {
                    index: episode.index,
                    aid: episode.aid,
                    bvid: episode.bvid,
                    cid: episode.cid,
                    epid: Some(episode.epid),
                    title: episode_display_title(&episode.title, episode.long_title.as_deref()),
                    cover_url: season.season.cover_url.clone(),
                    source: source.clone(),
                    planning_mode,
                })
                .await?,
            );
        }
        Ok(DownloadPlan {
            title: season.season.title,
            entries,
        })
    }

    async fn plan_collection(
        &self,
        collection: VideoCollectionResolution,
        planning_mode: PlanningMode,
    ) -> Result<DownloadPlan> {
        let mut entries = Vec::new();
        for item in collection.selected_items {
            entries.push(
                self.plan_entry(PlanEntrySeed {
                    index: item.index,
                    aid: item.aid,
                    bvid: item.bvid,
                    cid: item.cid,
                    epid: None,
                    title: item.title,
                    cover_url: item.cover_url,
                    source: self.normal_stream_source(),
                    planning_mode,
                })
                .await?,
            );
        }
        Ok(DownloadPlan {
            title: collection.collection.title,
            entries,
        })
    }

    async fn plan_entry(&self, seed: PlanEntrySeed) -> Result<DownloadEntry> {
        let (source, streams, diagnostics) = if seed.planning_mode.requires_streams() {
            let resolved_streams = self
                .fetch_stream_set(seed.source.clone(), seed.aid, seed.cid, seed.epid)
                .await?;
            (
                resolved_streams.source,
                resolved_streams.streams,
                resolved_streams.diagnostics,
            )
        } else {
            (
                seed.source.clone(),
                empty_stream_set(),
                StreamDiagnostics::default(),
            )
        };
        let subtitles = if seed.planning_mode.requires_subtitles() {
            let result = self
                .fetch_subtitles(source.clone(), seed.aid, seed.cid, seed.epid)
                .await;
            if matches!(seed.planning_mode, PlanningMode::SubtitlesOnly) {
                result?
            } else {
                result.unwrap_or_default()
            }
        } else {
            Vec::new()
        };
        Ok(DownloadEntry {
            index: seed.index,
            aid: seed.aid,
            bvid: seed.bvid,
            cid: seed.cid,
            epid: seed.epid,
            title: seed.title,
            cover_url: normalize_optional_media_url(seed.cover_url.as_deref()),
            source,
            streams,
            diagnostics,
            subtitles,
            danmaku: DanmakuTrack {
                cid: seed.cid,
                xml_url: Self::endpoint_url(
                    &self.config.endpoints.comment_base,
                    &format!("/{}.xml", seed.cid),
                )?
                .to_string(),
            },
        })
    }

    fn select_video_pages(
        video: &VideoMetadata,
        selection: Option<&Selection>,
    ) -> Result<Vec<PageMetadata>> {
        let pages = match selection {
            Some(Selection::All) => video.pages.clone(),
            Some(Selection::Latest) => video.pages.last().cloned().into_iter().collect(),
            Some(Selection::Page(page)) => video
                .pages
                .iter()
                .find(|candidate| candidate.index == *page)
                .cloned()
                .into_iter()
                .collect(),
            Some(Selection::Indices(indices)) => {
                select_items_by_index(&video.pages, indices, |page| page.index, "selected page")?
            }
            Some(Selection::Current) | None => video.pages.first().cloned().into_iter().collect(),
            Some(Selection::Episode(_)) => {
                return Err(Error::InvalidInput(
                    "episode selection is only valid for PGC inputs".to_owned(),
                ));
            }
        };
        if pages.is_empty() {
            return Err(Error::MissingField("selected page"));
        }
        Ok(pages)
    }

    fn select_video_metadata(
        mut video: VideoMetadata,
        selection: Option<&Selection>,
    ) -> Result<VideoMetadata> {
        if selection.is_some() {
            video.pages = Self::select_video_pages(&video, selection)?;
        }
        Ok(video)
    }

    fn resolve_collection_selection(
        collection: VideoCollectionMetadata,
        selection: Option<&Selection>,
    ) -> Result<VideoCollectionResolution> {
        let selected_items = select_feed_list_items(
            &collection.items,
            selection,
            |item| item.index,
            "selected collection item",
        )?;
        Ok(VideoCollectionResolution {
            collection,
            selected_items,
        })
    }

    fn collection_fetch_mode(selection: Option<&Selection>) -> Result<FeedListFetchMode> {
        feed_list_fetch_mode(selection)
    }

    fn collection_info_fetch_mode(selection: Option<&Selection>) -> Result<FeedListFetchMode> {
        feed_list_info_fetch_mode(selection)
    }

    async fn fetch_stream_set(
        &self,
        source: StreamSource,
        aid: u64,
        cid: u64,
        epid: Option<u64>,
    ) -> Result<ResolvedStreamSet> {
        if source == StreamSource::PgcWeb {
            return self.fetch_pgc_stream_set(aid, cid, epid).await;
        }
        if source == StreamSource::PgcTv {
            return self.fetch_pgc_tv_stream_set(aid, cid, epid).await;
        }
        if source == StreamSource::PgcApp {
            return self.fetch_pgc_app_stream_set(aid, cid, epid).await;
        }
        if source == StreamSource::NormalApp {
            return self
                .fetch_app_stream_set(StreamSource::NormalApp, aid, cid, false)
                .await;
        }
        let mut url = match source {
            StreamSource::NormalWeb => {
                Self::endpoint_url(&self.config.endpoints.api_base, "/x/player/playurl")?
            }
            StreamSource::NormalTv => {
                Self::endpoint_url(&self.config.endpoints.tv_api_base, "/x/tv/playurl")?
            }
            StreamSource::NormalApp => unreachable!(),
            StreamSource::PugvWeb => {
                Self::endpoint_url(&self.config.endpoints.api_base, "/pugv/player/web/playurl")?
            }
            StreamSource::PgcWeb
            | StreamSource::PgcTv
            | StreamSource::PgcApp
            | StreamSource::PgcProxy => {
                unreachable!()
            }
            StreamSource::IntlWeb => Self::endpoint_url(
                &self.config.endpoints.intl_base,
                "/intl/gateway/v2/ogv/playurl",
            )?,
        };
        match source {
            StreamSource::NormalWeb => {
                url.query_pairs_mut()
                    .append_pair("avid", &aid.to_string())
                    .append_pair("cid", &cid.to_string())
                    .append_pair("qn", "0")
                    .append_pair("fnval", "4048")
                    .append_pair("fnver", "0")
                    .append_pair("fourk", "1")
                    .append_pair("try_look", "1")
                    .append_pair("otype", "json");
            }
            StreamSource::NormalTv => {
                append_tv_playurl_params(
                    &mut url,
                    aid,
                    cid,
                    None,
                    self.config.credentials.tv_access_key.as_deref(),
                );
            }
            StreamSource::PugvWeb => {
                let epid = epid.ok_or(Error::MissingField("epid"))?;
                url.query_pairs_mut()
                    .append_pair("avid", &aid.to_string())
                    .append_pair("cid", &cid.to_string())
                    .append_pair("ep_id", &epid.to_string())
                    .append_pair("module", "bangumi")
                    .append_pair("qn", "0")
                    .append_pair("fnval", "4048")
                    .append_pair("fnver", "0")
                    .append_pair("fourk", "1")
                    .append_pair("otype", "json");
            }
            StreamSource::NormalApp
            | StreamSource::PgcWeb
            | StreamSource::PgcTv
            | StreamSource::PgcApp
            | StreamSource::PgcProxy => unreachable!(),
            StreamSource::IntlWeb => {
                let epid = epid.ok_or(Error::MissingField("epid"))?;
                let mut query = url.query_pairs_mut();
                for (key, value) in intl_ogv_playurl_params(
                    epid,
                    cid,
                    self.config.credentials.access_key.as_deref(),
                    current_unix_timestamp(),
                ) {
                    query.append_pair(key, &value);
                }
            }
        }
        let streams = match source {
            StreamSource::NormalTv => self.fetch_playurl_stream_set_without_cookie(url).await?,
            _ => self.fetch_playurl_stream_set(url).await?,
        };
        Ok(ResolvedStreamSet::official(source, streams))
    }

    async fn fetch_pgc_app_stream_set(
        &self,
        aid: u64,
        cid: u64,
        epid: Option<u64>,
    ) -> Result<ResolvedStreamSet> {
        let epid = epid.ok_or(Error::MissingField("epid"))?;
        let url = Self::endpoint_url(
            &self.config.endpoints.app_pgc_grpc_base,
            app_playurl::PGC_PLAYURL_PATH,
        )?;
        match self
            .post_app_playurl_stream_set(url.clone(), epid, cid, true)
            .await
        {
            Ok(streams) => Ok(ResolvedStreamSet::official(StreamSource::PgcApp, streams)),
            Err(error) => {
                self.fetch_pgc_proxy_stream_set_after_error(
                    aid,
                    cid,
                    epid,
                    StreamSource::PgcApp,
                    Some(redact_url_for_diagnostics(&url)),
                    error,
                )
                .await
            }
        }
    }

    async fn fetch_app_stream_set(
        &self,
        source: StreamSource,
        content_id: u64,
        cid: u64,
        is_pgc: bool,
    ) -> Result<ResolvedStreamSet> {
        let base = if is_pgc {
            &self.config.endpoints.app_pgc_grpc_base
        } else {
            &self.config.endpoints.app_grpc_base
        };
        let path = if is_pgc {
            app_playurl::PGC_PLAYURL_PATH
        } else {
            app_playurl::NORMAL_PLAYURL_PATH
        };
        let url = Self::endpoint_url(base, path)?;
        let streams = self
            .post_app_playurl_stream_set(url, content_id, cid, is_pgc)
            .await?;
        Ok(ResolvedStreamSet::official(source, streams))
    }

    async fn post_app_playurl_stream_set(
        &self,
        url: Url,
        content_id: u64,
        cid: u64,
        is_pgc: bool,
    ) -> Result<StreamSet> {
        let response = self
            .http
            .post(url)
            .headers(app_playurl::play_view_headers(
                self.app_playurl_access_key(),
                self.config.request_timeout,
            )?)
            .body(app_playurl::play_view_request_body(
                content_id, cid, is_pgc,
            )?)
            .timeout(self.config.request_timeout)
            .send()
            .await
            .map_err(Self::http_error_without_url)?;
        let response = response
            .error_for_status()
            .map_err(Self::http_error_without_url)?;
        let response = Self::collect_app_grpc_response(response).await?;
        decode_app_grpc_stream_set(&response, is_pgc)
    }

    async fn fetch_pgc_tv_stream_set(
        &self,
        aid: u64,
        cid: u64,
        epid: Option<u64>,
    ) -> Result<ResolvedStreamSet> {
        let epid = epid.ok_or(Error::MissingField("epid"))?;
        let mut url = Self::endpoint_url(
            &self.config.endpoints.tv_api_base,
            "/pgc/player/api/playurltv",
        )?;
        append_tv_playurl_params(
            &mut url,
            aid,
            cid,
            Some(epid),
            self.config.credentials.tv_access_key.as_deref(),
        );
        let streams = self.fetch_playurl_stream_set_without_cookie(url).await?;
        Ok(ResolvedStreamSet::official(StreamSource::PgcTv, streams))
    }

    async fn fetch_pgc_stream_set(
        &self,
        aid: u64,
        cid: u64,
        epid: Option<u64>,
    ) -> Result<ResolvedStreamSet> {
        let epid = epid.ok_or(Error::MissingField("epid"))?;
        let official_url = Self::pgc_playurl_url(&self.config.endpoints.pgc_base, aid, cid, epid)?;
        match self.fetch_playurl_stream_set(official_url.clone()).await {
            Ok(streams) => Ok(ResolvedStreamSet::official(StreamSource::PgcWeb, streams)),
            Err(error) => {
                self.fetch_pgc_proxy_stream_set_after_error(
                    aid,
                    cid,
                    epid,
                    StreamSource::PgcWeb,
                    Some(redact_url_for_diagnostics(&official_url)),
                    error,
                )
                .await
            }
        }
    }

    async fn fetch_pgc_proxy_stream_set_after_error(
        &self,
        aid: u64,
        cid: u64,
        epid: u64,
        official_source: StreamSource,
        official_endpoint: Option<String>,
        error: Error,
    ) -> Result<ResolvedStreamSet> {
        if self.config.restricted_area.proxies.is_empty()
            || !is_restricted_area_fallback_error(&official_source, &error)
        {
            return Err(error);
        }
        let proxy_access_key = self.pgc_proxy_playurl_access_key();
        let mut attempts = vec![resolver_attempt(
            official_source,
            None,
            official_endpoint,
            StreamResolverOutcome::Failed,
            Some(resolver_error_message(&error)),
        )];
        for proxy in self.config.restricted_area.ordered_proxies() {
            let request_urls =
                match Self::pgc_proxy_playurl_urls(&proxy, aid, cid, epid, proxy_access_key) {
                    Ok(urls) => urls,
                    Err(error) => {
                        attempts.push(resolver_attempt(
                            StreamSource::PgcProxy,
                            proxy.area,
                            Some(redact_url_string(&proxy.base_url)),
                            StreamResolverOutcome::Failed,
                            Some(resolver_error_message(&error)),
                        ));
                        continue;
                    }
                };
            for request_url in request_urls {
                match self
                    .fetch_playurl_stream_set_without_cookie(request_url.clone())
                    .await
                {
                    Ok(streams) => {
                        attempts.push(resolver_attempt(
                            StreamSource::PgcProxy,
                            proxy.area,
                            Some(redact_url_for_diagnostics(&request_url)),
                            StreamResolverOutcome::Succeeded,
                            None,
                        ));
                        return Ok(ResolvedStreamSet {
                            source: StreamSource::PgcProxy,
                            streams,
                            diagnostics: StreamDiagnostics { attempts },
                        });
                    }
                    Err(error) => attempts.push(resolver_attempt(
                        StreamSource::PgcProxy,
                        proxy.area,
                        Some(redact_url_for_diagnostics(&request_url)),
                        StreamResolverOutcome::Failed,
                        Some(resolver_error_message(&error)),
                    )),
                }
            }
        }
        Err(Error::AccessRestricted(format!(
            "restricted-area resolver failed: {}",
            summarize_resolver_attempts(&attempts)
        )))
    }

    fn pgc_playurl_url(base_url: &str, aid: u64, cid: u64, epid: u64) -> Result<Url> {
        let mut url = Self::endpoint_url(base_url, "/pgc/player/web/v2/playurl")?;
        append_pgc_playurl_params(&mut url, aid, cid, epid, None, None);
        Ok(url)
    }

    fn pgc_proxy_playurl_urls(
        proxy: &RestrictedAreaProxy,
        aid: u64,
        cid: u64,
        epid: u64,
        access_key: Option<&str>,
    ) -> Result<Vec<Url>> {
        let mut urls = match proxy.kind {
            RestrictedAreaProxyKind::PlayUrl => vec![Url::parse(&proxy.base_url)?],
            RestrictedAreaProxyKind::BilibiliApi => vec![
                Self::endpoint_url_preserving_query(&proxy.base_url, "/pgc/player/web/playurl")?,
                Self::endpoint_url_preserving_query(&proxy.base_url, "/pgc/player/web/v2/playurl")?,
            ],
        };
        for url in &mut urls {
            append_pgc_playurl_params(url, aid, cid, epid, proxy.area, access_key);
        }
        Ok(urls)
    }

    fn pgc_proxy_playurl_access_key(&self) -> Option<&str> {
        self.config
            .credentials
            .access_key
            .as_deref()
            .filter(|value| !value.is_empty())
    }

    async fn collect_app_grpc_response(response: reqwest::Response) -> Result<AppGrpcResponse> {
        let response: http::Response<reqwest::Body> = response.into();
        let (parts, body) = response.into_parts();
        let collected = body.collect().await.map_err(Self::http_error_without_url)?;
        let trailers = collected.trailers().cloned();
        Ok(AppGrpcResponse {
            headers: parts.headers,
            body: collected.to_bytes().to_vec(),
            trailers,
        })
    }

    async fn fetch_playurl_stream_set(&self, url: Url) -> Result<StreamSet> {
        let response: PlayUrlRoot = self.get_json(url).await?;
        response.into_stream_set()
    }

    async fn fetch_playurl_stream_set_without_cookie(&self, url: Url) -> Result<StreamSet> {
        let response: PlayUrlRoot = self.get_json_without_cookie(url).await?;
        response.into_stream_set()
    }

    async fn fetch_subtitles(
        &self,
        source: StreamSource,
        aid: u64,
        cid: u64,
        epid: Option<u64>,
    ) -> Result<Vec<SubtitleTrack>> {
        match source {
            StreamSource::NormalWeb
            | StreamSource::NormalTv
            | StreamSource::NormalApp
            | StreamSource::PgcWeb
            | StreamSource::PgcTv
            | StreamSource::PgcApp
            | StreamSource::PgcProxy
            | StreamSource::PugvWeb => {
                let mut url = Self::endpoint_url(&self.config.endpoints.api_base, "/x/player/v2")?;
                url.query_pairs_mut()
                    .append_pair("aid", &aid.to_string())
                    .append_pair("cid", &cid.to_string());
                let response: ApiData<PlayerV2Data> = self.get_json(url).await?;
                Ok(response.into_data()?.into_subtitles())
            }
            StreamSource::IntlWeb => {
                let epid = epid.ok_or(Error::MissingField("epid"))?;
                let mut url = Self::endpoint_url(
                    &self.config.endpoints.intl_base,
                    "/intl/gateway/web/v2/subtitle",
                )?;
                {
                    let mut query = url.query_pairs_mut();
                    query
                        .append_pair("episode_id", &epid.to_string())
                        .append_pair("platform", "web")
                        .append_pair("s_locale", "en_US");
                    if let Some(access_key) = self.config.credentials.access_key.as_deref() {
                        query.append_pair("access_key", access_key);
                    }
                }
                let response: ApiData<IntlSubtitleData> = self.get_json(url).await?;
                Ok(response.into_data()?.into_subtitles())
            }
        }
    }

    fn normal_stream_source(&self) -> StreamSource {
        match self.config.playurl_mode {
            PlayurlMode::Web => StreamSource::NormalWeb,
            PlayurlMode::Tv => StreamSource::NormalTv,
            PlayurlMode::App => StreamSource::NormalApp,
        }
    }

    fn pgc_stream_source(&self) -> StreamSource {
        match self.config.playurl_mode {
            PlayurlMode::Web => StreamSource::PgcWeb,
            PlayurlMode::Tv => StreamSource::PgcTv,
            PlayurlMode::App => StreamSource::PgcApp,
        }
    }

    fn app_playurl_access_key(&self) -> Option<&str> {
        self.config
            .credentials
            .tv_access_key
            .as_deref()
            .filter(|value| !value.is_empty())
            .or_else(|| {
                self.config
                    .credentials
                    .access_key
                    .as_deref()
                    .filter(|value| !value.is_empty())
            })
    }

    async fn get_json<T>(&self, url: Url) -> Result<T>
    where
        T: for<'de> Deserialize<'de>,
    {
        self.get_json_with_cookie(url, true).await
    }

    async fn get_json_without_cookie<T>(&self, url: Url) -> Result<T>
    where
        T: for<'de> Deserialize<'de>,
    {
        self.get_json_with_cookie(url, false).await
    }

    async fn get_json_with_cookie<T>(&self, url: Url, include_cookie: bool) -> Result<T>
    where
        T: for<'de> Deserialize<'de>,
    {
        let response = self
            .http
            .get(url)
            .headers(self.headers(include_cookie)?)
            .timeout(self.config.request_timeout)
            .send()
            .await
            .map_err(Self::http_error_without_url)?;
        let response = response
            .error_for_status()
            .map_err(Self::http_error_without_url)?;
        response
            .json::<T>()
            .await
            .map_err(Self::http_error_without_url)
    }

    pub(crate) fn anonymous_headers(&self) -> Result<HeaderMap> {
        self.headers(false)
    }

    pub(crate) fn media_headers(&self) -> Result<HeaderMap> {
        self.headers(false)
    }

    fn headers(&self, include_cookie: bool) -> Result<HeaderMap> {
        let mut headers = HeaderMap::new();
        headers.insert(
            USER_AGENT,
            HeaderValue::from_str(&self.config.user_agent)
                .unwrap_or_else(|_| HeaderValue::from_static("bbdown-rs/0.1")),
        );
        headers.insert(
            REFERER,
            HeaderValue::from_static("https://www.bilibili.com/"),
        );
        if include_cookie
            && let Some(cookie) = self.config.credentials.cookie.as_deref()
            && !cookie.is_empty()
        {
            let value = HeaderValue::from_str(cookie)
                .map_err(|_| Error::InvalidInput("invalid cookie header".to_owned()))?;
            headers.insert(COOKIE, value);
        }
        Ok(headers)
    }

    pub(crate) fn http_error_without_url(error: reqwest::Error) -> Error {
        Error::Http(error.without_url())
    }

    pub(crate) fn endpoint_url(base: &str, path: &str) -> Result<Url> {
        let mut url = Url::parse(base)?;
        set_endpoint_path(&mut url, path);
        url.set_query(None);
        url.set_fragment(None);
        Ok(url)
    }

    fn endpoint_url_preserving_query(base: &str, path: &str) -> Result<Url> {
        let mut url = Url::parse(base)?;
        set_endpoint_path(&mut url, path);
        url.set_fragment(None);
        Ok(url)
    }
}

fn deserialize_default_vec<'de, D, T>(deserializer: D) -> std::result::Result<Vec<T>, D::Error>
where
    D: serde::Deserializer<'de>,
    T: Deserialize<'de>,
{
    Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
}

fn set_endpoint_path(url: &mut Url, path: &str) {
    let base_path = url.path().trim_end_matches('/');
    let suffix = path.trim_start_matches('/');
    let next_path = if base_path.is_empty() {
        format!("/{suffix}")
    } else {
        format!("{base_path}/{suffix}")
    };
    url.set_path(&next_path);
}

fn pugv_episode_page_size(page: &PugvEpisodePage, loaded_episode_count: usize) -> u32 {
    page.size
        .or_else(|| u32::try_from(loaded_episode_count).ok())
        .filter(|size| *size > 0)
        .unwrap_or(20)
}

fn pugv_page_has_next(page: &PugvEpisodePage, page_size: u32, page_number: u32) -> bool {
    page.next.unwrap_or_else(|| {
        page.total
            .is_some_and(|total| page_number < total.div_ceil(page_size))
    })
}

fn season_from_pgc(result: PgcSeasonResult) -> SeasonMetadata {
    let PgcSeasonResult {
        season_id,
        media_id,
        title,
        season_title,
        evaluate,
        cover,
        episodes,
        section,
        areas,
        styles,
    } = result;
    let mut episodes = episodes_to_metadata(episodes, 0);
    let main_episode_count = episodes.len();
    let section_episodes = section
        .into_iter()
        .flat_map(|section| section.episodes.into_iter())
        .collect();
    episodes.extend(episodes_to_metadata(section_episodes, main_episode_count));
    SeasonMetadata {
        season_id,
        media_id,
        title: title.or(season_title).unwrap_or_default(),
        description: evaluate.unwrap_or_default(),
        cover_url: cover,
        main_episode_count,
        areas: areas.into_iter().filter_map(PgcName::into_name).collect(),
        tags: styles.into_iter().filter_map(PgcName::into_name).collect(),
        episodes,
    }
}

fn season_from_intl(result: IntlSeasonResult, current_epid: Option<u64>) -> SeasonMetadata {
    let IntlSeasonResult {
        season_id,
        media_id,
        title,
        season_title,
        evaluate,
        cover,
        episodes,
        modules,
        areas,
        styles,
        ..
    } = result;
    let mut module_episode_groups = modules
        .into_iter()
        .filter_map(|module| module.data)
        .map(|data| data.episodes)
        .filter(|episodes| !episodes.is_empty())
        .collect::<Vec<_>>();
    let module_episodes = current_epid
        .and_then(|epid| {
            module_episode_groups
                .iter()
                .find(|episodes| episodes.iter().any(|episode| episode.epid() == Some(epid)))
                .cloned()
        })
        .unwrap_or_else(|| module_episode_groups.drain(..).flatten().collect());
    let episodes = if episodes.is_empty() {
        module_episodes
    } else {
        episodes
    };
    let episodes = episodes_to_metadata(episodes, 0);
    let main_episode_count = episodes.len();
    SeasonMetadata {
        season_id,
        media_id,
        title: title.or(season_title).unwrap_or_default(),
        description: evaluate.unwrap_or_default(),
        cover_url: cover,
        main_episode_count,
        areas: areas.into_iter().filter_map(PgcName::into_name).collect(),
        tags: styles.into_iter().filter_map(PgcName::into_name).collect(),
        episodes,
    }
}

fn season_from_pugv(data: PugvSeasonData) -> SeasonMetadata {
    let owner = data.up_info.and_then(FavoriteUpper::into_owner);
    let mut episodes = Vec::new();
    for (fallback_index, episode) in data.episodes.into_iter().enumerate() {
        let Some(epid) = episode.id else {
            continue;
        };
        let Some(aid) = episode.aid else {
            continue;
        };
        let Some(cid) = episode.cid else {
            continue;
        };
        let Some(index) = u32::try_from(fallback_index + 1).ok() else {
            continue;
        };
        episodes.push(EpisodeMetadata {
            index,
            aid,
            bvid: None,
            cid,
            epid,
            title: index.to_string(),
            long_title: episode.title,
            pub_time: episode.release_date,
        });
    }
    let main_episode_count = episodes.len();
    SeasonMetadata {
        season_id: data.season_id,
        media_id: None,
        title: data.title.unwrap_or_default(),
        description: data.subtitle.unwrap_or_default(),
        cover_url: data.cover,
        main_episode_count,
        areas: Vec::new(),
        tags: owner
            .map(|owner| vec![format!("UP: {}", owner.name)])
            .unwrap_or_default(),
        episodes,
    }
}

fn episodes_to_metadata(episodes: Vec<PgcEpisode>, start_index: usize) -> Vec<EpisodeMetadata> {
    let mut output = Vec::new();
    for episode in episodes {
        if let Some(mut episode) = episode_from_pgc(0, episode) {
            let index = start_index
                .checked_add(output.len())
                .and_then(|value| value.checked_add(1));
            if let Some(index) = index.and_then(|value| u32::try_from(value).ok()) {
                episode.index = index;
                output.push(episode);
            }
        }
    }
    output
}

fn episode_from_pgc(index: usize, episode: PgcEpisode) -> Option<EpisodeMetadata> {
    let epid = episode.epid()?;
    Some(EpisodeMetadata {
        index: u32::try_from(index + 1).ok()?,
        aid: episode.aid?,
        bvid: episode.bvid,
        cid: episode.cid?,
        epid,
        title: episode.title.unwrap_or_default(),
        long_title: episode.long_title,
        pub_time: episode.pub_time,
    })
}

#[derive(Debug, Deserialize)]
struct ApiData<T> {
    code: i64,
    #[serde(default)]
    message: String,
    data: Option<T>,
}

impl<T> ApiData<T> {
    fn into_data(self) -> Result<T> {
        if self.code != 0 {
            return Err(Error::Api {
                code: self.code,
                message: self.message,
            });
        }
        self.data.ok_or(Error::MissingField("data"))
    }
}

#[derive(Debug, Deserialize)]
struct ApiResult<T> {
    code: i64,
    #[serde(default)]
    message: String,
    result: Option<T>,
}

impl<T> ApiResult<T> {
    fn into_result(self) -> Result<T> {
        if self.code != 0 {
            return Err(Error::Api {
                code: self.code,
                message: self.message,
            });
        }
        self.result.ok_or(Error::MissingField("result"))
    }
}

#[derive(Debug, Deserialize)]
struct ViewData {
    aid: Option<u64>,
    bvid: Option<String>,
    title: String,
    desc: Option<String>,
    pic: Option<String>,
    pubdate: Option<i64>,
    owner: Option<ViewOwner>,
    #[serde(default)]
    pages: Vec<ViewPage>,
}

#[derive(Debug, Deserialize)]
struct ViewOwner {
    mid: u64,
    name: String,
}

#[derive(Debug, Deserialize)]
struct ViewPage {
    page: u32,
    cid: u64,
    part: Option<String>,
    duration: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct TagData {
    tag_id: Option<u64>,
    tag_name: Option<String>,
}

#[derive(Debug, Deserialize)]
struct PgcSeasonResult {
    season_id: Option<u64>,
    media_id: Option<u64>,
    title: Option<String>,
    season_title: Option<String>,
    evaluate: Option<String>,
    cover: Option<String>,
    #[serde(default)]
    episodes: Vec<PgcEpisode>,
    #[serde(default)]
    section: Vec<PgcSection>,
    #[serde(default)]
    areas: Vec<PgcName>,
    #[serde(default)]
    styles: Vec<PgcName>,
}

#[derive(Debug, Deserialize)]
struct IntlSeasonRoot {
    code: i64,
    #[serde(default)]
    message: String,
    result: Option<IntlSeasonResult>,
    data: Option<IntlSeasonResult>,
}

impl IntlSeasonRoot {
    fn into_result(self) -> Result<IntlSeasonResult> {
        if self.code != 0 {
            return Err(Error::Api {
                code: self.code,
                message: self.message,
            });
        }
        self.result
            .or(self.data)
            .ok_or(Error::MissingField("result"))
    }
}

#[derive(Debug, Deserialize)]
struct IntlSeasonResult {
    season_id: Option<u64>,
    media_id: Option<u64>,
    title: Option<String>,
    season_title: Option<String>,
    evaluate: Option<String>,
    cover: Option<String>,
    status: Option<i64>,
    limit: Option<IntlLimit>,
    #[serde(default)]
    episodes: Vec<PgcEpisode>,
    #[serde(default)]
    modules: Vec<IntlModule>,
    #[serde(default)]
    areas: Vec<PgcName>,
    #[serde(default)]
    styles: Vec<PgcName>,
}

impl IntlSeasonResult {
    fn access_limit_message(&self) -> Option<String> {
        if self.has_episodes() {
            return None;
        }
        let content = self
            .limit
            .as_ref()
            .and_then(|limit| limit.content.as_deref())
            .map(str::trim)
            .filter(|content| !content.is_empty());
        if let Some(content) = content {
            return Some(content.to_owned());
        }
        if self.status == Some(13) {
            return Some("intl content is not available in the current region".to_owned());
        }
        None
    }

    fn has_episodes(&self) -> bool {
        !self.episodes.is_empty()
            || self.modules.iter().any(|module| {
                module
                    .data
                    .as_ref()
                    .is_some_and(|data| !data.episodes.is_empty())
            })
    }
}

#[derive(Debug, Deserialize)]
struct IntlLimit {
    content: Option<String>,
}

#[derive(Debug, Deserialize)]
struct IntlModule {
    data: Option<IntlModuleData>,
}

#[derive(Debug, Deserialize)]
struct IntlModuleData {
    #[serde(default)]
    episodes: Vec<PgcEpisode>,
}

#[derive(Clone, Debug, Deserialize)]
struct PgcEpisode {
    aid: Option<u64>,
    bvid: Option<String>,
    cid: Option<u64>,
    id: Option<u64>,
    ep_id: Option<u64>,
    episode_id: Option<u64>,
    title: Option<String>,
    long_title: Option<String>,
    pub_time: Option<i64>,
}

impl PgcEpisode {
    fn epid(&self) -> Option<u64> {
        self.ep_id.or(self.episode_id).or(self.id)
    }
}

#[derive(Debug, Deserialize)]
struct PgcSection {
    #[serde(default)]
    episodes: Vec<PgcEpisode>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PgcName {
    Object { name: Option<String> },
    Text(String),
}

impl PgcName {
    fn into_name(self) -> Option<String> {
        match self {
            Self::Object { name } => name,
            Self::Text(name) => Some(name),
        }
        .filter(|name| !name.is_empty())
    }
}

#[derive(Debug, Deserialize)]
struct PgcReviewResult {
    media: Option<PgcReviewMedia>,
}

#[derive(Debug, Deserialize)]
struct PgcReviewMedia {
    new_ep: Option<PgcReviewEpisode>,
}

#[derive(Debug, Deserialize)]
struct PgcReviewEpisode {
    id: u64,
}

#[derive(Debug, Deserialize)]
struct PugvSeasonData {
    season_id: Option<u64>,
    title: Option<String>,
    subtitle: Option<String>,
    cover: Option<String>,
    up_info: Option<FavoriteUpper>,
    episode_page: Option<PugvEpisodePage>,
    #[serde(default)]
    episodes: Vec<PugvEpisode>,
}

#[derive(Debug, Deserialize)]
struct PugvEpisodeListData {
    #[serde(default, alias = "episodes")]
    items: Vec<PugvEpisode>,
    page: Option<PugvEpisodePage>,
}

#[derive(Clone, Copy, Debug, Deserialize)]
struct PugvEpisodePage {
    next: Option<bool>,
    #[serde(alias = "pn", alias = "page_num")]
    num: Option<u32>,
    #[serde(alias = "ps", alias = "page_size")]
    size: Option<u32>,
    #[serde(alias = "count")]
    total: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct PugvEpisode {
    id: Option<u64>,
    aid: Option<u64>,
    cid: Option<u64>,
    title: Option<String>,
    release_date: Option<i64>,
}

#[derive(Debug, Deserialize)]
struct FavoriteFolderListData {
    #[serde(default)]
    list: Vec<FavoriteFolder>,
}

#[derive(Debug, Deserialize)]
struct FavoriteFolder {
    id: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct FavoriteResourceListData {
    info: FavoriteInfo,
    #[serde(default, deserialize_with = "deserialize_default_vec")]
    medias: Vec<FavoriteMedia>,
}

#[derive(Debug, Deserialize)]
struct FavoriteInfo {
    media_count: Option<usize>,
    title: Option<String>,
    intro: Option<String>,
    cover: Option<String>,
    ctime: Option<i64>,
    upper: Option<FavoriteUpper>,
}

#[derive(Clone, Debug, Deserialize)]
struct FavoriteUpper {
    mid: Option<u64>,
    name: Option<String>,
    uname: Option<String>,
}

impl FavoriteUpper {
    fn into_owner(self) -> Option<Owner> {
        Some(Owner {
            mid: self.mid?,
            name: self.name.or(self.uname).unwrap_or_default(),
        })
    }
}

#[derive(Debug, Deserialize)]
struct FavoriteMedia {
    id: Option<u64>,
    #[serde(rename = "type")]
    media_type: Option<u8>,
    bvid: Option<String>,
    title: Option<String>,
    intro: Option<String>,
    cover: Option<String>,
    pubtime: Option<i64>,
    duration: Option<u32>,
    attr: Option<i64>,
    page: Option<u32>,
    upper: Option<FavoriteUpper>,
    ugc: Option<FavoriteUgc>,
}

#[derive(Debug, Deserialize)]
struct FavoriteUgc {
    first_cid: Option<u64>,
}

#[derive(Debug, Deserialize)]
struct MediaListInfoData {
    title: Option<String>,
    intro: Option<String>,
    cover: Option<String>,
    ctime: Option<i64>,
}

#[derive(Debug, Deserialize)]
struct MediaListResourcePageData {
    has_more: Option<bool>,
    #[serde(default, deserialize_with = "deserialize_default_vec")]
    media_list: Vec<MediaListMedia>,
}

#[derive(Debug, Deserialize)]
struct MediaListMedia {
    id: Option<u64>,
    #[serde(alias = "bv_id")]
    bvid: Option<String>,
    title: Option<String>,
    intro: Option<String>,
    cover: Option<String>,
    pubtime: Option<i64>,
    attr: Option<i64>,
    page: Option<u32>,
    upper: Option<FavoriteUpper>,
    #[serde(default)]
    pages: Vec<MediaListPage>,
}

#[derive(Debug, Deserialize)]
struct MediaListPage {
    id: Option<u64>,
    page: Option<u32>,
    title: Option<String>,
    duration: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct SpaceArcSearchRootData {
    list: SpaceArcVideoList,
    page: SpaceArcPage,
}

#[derive(Debug)]
struct SpaceArcSearchData {
    videos: Vec<SpaceArcVideo>,
    page: SpaceArcPage,
}

#[derive(Debug, Deserialize)]
struct SpaceArcVideoList {
    #[serde(default, rename = "vlist")]
    videos: Vec<SpaceArcVideo>,
}

#[derive(Debug, Deserialize)]
struct SpaceArcPage {
    count: Option<usize>,
}

#[derive(Debug, Deserialize)]
struct SpaceArcVideo {
    aid: Option<u64>,
    bvid: Option<String>,
    pic: Option<String>,
    created: Option<i64>,
}

#[derive(Debug, Deserialize)]
struct SpaceArchiveListData {
    #[serde(default)]
    archives: Vec<SpaceArchive>,
    meta: Option<SpaceArchiveMeta>,
    #[serde(default)]
    page: SpaceArchivePage,
}

#[derive(Debug, Deserialize)]
struct SpaceSeriesMetaData {
    meta: Option<SpaceArchiveMeta>,
}

#[derive(Debug, Deserialize)]
struct SpaceArchive {
    aid: Option<u64>,
    bvid: Option<String>,
    title: Option<String>,
    pic: Option<String>,
    ctime: Option<i64>,
    pubdate: Option<i64>,
    duration: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct SpaceArchiveMeta {
    name: Option<String>,
    title: Option<String>,
    description: Option<String>,
    cover: Option<String>,
    mid: Option<u64>,
    ptime: Option<i64>,
    ctime: Option<i64>,
    total: Option<usize>,
}

impl SpaceArchiveMeta {
    fn title(&self) -> Option<String> {
        first_non_empty([self.name.clone(), self.title.clone()])
    }

    fn pub_time(&self) -> Option<i64> {
        self.ptime.or(self.ctime)
    }
}

#[derive(Default, Debug, Deserialize)]
struct SpaceArchivePage {
    total: Option<usize>,
    #[serde(alias = "pn", alias = "page_num")]
    _num: Option<u32>,
    #[serde(alias = "ps", alias = "page_size")]
    _size: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct RecommendationFeedData {
    #[serde(default, rename = "item", deserialize_with = "deserialize_default_vec")]
    items: Vec<RecommendationItem>,
}

#[derive(Debug, Deserialize)]
struct RecommendationItem {
    #[serde(rename = "goto")]
    target_type: Option<String>,
    id: Option<FlexibleU64>,
    bvid: Option<String>,
    cid: Option<FlexibleU64>,
    pic: Option<String>,
    title: Option<String>,
    duration: Option<u32>,
    pubdate: Option<i64>,
    owner: Option<FavoriteUpper>,
}

#[derive(Debug, Deserialize)]
struct DynamicFeedData {
    has_more: Option<bool>,
    offset: Option<String>,
    #[serde(default, deserialize_with = "deserialize_default_vec")]
    items: Vec<DynamicFeedItem>,
}

#[derive(Debug, Deserialize)]
struct DynamicFeedItem {
    #[serde(rename = "type")]
    item_type: Option<String>,
    visible: Option<bool>,
    modules: Option<DynamicModules>,
}

#[derive(Debug, Deserialize)]
struct DynamicModules {
    module_author: Option<DynamicAuthor>,
    module_dynamic: Option<DynamicModule>,
}

#[derive(Clone, Debug, Deserialize)]
struct DynamicAuthor {
    mid: Option<u64>,
    name: Option<String>,
    pub_ts: Option<i64>,
}

impl DynamicAuthor {
    fn into_owner(self) -> Option<Owner> {
        Some(Owner {
            mid: self.mid?,
            name: self.name.unwrap_or_default(),
        })
    }
}

#[derive(Debug, Deserialize)]
struct DynamicModule {
    major: Option<DynamicMajor>,
}

#[derive(Debug, Deserialize)]
struct DynamicMajor {
    #[serde(rename = "type")]
    major_type: Option<String>,
    archive: Option<DynamicArchive>,
}

#[derive(Debug, Deserialize)]
struct DynamicArchive {
    aid: Option<FlexibleU64>,
    bvid: Option<String>,
    cover: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum FlexibleU64 {
    Number(u64),
    Text(String),
}

impl FlexibleU64 {
    fn into_u64(self) -> Option<u64> {
        match self {
            Self::Number(value) => Some(value),
            Self::Text(value) => value.parse().ok(),
        }
    }

    fn as_u64(&self) -> Option<u64> {
        match self {
            Self::Number(value) => Some(*value),
            Self::Text(value) => value.parse().ok(),
        }
    }
}

#[derive(Debug)]
struct DynamicArchiveSeed {
    aid: u64,
    bvid: Option<String>,
    cover_url: Option<String>,
    owner: Option<Owner>,
    pub_time: Option<i64>,
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct HistoryCursorQuery {
    max: u64,
    view_at: u64,
    business: String,
}

#[derive(Debug, Deserialize)]
struct HistoryCursorData {
    cursor: Option<HistoryCursor>,
    #[serde(default, deserialize_with = "deserialize_default_vec")]
    list: Vec<HistoryEntry>,
}

#[derive(Debug, Deserialize)]
struct HistoryCursor {
    max: Option<u64>,
    view_at: Option<u64>,
    business: Option<String>,
}

impl HistoryCursor {
    fn into_query(self, current: &HistoryCursorQuery) -> HistoryCursorQuery {
        HistoryCursorQuery {
            max: self.max.unwrap_or(current.max),
            view_at: self.view_at.unwrap_or(current.view_at),
            business: self
                .business
                .filter(|business| !business.is_empty())
                .unwrap_or_else(|| current.business.clone()),
        }
    }
}

#[derive(Debug, Deserialize)]
struct HistoryEntry {
    aid: Option<u64>,
    title: Option<String>,
    bvid: Option<String>,
    pic: Option<String>,
    cover: Option<String>,
    desc: Option<String>,
    pubdate: Option<i64>,
    duration: Option<u32>,
    videos: Option<u32>,
    author_mid: Option<u64>,
    author_name: Option<String>,
    owner: Option<FavoriteUpper>,
    view_at: Option<i64>,
    business: Option<String>,
    history: Option<HistoryEntryKey>,
}

#[derive(Debug, Deserialize)]
struct HistoryEntryKey {
    oid: Option<u64>,
    cid: Option<u64>,
    bvid: Option<String>,
    page: Option<u32>,
    part: Option<String>,
    business: Option<String>,
}

#[derive(Debug, Deserialize)]
struct WatchLaterData {
    #[serde(default, deserialize_with = "deserialize_default_vec")]
    list: Vec<WatchLaterItem>,
}

#[derive(Debug, Deserialize)]
struct WatchLaterItem {
    aid: Option<FlexibleU64>,
    bvid: Option<String>,
    cid: Option<FlexibleU64>,
    title: Option<String>,
    desc: Option<String>,
    pic: Option<String>,
    cover43: Option<String>,
    pubdate: Option<i64>,
    add_at: Option<i64>,
    duration: Option<u32>,
    page: Option<WatchLaterPageField>,
    videos: Option<u32>,
    index_title: Option<String>,
    long_title: Option<String>,
    owner: Option<FavoriteUpper>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum WatchLaterPageField {
    Number(u32),
    Object(WatchLaterPage),
}

impl WatchLaterPageField {
    fn page_index(&self) -> Option<u32> {
        match self {
            Self::Number(page) => Some(*page),
            Self::Object(page) => page.page,
        }
    }

    fn cid(&self) -> Option<u64> {
        match self {
            Self::Number(_) => None,
            Self::Object(page) => page
                .cid
                .as_ref()
                .and_then(FlexibleU64::as_u64)
                .filter(|cid| *cid > 0),
        }
    }

    fn part(&self) -> Option<String> {
        match self {
            Self::Number(_) => None,
            Self::Object(page) => page.part.clone(),
        }
    }

    fn duration(&self) -> Option<u32> {
        match self {
            Self::Number(_) => None,
            Self::Object(page) => page.duration,
        }
    }
}

#[derive(Debug, Deserialize)]
struct WatchLaterPage {
    cid: Option<FlexibleU64>,
    page: Option<u32>,
    part: Option<String>,
    duration: Option<u32>,
}

#[derive(Debug)]
struct WatchLaterItemFallback {
    metadata: VideoMetadata,
    page: PageMetadata,
}

#[derive(Debug)]
struct WatchLaterItemBuild {
    aid: u64,
    bvid: Option<String>,
    cid: u64,
    title: Option<String>,
    desc: Option<String>,
    pic: Option<String>,
    cover43: Option<String>,
    pubdate: Option<i64>,
    add_at: Option<i64>,
    duration: Option<u32>,
    page: Option<WatchLaterPageField>,
    page_index: u32,
    videos: Option<u32>,
    index_title: Option<String>,
    long_title: Option<String>,
    owner: Option<FavoriteUpper>,
    fallback: Option<WatchLaterItemFallback>,
}

#[derive(Debug, Deserialize)]
struct NavData {
    wbi_img: WbiImage,
}

#[derive(Debug, Deserialize)]
struct NavLoginData {
    #[serde(default, rename = "isLogin")]
    is_login: bool,
}

#[derive(Debug, Deserialize)]
struct WbiImage {
    img_url: Option<String>,
    sub_url: Option<String>,
}

#[derive(Clone, Debug)]
struct PlanEntrySeed {
    index: u32,
    aid: u64,
    bvid: Option<String>,
    cid: u64,
    epid: Option<u64>,
    title: String,
    cover_url: Option<String>,
    source: StreamSource,
    planning_mode: PlanningMode,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PlanningMode {
    Full,
    StreamsOnly,
    SubtitlesOnly,
    MetadataOnly,
}

impl PlanningMode {
    const fn from_download_mode(mode: DownloadMode) -> Self {
        match mode {
            DownloadMode::All => Self::Full,
            DownloadMode::VideoOnly | DownloadMode::AudioOnly => Self::StreamsOnly,
            DownloadMode::SubtitleOnly => Self::SubtitlesOnly,
            DownloadMode::DanmakuOnly | DownloadMode::CoverOnly => Self::MetadataOnly,
        }
    }

    const fn requires_streams(self) -> bool {
        matches!(self, Self::Full | Self::StreamsOnly)
    }

    const fn requires_subtitles(self) -> bool {
        matches!(self, Self::Full | Self::SubtitlesOnly)
    }
}

#[derive(Clone, Debug)]
struct ResolvedStreamSet {
    source: StreamSource,
    streams: StreamSet,
    diagnostics: StreamDiagnostics,
}

struct AppGrpcResponse {
    headers: HeaderMap,
    body: Vec<u8>,
    trailers: Option<HeaderMap>,
}

fn empty_stream_set() -> StreamSet {
    StreamSet {
        videos: Vec::new(),
        audios: Vec::new(),
        flv_segments: Vec::new(),
        accept_quality: Vec::new(),
        qualities: Vec::new(),
        duration_seconds: None,
    }
}

impl ResolvedStreamSet {
    fn official(source: StreamSource, streams: StreamSet) -> Self {
        Self {
            source,
            streams,
            diagnostics: StreamDiagnostics::default(),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TagPolicy {
    Fetch,
    Skip,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MediaListKind {
    Collection,
    Series,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SpaceListKind {
    Collection,
    Series,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum DynamicFeedKind {
    Following,
    Space { mid: u64 },
}

impl MediaListKind {
    const fn type_id(self) -> &'static str {
        match self {
            Self::Collection => "8",
            Self::Series => "5",
        }
    }

    const fn desc_value(self) -> &'static str {
        match self {
            Self::Collection => "false",
            Self::Series => "true",
        }
    }

    const fn title(self) -> &'static str {
        match self {
            Self::Collection => "Collection",
            Self::Series => "Series",
        }
    }

    const fn into_collection_kind(self) -> VideoCollectionKind {
        match self {
            Self::Collection => VideoCollectionKind::Collection,
            Self::Series => VideoCollectionKind::Series,
        }
    }
}

impl SpaceListKind {
    const fn title(self) -> &'static str {
        match self {
            Self::Collection => "Collection",
            Self::Series => "Series",
        }
    }

    const fn into_collection_kind(self) -> VideoCollectionKind {
        match self {
            Self::Collection => VideoCollectionKind::Collection,
            Self::Series => VideoCollectionKind::Series,
        }
    }
}

impl DynamicFeedKind {
    const fn endpoint_path(self) -> &'static str {
        match self {
            Self::Following => "/x/polymer/web-dynamic/v1/feed/all",
            Self::Space { .. } => "/x/polymer/web-dynamic/v1/feed/space",
        }
    }

    const fn collection_id(self) -> Option<u64> {
        match self {
            Self::Following => None,
            Self::Space { mid } => Some(mid),
        }
    }

    const fn collection_kind(self) -> VideoCollectionKind {
        match self {
            Self::Following => VideoCollectionKind::Following,
            Self::Space { .. } => VideoCollectionKind::SpaceDynamic,
        }
    }

    fn collection_title(self) -> String {
        match self {
            Self::Following => "Following videos".to_owned(),
            Self::Space { mid } => format!("Space {mid} dynamic videos"),
        }
    }

    fn collection_description(self) -> String {
        match self {
            Self::Following => "Bilibili following video feed".to_owned(),
            Self::Space { mid } => format!("Bilibili dynamic video feed for space {mid}"),
        }
    }
}

fn recommendation_initial_page_size(fetch_mode: FeedListFetchMode) -> u32 {
    match fetch_mode {
        FeedListFetchMode::Page(_) => RECOMMENDATION_MAX_PAGE_SIZE,
        FeedListFetchMode::All | FeedListFetchMode::Latest => RECOMMENDATION_MIN_PAGE_SIZE,
    }
}

fn recommendation_should_retry(
    fetch_mode: FeedListFetchMode,
    item_count: usize,
    raw_count: usize,
    page_size: u32,
    fresh_idx: u32,
) -> bool {
    let Some(target) = recommendation_target_item_count(fetch_mode) else {
        return false;
    };
    if item_count >= target || raw_count == 0 {
        return false;
    }
    if page_size < RECOMMENDATION_MAX_PAGE_SIZE {
        return usize::try_from(page_size).is_ok_and(|requested| raw_count >= requested);
    }
    fresh_idx < RECOMMENDATION_MAX_REFRESH_PAGES
}

fn recommendation_target_item_count(fetch_mode: FeedListFetchMode) -> Option<usize> {
    match fetch_mode {
        FeedListFetchMode::All => None,
        FeedListFetchMode::Latest => Some(1),
        FeedListFetchMode::Page(page) => usize::try_from(page).ok(),
    }
}

fn episode_display_title(title: &str, long_title: Option<&str>) -> String {
    match long_title.filter(|value| !value.is_empty()) {
        Some(long_title) if title.is_empty() => long_title.to_owned(),
        Some(long_title) => format!("{title} {long_title}"),
        None => title.to_owned(),
    }
}

fn push_medialist_media_pages(
    items: &mut Vec<VideoCollectionItem>,
    media: MediaListMedia,
    aid: u64,
) -> Result<()> {
    let title = media.title.unwrap_or_else(|| aid.to_string());
    let page_count = media.page.unwrap_or_else(|| {
        u32::try_from(media.pages.len())
            .ok()
            .filter(|count| *count > 0)
            .unwrap_or(1)
    });
    for page in media.pages {
        let cid = page
            .id
            .ok_or(Error::MissingField("data.media_list[].pages[].id"))?;
        let page_index = page.page.unwrap_or(1);
        push_unique_collection_item(
            items,
            VideoCollectionItem {
                index: 0,
                aid,
                bvid: media.bvid.clone(),
                cid,
                title: if page_count == 1 {
                    title.clone()
                } else {
                    format_collection_page_title(
                        &title,
                        page_index,
                        page.title.as_deref().unwrap_or_default(),
                    )
                },
                cover_url: media.cover.clone(),
                description: media.intro.clone().unwrap_or_default(),
                pub_time: media.pubtime,
                owner: media.upper.clone().and_then(FavoriteUpper::into_owner),
                duration_seconds: page.duration,
            },
        );
    }
    Ok(())
}

fn push_recommendation_item(items: &mut Vec<VideoCollectionItem>, item: RecommendationItem) {
    let Some(item) = recommendation_item(item) else {
        return;
    };
    push_unique_collection_item(items, item);
}

fn recommendation_item(item: RecommendationItem) -> Option<VideoCollectionItem> {
    if item
        .target_type
        .as_deref()
        .is_some_and(|target_type| target_type != "av")
    {
        return None;
    }
    let aid = item.id.and_then(FlexibleU64::into_u64)?;
    let cid = item.cid.and_then(FlexibleU64::into_u64)?;
    Some(VideoCollectionItem {
        index: 0,
        aid,
        bvid: item.bvid,
        cid,
        title: item.title.unwrap_or_else(|| aid.to_string()),
        cover_url: item.pic,
        description: String::new(),
        pub_time: item.pubdate,
        owner: item.owner.and_then(FavoriteUpper::into_owner),
        duration_seconds: item.duration,
    })
}

fn dynamic_archive_seed(item: DynamicFeedItem) -> Option<DynamicArchiveSeed> {
    if item.visible == Some(false) {
        return None;
    }
    if item
        .item_type
        .as_deref()
        .is_some_and(|item_type| item_type != "DYNAMIC_TYPE_AV")
    {
        return None;
    }
    let modules = item.modules?;
    let author = modules.module_author;
    let pub_time = author.as_ref().and_then(|author| author.pub_ts);
    let owner = author.and_then(DynamicAuthor::into_owner);
    let major = modules.module_dynamic?.major?;
    if major
        .major_type
        .as_deref()
        .is_some_and(|major_type| major_type != "MAJOR_TYPE_ARCHIVE")
    {
        return None;
    }
    let archive = major.archive?;
    Some(DynamicArchiveSeed {
        aid: archive.aid.and_then(FlexibleU64::into_u64)?,
        bvid: archive.bvid,
        cover_url: archive.cover,
        owner,
        pub_time,
    })
}

fn push_history_entry_item(items: &mut Vec<VideoCollectionItem>, entry: HistoryEntry) {
    let Some(item) = history_entry_item(entry) else {
        return;
    };
    push_unique_collection_item(items, item);
}

fn history_entry_item(entry: HistoryEntry) -> Option<VideoCollectionItem> {
    let HistoryEntry {
        aid,
        title,
        bvid,
        pic,
        cover,
        desc,
        pubdate,
        duration,
        videos,
        author_mid,
        author_name,
        owner,
        view_at,
        business,
        history,
    } = entry;
    let history = history?;
    let business = history.business.as_deref().or(business.as_deref());
    if business.is_some_and(|business| business != "archive") {
        return None;
    }
    let aid = aid.or(history.oid)?;
    let cid = history.cid?;
    let base_title = title.unwrap_or_else(|| aid.to_string());
    let page_index = history.page.unwrap_or(1);
    let page_count = videos
        .filter(|count| *count > 0)
        .unwrap_or_else(|| page_index.max(1));
    let title = if page_count == 1 {
        base_title
    } else {
        format_collection_page_title(
            &base_title,
            page_index,
            history.part.as_deref().unwrap_or(""),
        )
    };
    Some(VideoCollectionItem {
        index: 0,
        aid,
        bvid: bvid.or(history.bvid),
        cid,
        title,
        cover_url: first_non_empty([pic, cover]),
        description: desc.unwrap_or_default(),
        pub_time: pubdate.or(view_at),
        owner: owner.and_then(FavoriteUpper::into_owner).or_else(|| {
            Some(Owner {
                mid: author_mid?,
                name: author_name.unwrap_or_default(),
            })
        }),
        duration_seconds: duration,
    })
}

fn build_watch_later_item(build: WatchLaterItemBuild) -> VideoCollectionItem {
    let WatchLaterItemBuild {
        aid,
        bvid,
        cid,
        title,
        desc,
        pic,
        cover43,
        pubdate,
        add_at,
        duration,
        page,
        page_index,
        videos,
        index_title,
        long_title,
        owner,
        fallback,
    } = build;
    let fallback_ref = fallback.as_ref();
    let base_title = title
        .or_else(|| fallback_ref.map(|fallback| fallback.metadata.title.clone()))
        .unwrap_or_else(|| aid.to_string());
    let page_count = videos
        .filter(|count| *count > 0)
        .or_else(|| {
            fallback_ref.and_then(|fallback| {
                u32::try_from(fallback.metadata.pages.len())
                    .ok()
                    .filter(|count| *count > 0)
            })
        })
        .unwrap_or_else(|| page_index.max(1));
    let page_title = first_non_empty([
        index_title,
        long_title,
        page.as_ref().and_then(WatchLaterPageField::part),
        fallback_ref.map(|fallback| fallback.page.title.clone()),
    ]);
    let title = if page_count == 1 {
        base_title
    } else {
        format_collection_page_title(&base_title, page_index, page_title.as_deref().unwrap_or(""))
    };
    VideoCollectionItem {
        index: 0,
        aid,
        bvid: bvid.or_else(|| fallback_ref.and_then(|fallback| fallback.metadata.bvid.clone())),
        cid,
        title,
        cover_url: first_non_empty([
            pic,
            cover43,
            fallback_ref.and_then(|fallback| fallback.metadata.cover_url.clone()),
        ]),
        description: desc
            .filter(|desc| !desc.is_empty())
            .or_else(|| fallback_ref.map(|fallback| fallback.metadata.description.clone()))
            .unwrap_or_default(),
        pub_time: pubdate
            .or(add_at)
            .or_else(|| fallback_ref.and_then(|fallback| fallback.metadata.pub_time)),
        owner: owner
            .and_then(FavoriteUpper::into_owner)
            .or_else(|| fallback_ref.and_then(|fallback| fallback.metadata.owner.clone())),
        duration_seconds: page
            .as_ref()
            .and_then(WatchLaterPageField::duration)
            .or_else(|| fallback_ref.and_then(|fallback| fallback.page.duration_seconds))
            .or(duration),
    }
}

fn push_unique_collection_item(items: &mut Vec<VideoCollectionItem>, item: VideoCollectionItem) {
    push_unique_feed_list_item(items, item, |existing, candidate| {
        existing.aid == candidate.aid && existing.cid == candidate.cid
    });
}

fn renumber_collection_items(items: &mut [VideoCollectionItem]) {
    renumber_feed_list_items(items, |item, index| item.index = index);
}

fn format_collection_page_title(video_title: &str, page_index: u32, page_title: &str) -> String {
    if page_index == 1 && (page_title.is_empty() || page_title == video_title) {
        return video_title.to_owned();
    }
    if page_title.is_empty() {
        format!("{video_title}_P{page_index}")
    } else {
        format!("{video_title}_P{page_index}_{page_title}")
    }
}

fn wbi_signed_query(mut params: Vec<(&'static str, String)>, mixin_key: &str) -> String {
    params.sort_by(|left, right| left.0.cmp(right.0));
    let encoded = encode_query(&params);
    let sign = format!(
        "{:x}",
        Md5::digest(format!("{encoded}{mixin_key}").as_bytes())
    );
    params.push(("w_rid", sign));
    encode_query(&params)
}

fn encode_query(params: &[(&'static str, String)]) -> String {
    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
    for (key, value) in params {
        serializer.append_pair(key, value);
    }
    serializer.finish()
}

fn app_sign(query: &str, secret: &str) -> String {
    format!("{:x}", Md5::digest(format!("{query}{secret}").as_bytes()))
}

fn append_tv_playurl_params(
    url: &mut Url,
    aid: u64,
    cid: u64,
    epid: Option<u64>,
    access_key: Option<&str>,
) {
    let mut params = Vec::new();
    if let Some(access_key) = access_key.filter(|value| !value.is_empty()) {
        params.push(("access_key", access_key.to_owned()));
    }
    params.extend([
        ("appkey", TV_PLAYURL_APPKEY.to_owned()),
        ("build", "106500".to_owned()),
        ("cid", cid.to_string()),
        ("device", "android".to_owned()),
    ]);
    if let Some(epid) = epid {
        params.extend([("ep_id", epid.to_string()), ("expire", "0".to_owned())]);
    }
    params.extend([
        ("fnval", "4048".to_owned()),
        ("fnver", "0".to_owned()),
        ("fourk", "1".to_owned()),
        ("mid", "0".to_owned()),
        ("mobi_app", "android_tv_yst".to_owned()),
        ("object_id", aid.to_string()),
        ("platform", "android".to_owned()),
        ("playurl_type", "1".to_owned()),
        ("qn", "0".to_owned()),
        ("ts", current_unix_timestamp().to_string()),
    ]);
    let sign = app_sign(&encode_query(&params), TV_PLAYURL_APP_SECRET);
    let mut query = url.query_pairs_mut();
    for (key, value) in params {
        query.append_pair(key, &value);
    }
    query.append_pair("sign", &sign);
}

fn wbi_mixin_key(img_url: &str, sub_url: &str) -> Result<String> {
    const MIXIN_KEY_ENC_TAB: [usize; 32] = [
        46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19,
        29, 28, 14, 39, 12, 38, 41, 13,
    ];
    let source = format!("{}{}", wbi_key_part(img_url)?, wbi_key_part(sub_url)?);
    let mut output = String::with_capacity(32);
    for index in MIXIN_KEY_ENC_TAB {
        output.push(char::from(
            *source
                .as_bytes()
                .get(index)
                .ok_or(Error::MissingField("data.wbi_img mixin key"))?,
        ));
    }
    Ok(output)
}

fn wbi_key_part(raw: &str) -> Result<&str> {
    raw.rsplit('/')
        .next()
        .and_then(|file_name| file_name.split('.').next())
        .filter(|part| !part.is_empty())
        .ok_or(Error::MissingField("data.wbi_img key part"))
}

fn append_pgc_playurl_params(
    url: &mut Url,
    aid: u64,
    cid: u64,
    epid: u64,
    area: Option<RestrictedArea>,
    access_key: Option<&str>,
) {
    let mut query = url.query_pairs_mut();
    query
        .append_pair("avid", &aid.to_string())
        .append_pair("cid", &cid.to_string())
        .append_pair("ep_id", &epid.to_string())
        .append_pair("module", "bangumi")
        .append_pair("qn", "0")
        .append_pair("fnval", "4048")
        .append_pair("fnver", "0")
        .append_pair("fourk", "1")
        .append_pair("otype", "json");
    if let Some(area) = area {
        query.append_pair("area", area.as_str());
    }
    if let Some(access_key) = access_key.filter(|value| !value.is_empty()) {
        query.append_pair("access_key", access_key);
    }
}

fn is_restricted_area_fallback_error(source: &StreamSource, error: &Error) -> bool {
    match error {
        Error::Api { code, message } => {
            is_restricted_area_message(message) || source == &StreamSource::PgcApp && *code == 7
        }
        Error::AccessRestricted(message) => {
            is_restricted_area_message(message)
                || source == &StreamSource::PgcApp
                    && message.starts_with(app_playurl::APP_PREVIEW_ONLY_RESTRICTION_MESSAGE)
        }
        _ => false,
    }
}

fn is_skippable_watch_later_fallback_error(error: &Error) -> bool {
    match error {
        Error::Api { code, message } => {
            matches!(*code, -404 | 62002 | 62004) || is_watch_later_unavailable_message(message)
        }
        Error::MissingField("data.pages[]") => true,
        _ => false,
    }
}

fn is_watch_later_unavailable_message(message: &str) -> bool {
    let lower = message.to_ascii_lowercase();
    lower.contains("not found")
        || lower.contains("deleted")
        || message.contains("不存在")
        || message.contains("已删除")
        || message.contains("已失效")
        || message.contains("稿件不可见")
}

fn app_grpc_error_from_headers(headers: &HeaderMap) -> Option<Error> {
    let status = headers
        .get("grpc-status")
        .and_then(|value| value.to_str().ok())?
        .trim();
    if status.is_empty() || status == "0" {
        return None;
    }
    let code = status.parse::<i64>().unwrap_or(-1);
    let message = headers
        .get("grpc-message")
        .and_then(|value| value.to_str().ok())
        .map(decode_grpc_message)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| "APP playurl gRPC request failed".to_owned());
    Some(Error::Api { code, message })
}

fn decode_app_grpc_stream_set(response: &AppGrpcResponse, is_pgc: bool) -> Result<StreamSet> {
    if let Some(error) = app_grpc_error_from_headers(&response.headers) {
        return Err(error);
    }
    if let Some(trailers) = response.trailers.as_ref()
        && let Some(error) = app_grpc_error_from_headers(trailers)
    {
        return Err(error);
    }
    app_playurl::decode_play_view_response(&response.body, is_pgc)
}

fn decode_grpc_message(raw: &str) -> String {
    let bytes = raw.as_bytes();
    let mut output = Vec::with_capacity(bytes.len());
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'%'
            && index + 2 < bytes.len()
            && let (Some(high), Some(low)) =
                (hex_value(bytes[index + 1]), hex_value(bytes[index + 2]))
        {
            output.push((high << 4) | low);
            index += 3;
            continue;
        }
        output.push(bytes[index]);
        index += 1;
    }
    String::from_utf8(output).unwrap_or_else(|_| raw.to_owned())
}

const fn hex_value(byte: u8) -> Option<u8> {
    match byte {
        b'0'..=b'9' => Some(byte - b'0'),
        b'a'..=b'f' => Some(byte - b'a' + 10),
        b'A'..=b'F' => Some(byte - b'A' + 10),
        _ => None,
    }
}

fn is_restricted_area_message(message: &str) -> bool {
    let lower = message.to_ascii_lowercase();
    [
        "area restricted",
        "area limit",
        "region restricted",
        "region limit",
        "not available in your region",
        "地区限制",
        "地區限制",
        "区域限制",
        "區域限制",
        "所在地区不可观看",
        "所在地區不可觀看",
        "所在地区无法观看",
        "所在地區無法觀看",
        "所在的地区不可观看",
        "所在的地區不可觀看",
        "所在的地区无法观看",
        "所在的地區無法觀看",
        "地区不可观看",
        "地區不可觀看",
        "地区无法观看",
        "地區無法觀看",
    ]
    .iter()
    .any(|needle| lower.contains(needle))
}

fn resolver_attempt(
    source: StreamSource,
    area: Option<RestrictedArea>,
    endpoint: Option<String>,
    outcome: StreamResolverOutcome,
    message: Option<String>,
) -> StreamResolverAttempt {
    StreamResolverAttempt {
        source,
        outcome,
        area: area.map(|area| area.as_str().to_owned()),
        endpoint,
        message,
    }
}

fn resolver_error_message(error: &Error) -> String {
    match error {
        Error::Api { code, message } => {
            format!("API code {code}: {}", sanitize_diagnostic_text(message))
        }
        Error::AccessRestricted(message) => {
            format!("access restricted: {}", sanitize_diagnostic_text(message))
        }
        Error::MissingField(field) => format!("missing field: {field}"),
        Error::Http(error) => format!(
            "HTTP error: {}",
            sanitize_diagnostic_text(&error.to_string())
        ),
        Error::Json(error) => format!(
            "JSON error: {}",
            sanitize_diagnostic_text(&error.to_string())
        ),
        Error::Url(error) => format!(
            "URL error: {}",
            sanitize_diagnostic_text(&error.to_string())
        ),
        Error::InvalidInput(message) => {
            format!("invalid input: {}", sanitize_diagnostic_text(message))
        }
        Error::SelectionRequired { input_kind } => {
            format!("{input_kind} links require an explicit selection")
        }
        Error::Unsupported(message) => {
            format!("unsupported: {}", sanitize_diagnostic_text(message))
        }
        Error::Io(error) => format!(
            "I/O error: {}",
            sanitize_diagnostic_text(&error.to_string())
        ),
        Error::MuxFailed { status } => {
            format!(
                "ffmpeg mux failed with status {}",
                sanitize_diagnostic_text(status)
            )
        }
    }
}

fn summarize_resolver_attempts(attempts: &[StreamResolverAttempt]) -> String {
    attempts
        .iter()
        .map(|attempt| {
            let area = attempt
                .area
                .as_deref()
                .map(|area| format!(" area={area}"))
                .unwrap_or_default();
            let message = attempt
                .message
                .as_deref()
                .map(|message| format!(" ({message})"))
                .unwrap_or_default();
            format!("{:?}{area} {:?}{message}", attempt.source, attempt.outcome)
        })
        .collect::<Vec<_>>()
        .join("; ")
}

fn redact_url_for_diagnostics(url: &Url) -> String {
    let mut redacted = url.clone();
    let _ = redacted.set_username("");
    let _ = redacted.set_password(None);
    redacted.set_path("");
    redacted.set_query(None);
    redacted.set_fragment(None);
    redacted.to_string().trim_end_matches('/').to_owned()
}

fn redact_url_string(raw: &str) -> String {
    Url::parse(raw).map_or_else(
        |_| redact_unparsed_url_for_diagnostics(raw),
        |url| redact_url_for_diagnostics(&url),
    )
}

fn sanitize_diagnostic_text(raw: &str) -> String {
    let without_urls = redact_urls_in_text(raw);
    let redacted = redact_sensitive_key_values(&without_urls);
    let lower = redacted.to_ascii_lowercase();
    if SENSITIVE_DIAGNOSTIC_KEYS
        .iter()
        .any(|key| lower.contains(key))
    {
        "redacted diagnostic message".to_owned()
    } else {
        redacted
    }
}

fn credential_health_error(
    kind: CredentialKind,
    scope: CredentialHealthScope,
    endpoint: &'static str,
    error: Error,
    raw_secret: &str,
) -> CredentialHealthProbe {
    match error {
        Error::Api { code, message } => CredentialHealthProbe::rejected(
            kind,
            scope,
            endpoint,
            Some(code),
            sanitize_diagnostic_text_with_secret(&message, raw_secret),
        ),
        other => CredentialHealthProbe::request_failed(
            kind,
            scope,
            endpoint,
            sanitize_diagnostic_text_with_secret(&other.to_string(), raw_secret),
        ),
    }
}

fn sanitize_diagnostic_text_with_secret(raw: &str, raw_secret: &str) -> String {
    let sanitized = sanitize_diagnostic_text(raw);
    redact_exact_secret_values(&sanitized, raw_secret)
}

fn redact_exact_secret_values(raw: &str, raw_secret: &str) -> String {
    let mut redacted = redact_exact_secret_value(raw, raw_secret);
    for part in raw_secret.split(';') {
        let value = part
            .trim()
            .split_once('=')
            .map_or(part.trim(), |(_, value)| value.trim());
        redacted = redact_exact_secret_value(&redacted, value);
    }
    redacted
}

fn redact_exact_secret_value(raw: &str, value: &str) -> String {
    if value.is_empty() {
        raw.to_owned()
    } else {
        raw.replace(value, "<redacted>")
    }
}

const SENSITIVE_DIAGNOSTIC_KEYS: &[&str] = &[
    "access_key",
    "access_token",
    "proxy_token",
    "token",
    "jwt",
    "api_key",
    "x-api-key",
    "authorization",
    "sessdata",
    "bili_jct",
    "cookie",
];

fn redact_urls_in_text(raw: &str) -> String {
    let mut output = String::with_capacity(raw.len());
    let mut index = 0;
    while let Some(relative_start) = find_next_url_start(&raw[index..]) {
        let start = index + relative_start;
        output.push_str(&raw[index..start]);
        let end = raw[start..]
            .find(is_message_url_delimiter)
            .map_or(raw.len(), |relative_end| start + relative_end);
        let token = &raw[start..end];
        let (url_token, trailing) = split_trailing_url_punctuation(token);
        match Url::parse(url_token) {
            Ok(url) => output.push_str(&redact_url_for_diagnostics(&url)),
            Err(_) => output.push_str(&redact_unparsed_url_for_diagnostics(url_token)),
        }
        output.push_str(trailing);
        index = end;
    }
    output.push_str(&raw[index..]);
    output
}

fn find_next_url_start(raw: &str) -> Option<usize> {
    let lower = raw.to_ascii_lowercase();
    match (lower.find("http://"), lower.find("https://")) {
        (Some(http), Some(https)) => Some(http.min(https)),
        (Some(http), None) => Some(http),
        (None, Some(https)) => Some(https),
        (None, None) => None,
    }
}

fn is_message_url_delimiter(character: char) -> bool {
    character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '(' | ')')
}

fn split_trailing_url_punctuation(token: &str) -> (&str, &str) {
    let trimmed_len = token.trim_end_matches([',', '.', ';']).len();
    token.split_at(trimmed_len)
}

fn redact_sensitive_key_values(raw: &str) -> String {
    SENSITIVE_DIAGNOSTIC_KEYS
        .iter()
        .fold(raw.to_owned(), |value, key| {
            redact_sensitive_key_value(&value, key)
        })
}

fn redact_sensitive_key_value(raw: &str, key: &str) -> String {
    let with_equals = redact_sensitive_key_value_with_separator(raw, key, '=');
    redact_sensitive_key_value_with_separator(&with_equals, key, ':')
}

fn redact_sensitive_key_value_with_separator(raw: &str, key: &str, separator: char) -> String {
    let mut output = String::with_capacity(raw.len());
    let mut index = 0;
    let pattern = format!("{key}{separator}");
    let lower = raw.to_ascii_lowercase();
    while let Some(relative_start) = lower[index..].find(&pattern) {
        let start = index + relative_start;
        output.push_str(&raw[index..start]);
        output.push_str("<redacted>");
        let value_start = skip_ascii_whitespace(raw, start + pattern.len());
        let value_end = raw[value_start..]
            .find(|character| is_sensitive_value_delimiter_for_key(key, character))
            .map_or(raw.len(), |relative_end| value_start + relative_end);
        index = value_end;
    }
    output.push_str(&raw[index..]);
    output
}

fn skip_ascii_whitespace(raw: &str, mut index: usize) -> usize {
    while let Some(character) = raw[index..].chars().next() {
        if !character.is_ascii_whitespace() {
            break;
        }
        index += character.len_utf8();
    }
    index
}

fn is_sensitive_value_delimiter_for_key(key: &str, character: char) -> bool {
    if key == "authorization" {
        matches!(
            character,
            '&' | '"' | '\'' | '<' | '>' | '(' | ')' | ',' | ';' | '\r' | '\n'
        )
    } else {
        is_sensitive_value_delimiter(character)
    }
}

fn is_sensitive_value_delimiter(character: char) -> bool {
    character.is_whitespace()
        || matches!(
            character,
            '&' | '"' | '\'' | '<' | '>' | '(' | ')' | ',' | ';' | '\r' | '\n'
        )
}

fn redact_basic_auth_like_string(raw: &str) -> String {
    let Some(scheme_end) = raw.find("//") else {
        return raw.to_owned();
    };
    let after_scheme = &raw[(scheme_end + 2)..];
    let Some(userinfo_end) = after_scheme.find('@') else {
        return raw.to_owned();
    };
    format!(
        "{}//<redacted>@{}",
        &raw[..scheme_end],
        &after_scheme[(userinfo_end + 1)..]
    )
    .trim_end_matches('/')
    .to_owned()
}

fn redact_unparsed_url_for_diagnostics(raw: &str) -> String {
    let basic_auth_redacted = redact_basic_auth_like_string(raw);
    let Some(scheme_end) = basic_auth_redacted.find("//") else {
        return "<invalid-url>".to_owned();
    };
    let prefix = &basic_auth_redacted[..scheme_end];
    let after_scheme = &basic_auth_redacted[(scheme_end + 2)..];
    let authority_end = after_scheme
        .find(|character: char| character.is_whitespace() || matches!(character, '/' | '?' | '#'))
        .unwrap_or(after_scheme.len());
    let authority = &after_scheme[..authority_end];
    let authority = authority
        .rsplit_once('@')
        .map_or(authority, |(_, host)| host);
    if authority.is_empty() {
        "<invalid-url>".to_owned()
    } else {
        format!("{prefix}//{authority}")
            .trim_end_matches('/')
            .to_owned()
    }
}

#[derive(Debug, Deserialize)]
struct PlayUrlRoot {
    code: i64,
    #[serde(default)]
    message: String,
    #[serde(default, deserialize_with = "deserialize_optional_play_payload")]
    data: Option<PlayPayload>,
    #[serde(default, deserialize_with = "deserialize_optional_play_payload")]
    result: Option<PlayPayload>,
    #[serde(flatten)]
    payload: PlayPayload,
}

fn deserialize_optional_play_payload<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<PlayPayload>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let Some(value) = Option::<serde_json::Value>::deserialize(deserializer)? else {
        return Ok(None);
    };
    match value {
        serde_json::Value::Object(_) => serde_json::from_value(value)
            .map(Some)
            .map_err(serde::de::Error::custom),
        _ => Ok(None),
    }
}

impl PlayUrlRoot {
    fn into_stream_set(self) -> Result<StreamSet> {
        if self.code != 0 {
            return Err(Error::Api {
                code: self.code,
                message: self.message,
            });
        }
        let payload = self
            .result
            .or(self.data)
            .or_else(|| self.payload.has_playurl_content().then_some(self.payload))
            .ok_or(Error::MissingField("playurl result"))?;
        payload.into_stream_set()
    }
}

#[derive(Debug, Deserialize)]
struct PlayPayload {
    video_info: Option<Box<PlayPayload>>,
    playurl: Option<IntlPlayUrlPayload>,
    dash: Option<DashPayload>,
    stream_list: Option<Vec<IntlStreamItem>>,
    dash_audio: Option<Vec<IntlMediaResource>>,
    durl: Option<Vec<DurlSegment>>,
    #[serde(default)]
    accept_quality: Vec<u32>,
    #[serde(default)]
    accept_description: Vec<String>,
    #[serde(default)]
    support_formats: Vec<SupportFormat>,
    timelength: Option<u64>,
}

impl PlayPayload {
    fn has_playurl_content(&self) -> bool {
        self.video_info.is_some()
            || self.playurl.is_some()
            || self.dash.is_some()
            || self.stream_list.is_some()
            || self.dash_audio.is_some()
            || self.durl.is_some()
    }

    fn into_stream_set(mut self) -> Result<StreamSet> {
        if let Some(video_info) = self.video_info.take() {
            return video_info.into_stream_set();
        }
        if let Some(playurl) = self.playurl.take() {
            return playurl.into_stream_set(self.timelength);
        }
        let dash_duration = self.dash.as_ref().and_then(|dash| dash.duration);
        let duration_seconds = dash_duration.or_else(|| {
            self.timelength
                .and_then(|value| u32::try_from(value / 1000).ok())
        });
        let mut videos = Vec::new();
        let mut audios = Vec::new();
        if let Some(dash) = self.dash {
            videos.extend(
                dash.video
                    .unwrap_or_default()
                    .into_iter()
                    .filter_map(DashTrack::into_media_stream),
            );
            audios.extend(
                dash.audio
                    .unwrap_or_default()
                    .into_iter()
                    .filter_map(DashTrack::into_media_stream),
            );
            if let Some(dolby) = dash.dolby {
                audios.extend(
                    dolby
                        .audio
                        .unwrap_or_default()
                        .into_iter()
                        .filter_map(DashTrack::into_media_stream),
                );
            }
            if let Some(flac) = dash.flac
                && let Some(audio) = flac.audio
                && let Some(stream) = audio.into_media_stream()
            {
                audios.push(stream);
            }
        }
        videos.extend(
            self.stream_list
                .unwrap_or_default()
                .into_iter()
                .filter_map(IntlStreamItem::into_video_stream),
        );
        audios.extend(
            self.dash_audio
                .unwrap_or_default()
                .into_iter()
                .enumerate()
                .filter_map(|(index, resource)| resource.into_media_stream(fallback_id(index))),
        );
        let flv_segments = self
            .durl
            .unwrap_or_default()
            .into_iter()
            .enumerate()
            .filter_map(|(index, segment)| segment.into_flv_segment(index))
            .collect::<Vec<_>>();
        if videos.is_empty() && audios.is_empty() && flv_segments.is_empty() {
            return Err(Error::MissingField("playurl streams"));
        }
        Ok(StreamSet {
            qualities: stream_qualities(
                &self.accept_quality,
                &self.accept_description,
                &self.support_formats,
                &videos,
            ),
            videos,
            audios,
            flv_segments,
            accept_quality: self.accept_quality,
            duration_seconds,
        })
    }
}

#[derive(Debug, Deserialize)]
struct SupportFormat {
    quality: Option<u32>,
    new_description: Option<String>,
    display_desc: Option<String>,
    description: Option<String>,
}

impl SupportFormat {
    fn label(&self) -> Option<String> {
        first_non_empty([
            self.new_description.clone(),
            self.display_desc.clone(),
            self.description.clone(),
        ])
    }
}

#[derive(Debug, Deserialize)]
struct IntlPlayUrlPayload {
    video: Option<Vec<IntlStreamItem>>,
    audio_resource: Option<Vec<IntlMediaResource>>,
    timelength: Option<u64>,
}

impl IntlPlayUrlPayload {
    fn into_stream_set(self, fallback_timelength: Option<u64>) -> Result<StreamSet> {
        let videos = self
            .video
            .unwrap_or_default()
            .into_iter()
            .filter_map(IntlStreamItem::into_video_stream)
            .collect::<Vec<_>>();
        let audios = self
            .audio_resource
            .unwrap_or_default()
            .into_iter()
            .enumerate()
            .filter_map(|(index, resource)| resource.into_media_stream(fallback_id(index)))
            .collect::<Vec<_>>();
        if videos.is_empty() && audios.is_empty() {
            return Err(Error::MissingField("intl playurl streams"));
        }
        let accept_quality = videos.iter().map(|stream| stream.id).collect::<Vec<_>>();
        let duration_seconds = self
            .timelength
            .or(fallback_timelength)
            .and_then(|value| u32::try_from(value / 1000).ok());
        Ok(StreamSet {
            qualities: stream_qualities(&accept_quality, &[], &[], &videos),
            videos,
            audios,
            flv_segments: Vec::new(),
            accept_quality,
            duration_seconds,
        })
    }
}

#[derive(Debug, Deserialize)]
struct IntlStreamItem {
    stream_info: Option<IntlStreamInfo>,
    video_resource: Option<IntlMediaResource>,
    dash_video: Option<IntlMediaResource>,
}

impl IntlStreamItem {
    fn into_video_stream(self) -> Option<MediaStream> {
        let id = self.stream_info.and_then(|info| info.quality).or_else(|| {
            self.video_resource
                .as_ref()
                .or(self.dash_video.as_ref())
                .and_then(|resource| resource.id)
        })?;
        self.video_resource
            .or(self.dash_video)
            .and_then(|resource| resource.into_media_stream(id))
    }
}

#[derive(Debug, Deserialize)]
struct IntlStreamInfo {
    quality: Option<u32>,
}

#[derive(Debug, Deserialize)]
struct IntlMediaResource {
    id: Option<u32>,
    url: Option<String>,
    base_url: Option<String>,
    #[serde(rename = "baseUrl")]
    base_url_camel: Option<String>,
    backup_url: Option<Vec<String>>,
    #[serde(rename = "backupUrl")]
    backup_url_camel: Option<Vec<String>>,
    codecs: Option<String>,
    bandwidth: Option<u64>,
    width: Option<u32>,
    height: Option<u32>,
    size: Option<u64>,
}

impl IntlMediaResource {
    fn into_media_stream(self, fallback_id: u32) -> Option<MediaStream> {
        let base_url = first_non_empty([self.url, self.base_url, self.base_url_camel])?;
        Some(MediaStream {
            id: self.id.unwrap_or(fallback_id),
            base_url: normalize_media_url(&base_url),
            backup_urls: normalize_media_urls([self.backup_url, self.backup_url_camel]),
            codecs: self.codecs,
            codec_family: None,
            bandwidth: self.bandwidth,
            width: self.width,
            height: self.height,
            frame_rate: None,
            mime_type: None,
            size: self.size,
        })
    }
}

#[derive(Debug, Deserialize)]
struct DashPayload {
    duration: Option<u32>,
    video: Option<Vec<DashTrack>>,
    audio: Option<Vec<DashTrack>>,
    dolby: Option<DolbyPayload>,
    flac: Option<FlacPayload>,
}

#[derive(Debug, Deserialize)]
struct DolbyPayload {
    audio: Option<Vec<DashTrack>>,
}

#[derive(Debug, Deserialize)]
struct FlacPayload {
    audio: Option<DashTrack>,
}

#[derive(Debug, Deserialize)]
struct DashTrack {
    id: Option<u32>,
    base_url: Option<String>,
    #[serde(rename = "baseUrl")]
    base_url_camel: Option<String>,
    backup_url: Option<Vec<String>>,
    #[serde(rename = "backupUrl")]
    backup_url_camel: Option<Vec<String>>,
    codecs: Option<String>,
    bandwidth: Option<u64>,
    width: Option<u32>,
    height: Option<u32>,
    frame_rate: Option<String>,
    #[serde(rename = "frameRate")]
    frame_rate_camel: Option<String>,
    mime_type: Option<String>,
    #[serde(rename = "mimeType")]
    mime_type_camel: Option<String>,
    size: Option<u64>,
}

impl DashTrack {
    fn into_media_stream(self) -> Option<MediaStream> {
        let base_url = first_non_empty([self.base_url, self.base_url_camel])?;
        Some(MediaStream {
            id: self.id?,
            base_url: normalize_media_url(&base_url),
            backup_urls: normalize_media_urls([self.backup_url, self.backup_url_camel]),
            codecs: self.codecs,
            codec_family: None,
            bandwidth: self.bandwidth,
            width: self.width,
            height: self.height,
            frame_rate: first_non_empty([self.frame_rate, self.frame_rate_camel]),
            mime_type: first_non_empty([self.mime_type, self.mime_type_camel]),
            size: self.size,
        })
    }
}

#[derive(Debug, Deserialize)]
struct DurlSegment {
    order: Option<u32>,
    url: Option<String>,
    #[serde(alias = "backupUrl")]
    backup_url: Option<Vec<String>>,
    size: Option<u64>,
    length: Option<u64>,
}

impl DurlSegment {
    fn into_flv_segment(self, index: usize) -> Option<FlvSegment> {
        let url = self.url.filter(|value| !value.is_empty())?;
        Some(FlvSegment {
            order: self
                .order
                .or_else(|| u32::try_from(index + 1).ok())
                .unwrap_or(1),
            url: normalize_media_url(&url),
            backup_urls: normalize_media_urls([self.backup_url]),
            size: self.size,
            length_ms: self.length,
        })
    }
}

#[derive(Debug, Deserialize)]
struct PlayerV2Data {
    subtitle: Option<PlayerSubtitle>,
}

impl PlayerV2Data {
    fn into_subtitles(self) -> Vec<SubtitleTrack> {
        self.subtitle
            .map(PlayerSubtitle::into_subtitles)
            .unwrap_or_default()
    }
}

#[derive(Debug, Deserialize)]
struct PlayerSubtitle {
    #[serde(default)]
    subtitles: Vec<SubtitleData>,
    #[serde(default)]
    list: Vec<SubtitleData>,
}

impl PlayerSubtitle {
    fn into_subtitles(self) -> Vec<SubtitleTrack> {
        self.subtitles
            .into_iter()
            .chain(self.list)
            .filter_map(SubtitleData::into_subtitle)
            .collect()
    }
}

#[derive(Debug, Deserialize)]
struct IntlSubtitleData {
    #[serde(default)]
    subtitles: Vec<SubtitleData>,
}

impl IntlSubtitleData {
    fn into_subtitles(self) -> Vec<SubtitleTrack> {
        self.subtitles
            .into_iter()
            .filter_map(SubtitleData::into_subtitle)
            .collect()
    }
}

#[derive(Debug, Deserialize)]
struct SubtitleData {
    #[serde(alias = "lang_key", alias = "key")]
    lan: Option<String>,
    #[serde(alias = "lang_doc")]
    lan_doc: Option<String>,
    #[serde(alias = "url")]
    subtitle_url: Option<String>,
}

impl SubtitleData {
    fn into_subtitle(self) -> Option<SubtitleTrack> {
        let url = normalize_media_url(self.subtitle_url?.trim());
        if url.is_empty() {
            return None;
        }
        Some(SubtitleTrack {
            language: self.lan.unwrap_or_else(|| "und".to_owned()),
            language_doc: self.lan_doc,
            format: subtitle_format(&url),
            url,
        })
    }
}

fn normalize_media_url(url: &str) -> String {
    if url.starts_with("//") {
        format!("https:{url}")
    } else {
        url.to_owned()
    }
}

fn normalize_optional_media_url(url: Option<&str>) -> Option<String> {
    url.map(str::trim)
        .map(normalize_media_url)
        .filter(|url| !url.is_empty())
}

fn first_non_empty<const N: usize>(values: [Option<String>; N]) -> Option<String> {
    values.into_iter().flatten().find(|value| !value.is_empty())
}

fn stream_qualities(
    accept_quality: &[u32],
    accept_description: &[String],
    support_formats: &[SupportFormat],
    videos: &[MediaStream],
) -> Vec<StreamQuality> {
    let mut qualities = Vec::new();
    for stream in videos {
        push_stream_quality(
            &mut qualities,
            stream.id,
            support_format_label(stream.id, support_formats)
                .or_else(|| accept_quality_label(stream.id, accept_quality, accept_description)),
        );
    }
    qualities
}

fn accept_quality_label(
    id: u32,
    accept_quality: &[u32],
    accept_description: &[String],
) -> Option<String> {
    accept_quality
        .iter()
        .position(|quality| *quality == id)
        .and_then(|index| accept_description.get(index))
        .filter(|value| !value.is_empty())
        .cloned()
}

fn support_format_label(id: u32, support_formats: &[SupportFormat]) -> Option<String> {
    support_formats
        .iter()
        .find(|format| format.quality == Some(id))
        .and_then(SupportFormat::label)
}

fn push_stream_quality(qualities: &mut Vec<StreamQuality>, id: u32, description: Option<String>) {
    if qualities.iter().any(|quality| quality.id == id) {
        return;
    }
    qualities.push(StreamQuality { id, description });
}

fn normalize_media_urls<const N: usize>(url_groups: [Option<Vec<String>>; N]) -> Vec<String> {
    url_groups
        .into_iter()
        .flatten()
        .flatten()
        .filter(|url| !url.is_empty())
        .map(|url| normalize_media_url(&url))
        .collect()
}

fn subtitle_format(url: &str) -> SubtitleFormat {
    let path = Url::parse(url)
        .ok()
        .map_or_else(|| url.to_owned(), |url| url.path().to_owned());
    match std::path::Path::new(&path)
        .extension()
        .and_then(|extension| extension.to_str())
    {
        Some(extension) if extension.eq_ignore_ascii_case("json") => SubtitleFormat::Json,
        Some(extension) if extension.eq_ignore_ascii_case("ass") => SubtitleFormat::Ass,
        _ => SubtitleFormat::Unknown,
    }
}

fn fallback_id(index: usize) -> u32 {
    u32::try_from(index).unwrap_or(u32::MAX)
}

fn current_unix_timestamp() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| duration.as_secs())
}

fn intl_ogv_playurl_params(
    epid: u64,
    cid: u64,
    access_key: Option<&str>,
    timestamp: u64,
) -> Vec<(&'static str, String)> {
    let mut params = Vec::new();
    if let Some(access_key) = access_key.filter(|value| !value.is_empty()) {
        params.push(("access_key", access_key.to_owned()));
    }
    params.extend([
        ("appkey", INTL_OGV_APPKEY.to_owned()),
        ("area", "th".to_owned()),
        ("build", "1001310".to_owned()),
        ("cid", cid.to_string()),
        ("ep_id", epid.to_string()),
        ("fnval", "4048".to_owned()),
        ("fnver", "0".to_owned()),
        ("force_host", "2".to_owned()),
        ("fourk", "1".to_owned()),
        ("mobi_app", "bstar_a".to_owned()),
        ("platform", "android".to_owned()),
        ("qn", "0".to_owned()),
        ("s_locale", "zh_SG".to_owned()),
        ("ts", timestamp.to_string()),
    ]);
    let sign = sign_ordered_params(&params, INTL_OGV_APP_SECRET);
    params.push(("sign", sign));
    params
}

fn oauth2_info_params(
    scope: CredentialHealthScope,
    access_key: &str,
    timestamp: u64,
) -> Result<Vec<(&'static str, String)>> {
    let (appkey, secret, build, mobi_app) = match scope {
        CredentialHealthScope::IntlBstar => {
            (INTL_OGV_APPKEY, INTL_OGV_APP_SECRET, "1001310", "bstar_a")
        }
        CredentialHealthScope::Tv => (
            TV_PLAYURL_APPKEY,
            TV_PLAYURL_APP_SECRET,
            "102801",
            "android_tv_yst",
        ),
        CredentialHealthScope::WebCookie => {
            return Err(Error::InvalidInput(
                "web cookie health cannot use oauth2 info probe".to_owned(),
            ));
        }
    };
    let mut params = vec![
        ("access_key", access_key.to_owned()),
        ("appkey", appkey.to_owned()),
        ("build", build.to_owned()),
        ("mobi_app", mobi_app.to_owned()),
        ("platform", "android".to_owned()),
        ("ts", timestamp.to_string()),
    ];
    let sign = sign_ordered_params(&params, secret);
    params.push(("sign", sign));
    Ok(params)
}

pub(crate) fn sign_ordered_params(params: &[(&str, String)], secret: &str) -> String {
    let mut plaintext = String::new();
    for (index, (key, value)) in params.iter().enumerate() {
        if index > 0 {
            plaintext.push('&');
        }
        plaintext.push_str(key);
        plaintext.push('=');
        plaintext.push_str(value);
    }
    plaintext.push_str(secret);
    format!("{:x}", Md5::digest(plaintext.as_bytes()))
}

#[cfg(test)]
mod tests {
    use super::{
        BiliClient, ClientConfig, EndpointConfig, INTL_OGV_APP_SECRET, INTL_OGV_APPKEY,
        MediaListKind, PlayUrlRoot, PlayurlMode, RestrictedArea, RestrictedAreaConfig,
        RestrictedAreaProxy, TV_PLAYURL_APP_SECRET, TV_PLAYURL_APPKEY, decode_app_grpc_stream_set,
        intl_ogv_playurl_params, oauth2_info_params, sign_ordered_params,
    };
    use crate::{
        CodecFamily, CredentialHealthScope, CredentialHealthStatus, CredentialKind, Credentials,
        EpisodeMetadata, Error, IndexSelection, IndexSelector, Input, PageMetadata,
        ResolvedContent, SeasonMetadata, Selection, StreamSource, SubtitleFormat,
        VideoCollectionItem, VideoCollectionKind, VideoCollectionMetadata, VideoMetadata,
        app_playurl,
    };
    use http_body_util::BodyExt as _;
    use httpmock::MockServer;
    use httpmock::prelude::*;
    use reqwest::header::{HeaderMap, HeaderValue};
    use std::convert::Infallible;
    use std::time::{Duration, Instant};

    #[tokio::test]
    async fn credential_health_reports_missing_credentials() {
        let server = MockServer::start();
        let client = test_client(&server);

        let report = client.check_credential_health().await;

        assert_eq!(
            report.credentials,
            crate::CredentialSource {
                has_cookie: false,
                has_access_key: false,
                has_tv_access_key: false,
            }
        );
        assert_eq!(report.probes.len(), 3);
        assert!(report.probes.iter().all(|probe| {
            probe.status == CredentialHealthStatus::Missing
                && probe.endpoint.is_none()
                && probe.api_code.is_none()
        }));
    }

    #[tokio::test]
    async fn credential_health_checks_configured_credentials_redacted() -> anyhow::Result<()> {
        let server = MockServer::start();
        let cookie_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/nav")
                .header("cookie", "SESSDATA=COOKIE_SECRET");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "isLogin": true,
                    "wbi_img": {
                        "img_url": "https://i0.hdslb.com/bfs/wbi/0123456789abcdef0123456789abcdef.png",
                        "sub_url": "https://i0.hdslb.com/bfs/wbi/fedcba9876543210fedcba9876543210.png"
                    }
                }
            }));
        });
        let access_key_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/passport-login/oauth2/info")
                .query_param("access_key", "ACCESS_SECRET")
                .query_param("appkey", INTL_OGV_APPKEY)
                .query_param("mobi_app", "bstar_a")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"mid": 1}
            }));
        });
        let tv_access_key_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/passport-login/oauth2/info")
                .query_param("access_key", "TV_SECRET")
                .query_param("appkey", TV_PLAYURL_APPKEY)
                .query_param("mobi_app", "android_tv_yst")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -101,
                "message": "TV_SECRET expired"
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials = Credentials::default()
            .with_cookie("SESSDATA=COOKIE_SECRET")
            .with_access_key("ACCESS_SECRET")
            .with_tv_access_key("TV_SECRET");

        let report = client.check_credential_health().await;

        assert_eq!(
            report
                .probes
                .iter()
                .map(|probe| (probe.kind, probe.scope, probe.status))
                .collect::<Vec<_>>(),
            vec![
                (
                    CredentialKind::Cookie,
                    CredentialHealthScope::WebCookie,
                    CredentialHealthStatus::Valid,
                ),
                (
                    CredentialKind::AccessKey,
                    CredentialHealthScope::IntlBstar,
                    CredentialHealthStatus::Valid,
                ),
                (
                    CredentialKind::TvAccessKey,
                    CredentialHealthScope::Tv,
                    CredentialHealthStatus::Rejected,
                ),
            ]
        );
        let tv_probe = report
            .probes
            .iter()
            .find(|probe| probe.kind == CredentialKind::TvAccessKey)
            .ok_or_else(|| anyhow::anyhow!("missing TV access key probe"))?;
        assert_eq!(tv_probe.api_code, Some(-101));
        let serialized = serde_json::to_string(&report)?;
        let debug = format!("{report:?}");
        for secret in ["COOKIE_SECRET", "ACCESS_SECRET", "TV_SECRET"] {
            assert!(!serialized.contains(secret));
            assert!(!debug.contains(secret));
        }
        cookie_mock.assert_calls(1);
        access_key_mock.assert_calls(1);
        tv_access_key_mock.assert_calls(1);
        Ok(())
    }

    #[tokio::test]
    async fn credential_health_cookie_probe_does_not_require_wbi_image() -> anyhow::Result<()> {
        let server = MockServer::start();
        let cookie_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/nav")
                .header("cookie", "SESSDATA=COOKIE_SECRET");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "isLogin": true
                }
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials = Credentials::default().with_cookie("SESSDATA=COOKIE_SECRET");

        let report = client.check_credential_health().await;

        let cookie_probe = report
            .probes
            .iter()
            .find(|probe| probe.kind == CredentialKind::Cookie)
            .ok_or_else(|| anyhow::anyhow!("missing cookie probe"))?;
        assert_eq!(cookie_probe.status, CredentialHealthStatus::Valid);
        assert_eq!(cookie_probe.scope, CredentialHealthScope::WebCookie);
        cookie_mock.assert_calls(1);
        Ok(())
    }

    #[tokio::test]
    async fn credential_health_redacts_short_exact_cookie_values() -> anyhow::Result<()> {
        let server = MockServer::start();
        let cookie_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/nav")
                .header("cookie", "SESSDATA=abc");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -101,
                "message": "abc expired"
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials = Credentials::default().with_cookie("SESSDATA=abc");

        let report = client.check_credential_health().await;

        let cookie_probe = report
            .probes
            .iter()
            .find(|probe| probe.kind == CredentialKind::Cookie)
            .ok_or_else(|| anyhow::anyhow!("missing cookie probe"))?;
        assert_eq!(cookie_probe.status, CredentialHealthStatus::Rejected);
        assert_eq!(cookie_probe.message.as_deref(), Some("<redacted> expired"));
        let serialized = serde_json::to_string(&report)?;
        let debug = format!("{report:?}");
        assert!(!serialized.contains("abc"));
        assert!(!debug.contains("abc"));
        cookie_mock.assert_calls(1);
        Ok(())
    }

    #[test]
    fn credential_health_oauth2_info_params_are_scope_specific_and_signed() -> anyhow::Result<()> {
        let generic =
            oauth2_info_params(CredentialHealthScope::IntlBstar, "ACCESS", 1_700_000_000)?;
        assert_eq!(
            &generic[..6],
            [
                ("access_key", "ACCESS".to_owned()),
                ("appkey", INTL_OGV_APPKEY.to_owned()),
                ("build", "1001310".to_owned()),
                ("mobi_app", "bstar_a".to_owned()),
                ("platform", "android".to_owned()),
                ("ts", "1700000000".to_owned()),
            ]
        );
        assert_eq!(
            generic[6],
            (
                "sign",
                sign_ordered_params(&generic[..6], INTL_OGV_APP_SECRET)
            )
        );

        let tv = oauth2_info_params(CredentialHealthScope::Tv, "TV", 1_700_000_000)?;
        assert_eq!(tv[1], ("appkey", TV_PLAYURL_APPKEY.to_owned()));
        assert_eq!(tv[3], ("mobi_app", "android_tv_yst".to_owned()));
        assert_eq!(
            tv[6],
            ("sign", sign_ordered_params(&tv[..6], TV_PLAYURL_APP_SECRET))
        );
        Ok(())
    }

    #[tokio::test]
    async fn credential_health_checks_raw_stored_access_key_value() -> anyhow::Result<()> {
        let server = MockServer::start();
        let access_key_mock = server.mock(|when, then| {
            when.method(GET)
                .path("/x/passport-login/oauth2/info")
                .query_param("access_key", "ACCESS_SECRET ")
                .query_param("appkey", INTL_OGV_APPKEY)
                .query_param("mobi_app", "bstar_a")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"mid": 1}
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials =
            Credentials::default().with_access_key("ACCESS_SECRET ".to_owned());

        let report = client.check_credential_health().await;

        assert_eq!(report.probes[1].kind, CredentialKind::AccessKey);
        assert_eq!(report.probes[1].scope, CredentialHealthScope::IntlBstar);
        assert_eq!(report.probes[1].status, CredentialHealthStatus::Valid);
        access_key_mock.assert_calls(1);
        Ok(())
    }

    #[tokio::test]
    async fn credential_health_uses_tv_passport_poll_base_for_tv_access_key() -> anyhow::Result<()>
    {
        let passport_server = MockServer::start();
        let tv_passport_poll_server = MockServer::start();
        let tv_access_key_mock = tv_passport_poll_server.mock(|when, then| {
            when.method(GET)
                .path("/x/passport-login/oauth2/info")
                .query_param("access_key", "TV_SECRET")
                .query_param("appkey", TV_PLAYURL_APPKEY)
                .query_param("mobi_app", "android_tv_yst")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"mid": 1}
            }));
        });
        let mut client = test_client(&passport_server);
        client.config.endpoints.tv_passport_base = "http://127.0.0.1:1".to_owned();
        client.config.endpoints.tv_passport_poll_base = tv_passport_poll_server.base_url();
        client.config.credentials = Credentials::default().with_tv_access_key("TV_SECRET");

        let report = client.check_credential_health().await;

        assert_eq!(report.probes[2].kind, CredentialKind::TvAccessKey);
        assert_eq!(report.probes[2].scope, CredentialHealthScope::Tv);
        assert_eq!(report.probes[2].status, CredentialHealthStatus::Valid);
        tv_access_key_mock.assert_calls(1);
        Ok(())
    }

    #[tokio::test]
    async fn resolves_video_metadata_with_tags() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Example video",
                    "desc": "Description",
                    "pic": "https://example.invalid/cover.jpg",
                    "pubdate": 1_700_000_000,
                    "owner": {"mid": 42, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "P1", "duration": 123}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": [{"tag_id": 1, "tag_name": "anime"}]
            }));
        });

        let client = test_client(&server);
        let resolved = client.resolve_input("av170001", None).await?;
        match resolved {
            ResolvedContent::Video(video) => {
                assert_eq!(video.title, "Example video");
                assert_eq!(video.tags[0].name, "anime");
                assert_eq!(video.pages[0].cid, 9988);
            }
            ResolvedContent::Season(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected video"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn video_info_applies_page_selection() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Example video",
                    "pages": [
                        {"page": 1, "cid": 9981, "part": "P1"},
                        {"page": 2, "cid": 9982, "part": "P2"},
                        {"page": 3, "cid": 9983, "part": "P3"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": []
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input("av170001", Some("3,1".parse()?))
            .await?;

        match resolved {
            ResolvedContent::Video(video) => {
                assert_eq!(
                    video
                        .pages
                        .iter()
                        .map(|page| page.index)
                        .collect::<Vec<_>>(),
                    [3, 1]
                );
            }
            ResolvedContent::Season(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected video"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn video_info_rejects_missing_page_selection() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "title": "Example video",
                    "pages": [
                        {"page": 1, "cid": 9981, "part": "P1"},
                        {"page": 2, "cid": 9982, "part": "P2"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": []
            }));
        });

        let client = test_client(&server);
        let Err(error) = client
            .resolve_input("av170001", Some("1,999".parse()?))
            .await
        else {
            return Err(anyhow::anyhow!("missing selected page should fail"));
        };

        assert!(matches!(error, Error::MissingField("selected page")));
        Ok(())
    }

    #[tokio::test]
    async fn video_tag_failure_is_not_silent() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Example video",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -101,
                "message": "login required"
            }));
        });

        let client = test_client(&server);
        let Err(error) = client.resolve_input("av170001", None).await else {
            return Err(anyhow::anyhow!("tag API failure should propagate"));
        };
        assert!(matches!(error, Error::Api { code: -101, .. }));
        Ok(())
    }

    #[tokio::test]
    async fn plan_video_ignores_tag_failure() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Example video",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -101,
                "message": "login required"
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/playurl")
                .query_param("avid", "170001")
                .query_param("cid", "9988")
                .query_param("try_look", "1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "durl": [{"url": "https://video.example/segment.flv"}]
                }
            }));
        });

        let client = test_client(&server);
        let plan = client.plan(Input::Aid(170_001), None).await?;

        assert_eq!(plan.title, "Example video");
        assert_eq!(
            plan.entries[0].streams.flv_segments[0].url,
            "https://video.example/segment.flv"
        );
        Ok(())
    }

    #[test]
    fn medialist_desc_matches_bbdown_parity() {
        assert_eq!(MediaListKind::Collection.desc_value(), "false");
        assert_eq!(MediaListKind::Series.desc_value(), "true");
    }

    #[test]
    fn playurl_accepts_nullable_dash_lists_with_flv_fallback() -> anyhow::Result<()> {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "data": {
                "dash": {
                    "duration": 9,
                    "video": null,
                    "audio": null
                },
                "durl": [{
                    "url": "https://flv.example/segment.flv",
                    "backupUrl": null
                }]
            }
        }))?;

        let streams = response.into_stream_set()?;

        assert!(streams.videos.is_empty());
        assert!(streams.audios.is_empty());
        assert_eq!(
            streams.flv_segments[0].url,
            "https://flv.example/segment.flv"
        );
        Ok(())
    }

    #[test]
    fn playurl_accepts_null_durl_with_dash_streams() -> anyhow::Result<()> {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "data": {
                "dash": {
                    "duration": 9,
                    "video": [{
                        "id": 80,
                        "baseUrl": "https://video.example/80.m4s"
                    }],
                    "audio": [{
                        "id": 30280,
                        "baseUrl": "https://audio.example/30280.m4s"
                    }]
                },
                "durl": null
            }
        }))?;

        let streams = response.into_stream_set()?;

        assert_eq!(streams.videos[0].id, 80);
        assert_eq!(streams.audios[0].id, 30280);
        assert!(streams.flv_segments.is_empty());
        Ok(())
    }

    #[test]
    fn playurl_accepts_top_level_bpplayurl_flv_shape() -> anyhow::Result<()> {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "result": "suee",
            "timelength": 42_000,
            "durl": [{
                "url": "//flv.example/segment.flv",
                "backupUrl": ["//flv-backup.example/segment.flv"],
                "length": 42_000
            }]
        }))?;

        let streams = response.into_stream_set()?;

        assert!(streams.videos.is_empty());
        assert!(streams.audios.is_empty());
        assert_eq!(streams.duration_seconds, Some(42));
        assert_eq!(
            streams.flv_segments[0].url,
            "https://flv.example/segment.flv"
        );
        assert_eq!(
            streams.flv_segments[0].backup_urls[0],
            "https://flv-backup.example/segment.flv"
        );
        Ok(())
    }

    #[test]
    fn playurl_accepts_top_level_mobile_dash_shape() -> anyhow::Result<()> {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "timelength": 123_000,
            "accept_quality": [80],
            "accept_description": ["1080P"],
            "support_formats": [{"quality": 80, "new_description": "1080P 高码率"}],
            "dash": {
                "duration": 123,
                "video": [{
                    "id": 80,
                    "baseUrl": "//video.example/80.m4s",
                    "codecs": "avc1.640028",
                    "width": 1920,
                    "height": 1080
                }],
                "audio": [{
                    "id": 30280,
                    "baseUrl": "//audio.example/30280.m4s",
                    "codecs": "mp4a.40.2"
                }]
            }
        }))?;

        let streams = response.into_stream_set()?;

        assert_eq!(streams.videos[0].id, 80);
        assert_eq!(streams.audios[0].id, 30280);
        assert_eq!(streams.accept_quality, vec![80]);
        assert_eq!(streams.qualities[0].id, 80);
        assert_eq!(
            streams.qualities[0].description.as_deref(),
            Some("1080P 高码率")
        );
        assert_eq!(streams.duration_seconds, Some(123));
        Ok(())
    }

    #[test]
    fn playurl_uses_non_empty_camel_media_fields_when_snake_fields_are_empty() -> anyhow::Result<()>
    {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "data": {
                "dash": {
                    "duration": 9,
                    "video": [{
                        "id": 80,
                        "base_url": "",
                        "baseUrl": "//video.example/80.m4s",
                        "backup_url": [],
                        "backupUrl": ["//backup.example/80.m4s"],
                        "frame_rate": "",
                        "frameRate": "30",
                        "mime_type": "",
                        "mimeType": "video/mp4"
                    }],
                    "audio": [{
                        "id": 30280,
                        "base_url": "",
                        "baseUrl": "//audio.example/30280.m4s"
                    }]
                }
            }
        }))?;

        let streams = response.into_stream_set()?;

        assert_eq!(streams.videos[0].base_url, "https://video.example/80.m4s");
        assert_eq!(
            streams.videos[0].backup_urls,
            vec!["https://backup.example/80.m4s"]
        );
        assert_eq!(streams.videos[0].frame_rate.as_deref(), Some("30"));
        assert_eq!(streams.videos[0].mime_type.as_deref(), Some("video/mp4"));
        Ok(())
    }

    #[test]
    fn playurl_accepts_intl_mobile_video_info_shape() -> anyhow::Result<()> {
        let response: PlayUrlRoot = serde_json::from_value(serde_json::json!({
            "code": 0,
            "data": {
                "video_info": {
                    "timelength": 42_000,
                    "stream_list": [{
                        "stream_info": {"quality": 80},
                        "dash_video": {
                            "base_url": "https://intl.example/video.m4s",
                            "bandwidth": 900,
                            "codecs": "avc1",
                            "width": 1280,
                            "height": 720
                        }
                    }],
                    "dash_audio": [{
                        "id": 30280,
                        "base_url": "https://intl.example/audio.m4s",
                        "bandwidth": 128_000,
                        "codecs": "mp4a.40.2"
                    }]
                }
            }
        }))?;

        let streams = response.into_stream_set()?;

        assert_eq!(streams.duration_seconds, Some(42));
        assert_eq!(streams.videos[0].id, 80);
        assert_eq!(streams.audios[0].id, 30280);
        Ok(())
    }

    #[test]
    fn intl_ogv_playurl_params_are_signed_in_helper_order() {
        let params = intl_ogv_playurl_params(341_736, 70, Some("intl-token"), 1_234_567_890);

        assert_eq!(
            params,
            vec![
                ("access_key", "intl-token".to_owned()),
                ("appkey", "7d089525d3611b1c".to_owned()),
                ("area", "th".to_owned()),
                ("build", "1001310".to_owned()),
                ("cid", "70".to_owned()),
                ("ep_id", "341736".to_owned()),
                ("fnval", "4048".to_owned()),
                ("fnver", "0".to_owned()),
                ("force_host", "2".to_owned()),
                ("fourk", "1".to_owned()),
                ("mobi_app", "bstar_a".to_owned()),
                ("platform", "android".to_owned()),
                ("qn", "0".to_owned()),
                ("s_locale", "zh_SG".to_owned()),
                ("ts", "1234567890".to_owned()),
                ("sign", "8b320aef0eac5b957c3cab1f98bb9b6d".to_owned()),
            ]
        );
    }

    #[tokio::test]
    #[allow(clippy::too_many_lines)]
    async fn plans_video_download_with_streams_subtitles_and_danmaku() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Example video",
                    "pic": "//cover.example/video.jpg",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": []
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/playurl")
                .query_param("avid", "170001")
                .query_param("cid", "9988")
                .query_param("try_look", "1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "timelength": 123_000,
                    "accept_quality": [80, 64],
                    "accept_description": ["1080P", "720P"],
                    "support_formats": [
                        {"quality": 80, "new_description": "1080P 高码率"},
                        {"quality": 64, "display_desc": "720P"}
                    ],
                    "dash": {
                        "duration": 123,
                        "video": [{
                            "id": 80,
                            "baseUrl": "//video.example/80.m4s",
                            "base_url": "//video.example/80.m4s",
                            "backupUrl": ["//backup.example/80.m4s"],
                            "backup_url": ["//backup.example/80.m4s"],
                            "codecs": "avc1.640028",
                            "bandwidth": 1000,
                            "width": 1920,
                            "height": 1080,
                            "frameRate": "30",
                            "frame_rate": "30"
                        }],
                        "audio": [{
                            "id": 30280,
                            "baseUrl": "//audio.example/30280.m4s",
                            "base_url": "//audio.example/30280.m4s",
                            "codecs": "mp4a.40.2",
                            "bandwidth": 128_000
                        }],
                        "dolby": {"audio": null}
                    },
                    "durl": [{
                        "url": "//flv.example/segment.flv",
                        "backup_url": ["//flv-backup.example/segment.flv"],
                        "length": 123_000
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "subtitle": {
                        "subtitles": [{
                            "lan": "zh-CN",
                            "lan_doc": "中文（简体）",
                            "subtitle_url": "//subtitle.example/zh.json"
                        }]
                    }
                }
            }));
        });

        let client = test_client(&server);
        let plan = client.plan_download("av170001", None).await?;

        assert_eq!(plan.title, "Example video");
        assert_eq!(plan.entries.len(), 1);
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::NormalWeb);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://video.example/80.m4s"
        );
        assert_eq!(
            entry.cover_url.as_deref(),
            Some("https://cover.example/video.jpg")
        );
        assert_eq!(
            entry.streams.videos[0].backup_urls[0],
            "https://backup.example/80.m4s"
        );
        assert_eq!(entry.streams.audios[0].id, 30280);
        assert_eq!(
            entry.streams.audios[0].base_url,
            "https://audio.example/30280.m4s"
        );
        assert_eq!(entry.streams.flv_segments[0].order, 1);
        assert_eq!(
            entry.streams.flv_segments[0].url,
            "https://flv.example/segment.flv"
        );
        assert_eq!(
            entry.streams.flv_segments[0].backup_urls[0],
            "https://flv-backup.example/segment.flv"
        );
        assert_eq!(entry.streams.duration_seconds, Some(123));
        assert_eq!(entry.streams.accept_quality, vec![80, 64]);
        assert_eq!(entry.streams.qualities.len(), 1);
        assert_eq!(entry.streams.qualities[0].id, 80);
        assert_eq!(
            entry.streams.qualities[0].description.as_deref(),
            Some("1080P 高码率")
        );
        assert_eq!(entry.subtitles[0].url, "https://subtitle.example/zh.json");
        assert_eq!(
            entry.danmaku.xml_url,
            format!("{}/9988.xml", server.base_url())
        );
        Ok(())
    }

    #[tokio::test]
    async fn plans_video_download_with_tv_playurl_mode() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "TV video",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        let tv_playurl = server.mock(|when, then| {
            when.method(GET)
                .path("/x/tv/playurl")
                .query_param("access_key", "TV_ACCESS")
                .query_param("appkey", TV_PLAYURL_APPKEY)
                .query_param("cid", "9988")
                .query_param("mobi_app", "android_tv_yst")
                .query_param("object_id", "170001")
                .query_param("platform", "android")
                .query_param("playurl_type", "1")
                .query_param("qn", "0")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 123,
                        "video": [{
                            "id": 80,
                            "base_url": "https://tv.example/80.m4s",
                            "codecs": "avc1.640028",
                            "bandwidth": 1_000_000,
                            "mime_type": "video/mp4"
                        }],
                        "audio": []
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_comment_base(server.base_url())
                        .with_tv_api_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_cookie("SESSDATA=WEB_COOKIE")
                        .with_tv_access_key("TV_ACCESS"),
                )
                .with_playurl_mode(PlayurlMode::Tv),
        );
        let plan = client.plan_download("av170001", None).await?;

        tv_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::NormalTv);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://tv.example/80.m4s"
        );
        Ok(())
    }

    #[tokio::test]
    async fn plans_video_download_with_app_playurl_mode() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "APP video",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        let app_response =
            app_playurl::test_play_view_response_frame("https://app.example/80.m4s")?;
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::NORMAL_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 TV_ACCESS")
                .header_exists("x-bili-metadata-bin")
                .header_exists("x-bili-device-bin")
                .header_exists("x-bili-network-bin")
                .header_missing("cookie");
            then.status(200).body(app_response.clone());
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_comment_base(server.base_url())
                        .with_app_grpc_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_cookie("SESSDATA=WEB_COOKIE")
                        .with_tv_access_key("TV_ACCESS"),
                )
                .with_playurl_mode(PlayurlMode::App),
        );
        let plan = client.plan_download("av170001", None).await?;

        app_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::NormalApp);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://app.example/80.m4s"
        );
        assert_eq!(entry.streams.videos[0].codecs, None);
        assert_eq!(
            entry.streams.videos[0].codec_family,
            Some(CodecFamily::Hevc)
        );
        assert_eq!(entry.streams.audios[0].codecs.as_deref(), Some("mp4a.40.2"));
        assert_eq!(entry.streams.flv_segments[0].order, 1);
        Ok(())
    }

    #[tokio::test]
    async fn app_grpc_status_error_is_reported() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "APP video",
                    "pages": [{"page": 1, "cid": 9988, "part": "P1"}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::NORMAL_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 APP_ACCESS")
                .header_missing("cookie");
            then.status(200)
                .header("grpc-status", "7")
                .header("grpc-message", "area%20restricted");
        });

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_app_grpc_base(server.base_url()),
                )
                .with_credentials(Credentials::default().with_access_key("APP_ACCESS"))
                .with_playurl_mode(PlayurlMode::App),
        );
        let error = client
            .plan_download("av170001", None)
            .await
            .err()
            .ok_or_else(|| anyhow::anyhow!("APP gRPC status error should fail"))?;

        assert!(matches!(
            error,
            Error::Api { code: 7, message } if message == "area restricted"
        ));
        Ok(())
    }

    #[tokio::test]
    async fn app_grpc_trailing_status_error_is_reported() -> anyhow::Result<()> {
        let mut trailers = HeaderMap::new();
        trailers.insert("grpc-status", HeaderValue::from_static("7"));
        trailers.insert(
            "grpc-message",
            HeaderValue::from_static("area%20restricted"),
        );
        let body = http_body_util::Empty::<bytes::Bytes>::new()
            .with_trailers(std::future::ready(Some(Ok::<_, Infallible>(trailers))));
        let response = http::Response::builder()
            .status(200)
            .body(reqwest::Body::wrap(body))?;
        let response: reqwest::Response = response.into();
        let response = BiliClient::collect_app_grpc_response(response).await?;
        let error = decode_app_grpc_stream_set(&response, false)
            .err()
            .ok_or_else(|| anyhow::anyhow!("APP trailing gRPC status error should fail"))?;

        assert!(matches!(
            error,
            Error::Api { code: 7, message } if message == "area restricted"
        ));
        Ok(())
    }

    #[tokio::test]
    async fn plans_cheese_download_with_pugv_source() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("ep_id", "101");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "subtitle": "Course subtitle",
                    "cover": "https://example.invalid/course.jpg",
                    "up_info": {"mid": 1, "uname": "Teacher"},
                    "episodes": [{
                        "id": 101,
                        "aid": 170_001,
                        "cid": 9988,
                        "index": 1,
                        "title": "Lesson",
                        "duration": 12,
                        "release_date": 1_700_000_000
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/player/web/playurl")
                .query_param("avid", "170001")
                .query_param("cid", "9988")
                .query_param("ep_id", "101");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 12,
                        "video": [{
                            "id": 80,
                            "baseUrl": "https://video.example/course.m4s",
                            "base_url": "https://video.example/course.m4s"
                        }],
                        "audio": [{
                            "id": 30280,
                            "baseUrl": "https://audio.example/course.m4s",
                            "base_url": "https://audio.example/course.m4s"
                        }]
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = test_client(&server);
        let plan = client.plan_download("cheese/ep101", None).await?;

        assert_eq!(plan.title, "Course");
        assert_eq!(plan.entries[0].source, StreamSource::PugvWeb);
        assert_eq!(plan.entries[0].epid, Some(101));
        assert_eq!(plan.entries[0].streams.videos[0].id, 80);
        Ok(())
    }

    #[tokio::test]
    async fn cheese_episode_current_uses_global_index_from_loaded_order() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("ep_id", "102");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "episode_page": {"next": false, "num": 2, "size": 1, "total": 2},
                    "episodes": [{
                        "id": 102,
                        "aid": 170_002,
                        "cid": 9989,
                        "index": 1,
                        "page": 2,
                        "title": "Second lesson"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("season_id", "202")
                .query_param_missing("pn");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "episode_page": {"next": true, "num": 1, "size": 1, "total": 2},
                    "episodes": [{
                        "id": 101,
                        "aid": 170_001,
                        "cid": 9988,
                        "index": 1,
                        "title": "First lesson"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/ep/list")
                .query_param("season_id", "202")
                .query_param("pn", "2")
                .query_param("ps", "1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "page": {"next": false, "num": 2, "size": 1, "total": 2},
                    "items": [{
                        "id": 102,
                        "aid": 170_002,
                        "cid": 9989,
                        "index": 1,
                        "page": 2,
                        "title": "Second lesson"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/player/web/playurl")
                .query_param("avid", "170002")
                .query_param("cid", "9989")
                .query_param("ep_id", "102");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 12,
                        "video": [{
                            "id": 80,
                            "baseUrl": "https://video.example/course.m4s",
                            "base_url": "https://video.example/course.m4s"
                        }],
                        "audio": [{
                            "id": 30280,
                            "baseUrl": "https://audio.example/course.m4s",
                            "base_url": "https://audio.example/course.m4s"
                        }]
                    }
                }
            }));
        });
        server_mock_player_v2(&server, 170_002, 9989);

        let plan = test_client(&server)
            .plan_download("cheese/ep102", None)
            .await?;

        assert_eq!(plan.entries[0].index, 2);
        assert_eq!(plan.entries[0].epid, Some(102));
        Ok(())
    }

    #[tokio::test]
    async fn resolves_paginated_cheese_season_latest() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("season_id", "202")
                .query_param_missing("pn");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "subtitle": "Course subtitle",
                    "cover": "https://example.invalid/course.jpg",
                    "up_info": {"mid": 1, "uname": "Teacher"},
                    "episode_page": {"next": true, "num": 1, "size": 1, "total": 2},
                    "episodes": [{
                        "id": 101,
                        "aid": 170_001,
                        "cid": 9988,
                        "index": 1,
                        "title": "First lesson",
                        "release_date": 1_700_000_000
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/ep/list")
                .query_param("season_id", "202")
                .query_param("pn", "2")
                .query_param("ps", "1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "page": {"next": false, "num": 2, "size": 1, "total": 2},
                    "items": [{
                        "id": 102,
                        "aid": 170_002,
                        "cid": 9989,
                        "index": 1,
                        "page": 2,
                        "title": "Second lesson",
                        "release_date": 1_700_000_001
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("cheese/ss202", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.episodes.len(), 2);
                assert_eq!(season.selected_episodes[0].epid, 102);
                assert_eq!(season.selected_episodes[0].index, 2);
                assert_eq!(
                    season.selected_episodes[0].long_title.as_deref(),
                    Some("Second lesson")
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn pugv_pagination_uses_stable_page_size_when_size_is_missing() -> anyhow::Result<()> {
        let server = MockServer::start();
        for (page_number, episode_id, title, has_next) in [
            (1_u32, 101_u64, "First lesson", true),
            (2, 102, "Second lesson", true),
            (3, 103, "Third lesson", false),
        ] {
            server.mock(|when, then| {
                if page_number == 1 {
                    when.method(GET)
                        .path("/pugv/view/web/season")
                        .query_param("season_id", "202")
                        .query_param_missing("pn");
                    then.status(200).json_body_obj(&serde_json::json!({
                        "code": 0,
                        "data": {
                            "season_id": 202,
                            "title": "Course",
                            "episode_page": {"next": has_next, "num": page_number, "total": 3},
                            "episodes": [{
                                "id": episode_id,
                                "aid": 170_000 + episode_id,
                                "cid": 9_000 + episode_id,
                                "index": 1,
                                "page": page_number,
                                "title": title
                            }]
                        }
                    }));
                } else {
                    when.method(GET)
                        .path("/pugv/view/web/ep/list")
                        .query_param("season_id", "202")
                        .query_param("pn", page_number.to_string())
                        .query_param("ps", "1");
                    then.status(200).json_body_obj(&serde_json::json!({
                        "code": 0,
                        "data": {
                            "page": {"next": has_next, "num": page_number, "total": 3},
                            "items": [{
                                "id": episode_id,
                                "aid": 170_000 + episode_id,
                                "cid": 9_000 + episode_id,
                                "index": page_number,
                                "title": title
                            }]
                        }
                    }));
                }
            });
        }

        let resolved = test_client(&server)
            .resolve_input("cheese/ss202", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.episodes.len(), 3);
                assert_eq!(season.selected_episodes[0].epid, 103);
                assert_eq!(season.selected_episodes[0].index, 3);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn cheese_episode_non_current_selection_refetches_from_first_page() -> anyhow::Result<()>
    {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("ep_id", "102");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "episode_page": {"next": false, "num": 2, "size": 1, "total": 2},
                    "episodes": [{
                        "id": 102,
                        "aid": 170_002,
                        "cid": 9989,
                        "index": 1,
                        "page": 2,
                        "title": "Second lesson"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/season")
                .query_param("season_id", "202")
                .query_param_missing("pn");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "season_id": 202,
                    "title": "Course",
                    "episode_page": {"next": true, "num": 1, "size": 1, "total": 2},
                    "episodes": [{
                        "id": 101,
                        "aid": 170_001,
                        "cid": 9988,
                        "index": 1,
                        "title": "First lesson"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pugv/view/web/ep/list")
                .query_param("season_id", "202")
                .query_param("pn", "2")
                .query_param("ps", "1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "page": {"next": false, "num": 2, "size": 1, "total": 2},
                    "items": [{
                        "id": 102,
                        "aid": 170_002,
                        "cid": 9989,
                        "index": 1,
                        "page": 2,
                        "title": "Second lesson"
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("cheese/ep102", Some(Selection::Page(1)))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.episodes.len(), 2);
                assert_eq!(season.selected_episodes[0].epid, 101);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_favorite_collection_and_plans_selected_item() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_favorite_collection(&server);
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/playurl")
                .query_param("avid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 3,
                        "video": [{
                            "id": 80,
                            "baseUrl": "https://video.example/fav.m4s",
                            "base_url": "https://video.example/fav.m4s"
                        }],
                        "audio": [{
                            "id": 30280,
                            "baseUrl": "https://audio.example/fav.m4s",
                            "base_url": "https://audio.example/fav.m4s"
                        }]
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "170001")
                .query_param("cid", "9988");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = test_client(&server);
        let resolved = client.resolve_input("fav456", None).await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.title, "Favorite");
                assert_eq!(
                    collection.collection.kind,
                    crate::VideoCollectionKind::Favorite
                );
                assert_eq!(collection.selected_items[0].title, "Saved video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }

        let plan = client
            .plan_download("fav456", Some(Selection::Page(1)))
            .await?;
        assert_eq!(plan.title, "Favorite");
        assert_eq!(plan.entries[0].title, "Saved video");
        assert_eq!(plan.entries[0].streams.audios[0].id, 30280);
        Ok(())
    }

    #[tokio::test]
    async fn resolves_favorite_item_without_first_cid_from_video_metadata() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v3/fav/resource/list")
                .query_param("media_id", "456")
                .query_param("pn", "1")
                .query_param("ps", "20")
                .query_param("type", "0");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "info": {
                        "media_count": 1,
                        "title": "Favorite",
                        "upper": {"mid": 1, "name": "Tester"}
                    },
                    "medias": [{
                        "id": 170_001,
                        "type": 2,
                        "bvid": "BV1xx411c7mD",
                        "title": "Saved video",
                        "attr": 0,
                        "page": 1
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Saved video",
                    "owner": {"mid": 1, "name": "Tester"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Saved video"}]
                }
            }));
        });

        let resolved = test_client(&server).resolve_input("fav456", None).await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].cid, 9988);
                assert_eq!(collection.selected_items[0].title, "Saved video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn favorite_collection_accepts_null_media_list_as_empty() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v3/fav/resource/list")
                .query_param("media_id", "456")
                .query_param("pn", "1")
                .query_param("ps", "20")
                .query_param("type", "0");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "info": {
                        "media_count": 0,
                        "title": "Empty Favorite",
                        "upper": {"mid": 1, "name": "Tester"}
                    },
                    "medias": null
                }
            }));
        });

        let resolved = test_client(&server).resolve_input("fav456", None).await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.title, "Empty Favorite");
                assert!(collection.collection.items.is_empty());
                assert!(collection.selected_items.is_empty());
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn favorite_collection_skips_non_video_entries() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v3/fav/resource/list")
                .query_param("media_id", "456")
                .query_param("pn", "1")
                .query_param("ps", "20")
                .query_param("type", "0");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "info": {
                        "media_count": 2,
                        "title": "Favorite",
                        "upper": {"mid": 1, "name": "Tester"}
                    },
                    "medias": [{
                        "id": 990_001,
                        "type": 12,
                        "title": "Audio entry",
                        "attr": 0
                    }, {
                        "id": 170_001,
                        "type": 2,
                        "bvid": "BV1xx411c7mD",
                        "title": "Saved video",
                        "attr": 0,
                        "page": 1,
                        "ugc": {"first_cid": 9988}
                    }]
                }
            }));
        });

        let resolved = test_client(&server).resolve_input("fav456", None).await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].aid, 170_001);
                assert_eq!(collection.selected_items[0].title, "Saved video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_history_archive_items_from_cursor() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_history_page(
            &server,
            0,
            0,
            "",
            &serde_json::json!({
                "max": 170_002,
                "view_at": 1_700_000_000_i64,
                "business": "archive"
            }),
            &[
                serde_json::json!({
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "History video",
                    "pic": "https://example.invalid/history.jpg",
                    "desc": "History description",
                    "pubdate": 1_699_999_999_i64,
                    "duration": 3,
                    "videos": 2,
                    "author_mid": 1,
                    "author_name": "Tester",
                    "history": {
                        "oid": 170_001,
                        "cid": 9988,
                        "page": 2,
                        "part": "Second",
                        "business": "archive"
                    }
                }),
                serde_json::json!({
                    "aid": 170_003,
                    "bvid": "BV1xx411c7mE",
                    "title": "Multi-page history",
                    "duration": 4,
                    "videos": 2,
                    "history": {
                        "oid": 170_003,
                        "cid": 9990,
                        "page": 1,
                        "part": "First",
                        "business": "archive"
                    }
                }),
                serde_json::json!({
                    "title": "Skipped PGC record",
                    "history": {
                        "oid": 170_002,
                        "cid": 9989,
                        "business": "pgc"
                    }
                }),
            ],
        );
        mock_history_page(
            &server,
            170_002,
            1_700_000_000,
            "archive",
            &serde_json::json!({
                "max": 170_002,
                "view_at": 1_700_000_000_i64,
                "business": "archive"
            }),
            &[],
        );

        let resolved = test_client(&server).resolve_input("history", None).await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::History);
                assert_eq!(collection.collection.title, "History");
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.selected_items.len(), 2);
                let item = &collection.selected_items[0];
                assert_eq!(item.aid, 170_001);
                assert_eq!(item.cid, 9988);
                assert_eq!(item.title, "History video_P2_Second");
                assert_eq!(item.owner.as_ref().map(|owner| owner.mid), Some(1));
                assert_eq!(
                    collection.selected_items[1].title,
                    "Multi-page history_P1_First"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn plans_history_latest_as_normal_video_entry() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_history_page(
            &server,
            0,
            0,
            "",
            &serde_json::json!({
                "max": 170_002,
                "view_at": 1_700_000_000_i64,
                "business": "archive"
            }),
            &[serde_json::json!({
                "aid": 170_001,
                "bvid": "BV1xx411c7mD",
                "title": "History video",
                "duration": 3,
                "history": {
                    "oid": 170_001,
                    "cid": 9988,
                    "page": 1,
                    "business": "archive"
                }
            })],
        );
        server_mock_playurl(&server, 170_001, 9988, "history");
        server_mock_player_v2(&server, 170_001, 9988);

        let plan = test_client(&server)
            .plan_download("history", Some(Selection::Latest))
            .await?;

        assert_eq!(plan.title, "History");
        assert_eq!(plan.entries.len(), 1);
        assert_eq!(plan.entries[0].aid, 170_001);
        assert_eq!(plan.entries[0].cid, 9988);
        assert_eq!(plan.entries[0].source, StreamSource::NormalWeb);
        Ok(())
    }

    #[tokio::test]
    async fn resolves_watch_later_items() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/history/toview")
                .header("cookie", "SESSDATA=WEB_COOKIE");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 3,
                    "list": [{
                        "aid": "170001",
                        "bvid": "BV1xx411c7mD",
                        "cid": 1111,
                        "title": "Watch later video",
                        "desc": "Watch later description",
                        "pic": "https://example.invalid/watch-later.jpg",
                        "pubdate": 1_699_999_999_i64,
                        "duration": 999,
                        "page": {
                            "cid": "9988",
                            "page": 2,
                            "part": "Second",
                            "duration": 3
                        },
                        "videos": 2,
                        "owner": {"mid": 1, "name": "Tester"}
                    }, {
                        "aid": 170_002,
                        "bvid": "BV1xx411c7mE",
                        "cid": 9989,
                        "title": "Second watch later video",
                        "duration": 4,
                        "page": 1,
                        "videos": 1,
                        "owner": {"mid": 2, "name": "Uploader"}
                    }, {
                        "title": "Skipped missing aid"
                    }]
                }
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials = Credentials::default().with_cookie("SESSDATA=WEB_COOKIE");

        let resolved = client
            .resolve_input("watchlater", Some(Selection::Page(2)))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::WatchLater);
                assert_eq!(collection.collection.title, "Watch later");
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(
                    collection.collection.items[0].title,
                    "Watch later video_P2_Second"
                );
                assert_eq!(collection.collection.items[0].duration_seconds, Some(3));
                assert_eq!(collection.collection.items[0].cid, 9988);
                assert_eq!(
                    collection.selected_items[0].title,
                    "Second watch later video"
                );
                assert_eq!(collection.selected_items[0].cid, 9989);
                assert_eq!(
                    collection.selected_items[0]
                        .owner
                        .as_ref()
                        .map(|owner| owner.name.as_str()),
                    Some("Uploader")
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_watch_later_item_cid_from_video_metadata() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/v2/history/toview");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 1,
                    "list": [{
                        "aid": 170_003,
                        "bvid": "BV1xx411c7mF",
                        "cid": 0,
                        "title": "Fallback watch later video",
                        "duration": 999,
                        "page": {"page": 1, "part": "Fallback page"},
                        "videos": 1
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("bvid", "BV1xx411c7mF");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_003,
                    "bvid": "BV1xx411c7mF",
                    "title": "Fallback detail title",
                    "pages": [{
                        "page": 1,
                        "cid": 9990,
                        "part": "Fallback detail page",
                        "duration": 5
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("watchlater", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.collection.items[0].cid, 9990);
                assert_eq!(collection.collection.items[0].duration_seconds, Some(5));
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn skips_watch_later_item_when_cid_fallback_fails() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/v2/history/toview");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 2,
                    "list": [{
                        "aid": 170_003,
                        "bvid": "BV1xx411c7mF",
                        "cid": 0,
                        "title": "Deleted watch later video"
                    }, {
                        "aid": 170_004,
                        "bvid": "BV1xx411c7mG",
                        "cid": 9991,
                        "title": "Usable watch later video"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("bvid", "BV1xx411c7mF");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -404,
                "message": "not found"
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("watchlater", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.collection.items[0].cid, 9991);
                assert_eq!(
                    collection.collection.items[0].title,
                    "Usable watch later video"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn skips_watch_later_item_when_fallback_page_is_missing() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/v2/history/toview");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 2,
                    "list": [{
                        "aid": 170_003,
                        "bvid": "BV1xx411c7mF",
                        "cid": 0,
                        "title": "Missing page watch later video",
                        "page": {"page": 2, "part": "Missing page"}
                    }, {
                        "aid": 170_004,
                        "bvid": "BV1xx411c7mG",
                        "cid": 9991,
                        "title": "Usable watch later video"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("bvid", "BV1xx411c7mF");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_003,
                    "bvid": "BV1xx411c7mF",
                    "title": "Fallback detail title",
                    "pages": [{"page": 1, "cid": 9990, "part": "Only page"}]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("watchlater", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.collection.items[0].cid, 9991);
                assert_eq!(
                    collection.collection.items[0].title,
                    "Usable watch later video"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn propagates_watch_later_item_fallback_auth_error() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/v2/history/toview");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 1,
                    "list": [{
                        "aid": 170_003,
                        "bvid": "BV1xx411c7mF",
                        "cid": 0,
                        "title": "Auth-sensitive watch later video"
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("bvid", "BV1xx411c7mF");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -101,
                "message": "login required"
            }));
        });

        let Err(error) = test_client(&server).resolve_input("watchlater", None).await else {
            return Err(anyhow::anyhow!("fallback auth error should propagate"));
        };
        assert!(matches!(error, Error::Api { code: -101, .. }));
        Ok(())
    }

    #[tokio::test]
    async fn plans_watch_later_latest_as_normal_video_entry() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/v2/history/toview");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "count": 1,
                    "list": [{
                        "aid": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "cid": 9988,
                        "title": "Watch later video",
                        "duration": 3
                    }]
                }
            }));
        });
        server_mock_playurl(&server, 170_001, 9988, "watch-later");
        server_mock_player_v2(&server, 170_001, 9988);

        let plan = test_client(&server)
            .plan_download("watch-later", Some(Selection::Latest))
            .await?;

        assert_eq!(plan.title, "Watch later");
        assert_eq!(plan.entries.len(), 1);
        assert_eq!(plan.entries[0].aid, 170_001);
        assert_eq!(plan.entries[0].cid, 9988);
        assert_eq!(plan.entries[0].source, StreamSource::NormalWeb);
        Ok(())
    }

    #[tokio::test]
    async fn resolves_following_feed_archive_items() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/polymer/web-dynamic/v1/feed/all")
                .query_param("type", "video")
                .query_param("platform", "web")
                .header("cookie", "SESSDATA=WEB_COOKIE");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": false,
                    "offset": "offset-1",
                    "items": [{
                        "type": "DYNAMIC_TYPE_AV",
                        "visible": true,
                        "modules": {
                            "module_author": {
                                "mid": 1,
                                "name": "Tester",
                                "pub_ts": 1_700_000_001_i64
                            },
                            "module_dynamic": {
                                "major": {
                                    "type": "MAJOR_TYPE_ARCHIVE",
                                    "archive": {
                                        "aid": "170001",
                                        "bvid": "BV1xx411c7mD",
                                        "cover": "https://example.invalid/dynamic.jpg"
                                    }
                                }
                            }
                        }
                    }, {
                        "type": "DYNAMIC_TYPE_ARTICLE",
                        "visible": true,
                        "modules": {
                            "module_dynamic": {
                                "major": {"type": "MAJOR_TYPE_ARTICLE"}
                            }
                        }
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Following video",
                    "desc": "Following description",
                    "owner": {"mid": 1, "name": "Tester"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Main", "duration": 3}]
                }
            }));
        });
        let mut client = test_client(&server);
        client.config.credentials = Credentials::default().with_cookie("SESSDATA=WEB_COOKIE");

        let resolved = client.resolve_input("following", None).await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::Following);
                assert_eq!(collection.collection.title, "Following videos");
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].title, "Following video");
                let owner = collection.selected_items[0]
                    .owner
                    .as_ref()
                    .ok_or_else(|| anyhow::anyhow!("expected owner"))?;
                assert_eq!(owner.name, "Tester");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_recommendation_feed_items() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "20")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": [{
                        "goto": "av",
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "cid": 9988,
                        "pic": "https://example.invalid/recommendation.jpg",
                        "title": "Recommended video",
                        "duration": 3,
                        "pubdate": 1_700_000_001_i64,
                        "owner": {"mid": 1, "name": "Tester"}
                    }, {
                        "goto": "live",
                        "id": 170_002,
                        "title": "Skipped live recommendation"
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("recommendations", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(
                    collection.collection.kind,
                    VideoCollectionKind::Recommendation
                );
                assert_eq!(collection.collection.title, "Recommendations");
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].title, "Recommended video");
                assert_eq!(collection.selected_items[0].cid, 9988);
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_recommendation_page_selection_after_filtered_cards() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            let mut items = vec![serde_json::json!({
                "goto": "live",
                "id": 170_000,
                "title": "Skipped live recommendation"
            })];
            items.extend((1..=21).map(|index| {
                serde_json::json!({
                    "goto": "av",
                    "id": 170_000 + index,
                    "bvid": format!("BV1xx411c{index:03}"),
                    "cid": 9900 + index,
                    "title": format!("Recommended video {index}")
                })
            }));
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "30")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": items
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("recommendations", Some(Selection::Page(21)))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 21);
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(collection.selected_items[0].index, 21);
                assert_eq!(collection.selected_items[0].title, "Recommended video 21");
                assert_eq!(collection.selected_items[0].cid, 9921);
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_recommendation_page_selection_across_refresh_batches() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            let items: Vec<_> = (1..=30)
                .map(|index| {
                    serde_json::json!({
                        "goto": "av",
                        "id": 170_000 + index,
                        "bvid": format!("BV1xx411c{index:03}"),
                        "cid": 9900 + index,
                        "title": format!("Recommended video {index}")
                    })
                })
                .collect();
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "30")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": items
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "30")
                .query_param("fresh_idx", "2")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": [{
                        "goto": "av",
                        "id": 170_031,
                        "bvid": "BV1xx411c031",
                        "cid": 9931,
                        "title": "Recommended video 31"
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("recommendations", Some(Selection::Page(31)))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 31);
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(collection.selected_items[0].index, 31);
                assert_eq!(collection.selected_items[0].title, "Recommended video 31");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_recommendation_latest_keeps_batch_metadata() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "20")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": [{
                        "goto": "av",
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "cid": 9988,
                        "title": "First recommended video"
                    }, {
                        "goto": "av",
                        "id": 170_002,
                        "bvid": "BV1xx411c7mE",
                        "cid": 9989,
                        "title": "Second recommended video"
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("recommendations", Some(Selection::Latest))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(
                    collection.selected_items[0].title,
                    "First recommended video"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_recommendation_latest_retries_after_filtered_batch() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            let items: Vec<_> = (1..=20)
                .map(|index| {
                    serde_json::json!({
                        "goto": "live",
                        "id": 170_000 + index,
                        "title": format!("Skipped live recommendation {index}")
                    })
                })
                .collect();
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "20")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": items
                }
            }));
        });
        server.mock(|when, then| {
            let mut items: Vec<_> = (1..=20)
                .map(|index| {
                    serde_json::json!({
                        "goto": "live",
                        "id": 170_000 + index,
                        "title": format!("Skipped live recommendation {index}")
                    })
                })
                .collect();
            items.push(serde_json::json!({
                "goto": "av",
                "id": 170_021,
                "bvid": "BV1xx411c7mF",
                "cid": 9921,
                "title": "Retried recommended video"
            }));
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "30")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": items
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("recommendations", Some(Selection::Latest))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(
                    collection.selected_items[0].title,
                    "Retried recommended video"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn plans_recommendation_latest_as_normal_video_entry() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_wbi_nav(&server);
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/wbi/index/top/feed/rcmd")
                .query_param("ps", "20")
                .query_param("fresh_idx", "1")
                .query_param_exists("wts")
                .query_param_exists("w_rid");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "item": [{
                        "goto": "av",
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "cid": 9988,
                        "pic": "https://example.invalid/recommendation.jpg",
                        "title": "Recommended video",
                        "duration": 3,
                        "owner": {"mid": 1, "name": "Tester"}
                    }, {
                        "goto": "av",
                        "id": 170_002,
                        "bvid": "BV1xx411c7mE",
                        "cid": 9989,
                        "title": "Second recommended video"
                    }]
                }
            }));
        });
        server_mock_playurl(&server, 170_001, 9988, "recommendation");
        server_mock_player_v2(&server, 170_001, 9988);

        let plan = test_client(&server)
            .plan_download("https://www.bilibili.com/", Some(Selection::Latest))
            .await?;

        assert_eq!(plan.title, "Recommendations");
        assert_eq!(plan.entries.len(), 1);
        assert_eq!(plan.entries[0].title, "Recommended video");
        assert_eq!(plan.entries[0].source, StreamSource::NormalWeb);
        Ok(())
    }

    #[tokio::test]
    async fn dynamic_feed_stops_when_next_offset_is_missing() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/polymer/web-dynamic/v1/feed/all")
                .query_param("type", "video")
                .query_param("platform", "web")
                .query_param_missing("offset");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": true,
                    "offset": "offset-1",
                    "items": [{
                        "type": "DYNAMIC_TYPE_AV",
                        "visible": true,
                        "modules": {
                            "module_dynamic": {
                                "major": {
                                    "type": "MAJOR_TYPE_ARCHIVE",
                                    "archive": {"aid": 170_001}
                                }
                            }
                        }
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/polymer/web-dynamic/v1/feed/all")
                .query_param("type", "video")
                .query_param("platform", "web")
                .query_param("offset", "offset-1");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": true,
                    "items": [{
                        "type": "DYNAMIC_TYPE_AV",
                        "visible": true,
                        "modules": {
                            "module_dynamic": {
                                "major": {
                                    "type": "MAJOR_TYPE_ARCHIVE",
                                    "archive": {"aid": 170_002}
                                }
                            }
                        }
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "First dynamic video",
                    "pages": [{"page": 1, "cid": 9988, "part": "Main", "duration": 3}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170002");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_002,
                    "bvid": "BV1xx411c7mE",
                    "title": "Second dynamic video",
                    "pages": [{"page": 1, "cid": 9989, "part": "Main", "duration": 4}]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("following", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.collection.items[0].title, "First dynamic video");
                assert_eq!(collection.collection.items[1].title, "Second dynamic video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn plans_space_dynamic_latest_as_normal_video_entry() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/polymer/web-dynamic/v1/feed/space")
                .query_param("host_mid", "123")
                .query_param("platform", "web");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": true,
                    "offset": "offset-1",
                    "items": [{
                        "type": "DYNAMIC_TYPE_AV",
                        "visible": true,
                        "modules": {
                            "module_author": {
                                "mid": 123,
                                "name": "Uploader",
                                "pub_ts": 1_700_000_001_i64
                            },
                            "module_dynamic": {
                                "major": {
                                    "type": "MAJOR_TYPE_ARCHIVE",
                                    "archive": {
                                        "aid": 170_001,
                                        "bvid": "BV1xx411c7mD",
                                        "cover": "https://example.invalid/space-dynamic.jpg"
                                    }
                                }
                            }
                        }
                    }, {
                        "type": "DYNAMIC_TYPE_AV",
                        "visible": true,
                        "modules": {
                            "module_dynamic": {
                                "major": {
                                    "type": "MAJOR_TYPE_ARCHIVE",
                                    "archive": {"aid": 170_002}
                                }
                            }
                        }
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Space dynamic video",
                    "desc": "Space dynamic description",
                    "owner": {"mid": 123, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Main", "duration": 3}]
                }
            }));
        });
        server_mock_playurl(&server, 170_001, 9988, "space-dynamic");
        server_mock_player_v2(&server, 170_001, 9988);

        let plan = test_client(&server)
            .plan_download(
                "https://space.bilibili.com/123/dynamic",
                Some(Selection::Latest),
            )
            .await?;

        assert_eq!(plan.title, "Space 123 dynamic videos");
        assert_eq!(plan.entries.len(), 1);
        assert_eq!(plan.entries[0].title, "Space dynamic video");
        assert_eq!(plan.entries[0].source, StreamSource::NormalWeb);
        Ok(())
    }

    #[tokio::test]
    async fn resolve_collection_selection_keeps_full_items_while_plan_fetches_selected_item()
    -> anyhow::Result<()> {
        let resolve_server = MockServer::start();
        mock_favorite_collection_page(
            &resolve_server,
            1,
            21,
            170_001,
            "BV1xx411c7mD",
            "Newest video",
            9988,
        );
        mock_favorite_collection_page(
            &resolve_server,
            2,
            21,
            170_002,
            "BV1xx411c7mE",
            "Older video",
            9989,
        );

        let resolved = test_client(&resolve_server)
            .resolve_input("fav456", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.collection.items[1].title, "Older video");
                assert_eq!(collection.selected_items.len(), 1);
                assert_eq!(collection.selected_items[0].title, "Newest video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }

        let plan_server = MockServer::start();
        mock_favorite_collection_page(
            &plan_server,
            1,
            21,
            170_001,
            "BV1xx411c7mD",
            "Newest video",
            9988,
        );
        server_mock_playurl(&plan_server, 170_001, 9988, "fav");
        server_mock_player_v2(&plan_server, 170_001, 9988);

        let plan = test_client(&plan_server)
            .plan_download("fav456", Some(Selection::Latest))
            .await?;
        assert_eq!(plan.entries.len(), 1);
        assert_eq!(plan.entries[0].title, "Newest video");
        Ok(())
    }

    #[tokio::test]
    async fn resolves_space_series_with_owner_mid_endpoint() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/series/archives")
                .query_param("mid", "123")
                .query_param("series_id", "456")
                .query_param("pn", "1")
                .query_param("ps", "30");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "archives": [{
                        "aid": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "title": "Series video",
                        "pic": "https://example.invalid/series.jpg",
                        "pubdate": 1_700_000_001,
                        "duration": 3
                    }],
                    "page": {"total": 1}
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/series/series")
                .query_param("series_id", "456");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "meta": {
                        "name": "Uploader series",
                        "description": "Series intro",
                        "cover": "https://example.invalid/series-cover.jpg",
                        "mid": 123,
                        "total": 1
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Series video",
                    "desc": "Series description",
                    "owner": {"mid": 123, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Series video", "duration": 3}]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("https://space.bilibili.com/123/lists/456?type=series", None)
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::Series);
                assert_eq!(collection.collection.title, "Uploader series");
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].cid, 9988);
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn space_collection_latest_requests_newest_first() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/polymer/web-space/seasons_archives_list")
                .query_param("mid", "123")
                .query_param("season_id", "456")
                .query_param("sort_reverse", "true")
                .query_param("page_num", "1")
                .query_param("page_size", "30");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "archives": [{
                        "aid": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "title": "Newest collection video",
                        "pic": "https://example.invalid/collection.jpg",
                        "pubdate": 1_700_000_001,
                        "duration": 3
                    }],
                    "meta": {
                        "name": "Uploader collection",
                        "description": "Collection intro",
                        "cover": "https://example.invalid/collection-cover.jpg",
                        "mid": 123,
                        "total": 1
                    },
                    "page": {"total": 1}
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Newest collection video",
                    "desc": "Collection video description",
                    "owner": {"mid": 123, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Main", "duration": 3}]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input(
                "https://space.bilibili.com/123/lists/456?type=collection",
                Some(Selection::Latest),
            )
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::Collection);
                assert_eq!(
                    collection.selected_items[0].title,
                    "Newest collection video"
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_space_videos_from_wbi_response_shape() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/x/web-interface/nav");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "wbi_img": {
                        "img_url": "https://i0.hdslb.com/bfs/wbi/0123456789abcdef0123456789abcdef.png",
                        "sub_url": "https://i0.hdslb.com/bfs/wbi/fedcba9876543210fedcba9876543210.png"
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/space/wbi/arc/search")
                .query_param("mid", "123")
                .query_param("pn", "1")
                .query_param("ps", "50");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "list": {
                        "vlist": [{
                            "aid": 170_001,
                            "bvid": "BV1xx411c7mD",
                            "pic": "https://example.invalid/space.jpg",
                            "created": 1_700_000_000
                        }, {
                            "aid": 170_002,
                            "bvid": "BV1xx411c7mE",
                            "pic": "https://example.invalid/unselected.jpg",
                            "created": 1_699_999_999
                        }]
                    },
                    "page": {"count": 2}
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Space video",
                    "desc": "Space description",
                    "owner": {"mid": 123, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Main", "duration": 3}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170002");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_002,
                    "bvid": "BV1xx411c7mE",
                    "title": "Unselected space video",
                    "desc": "Unselected description",
                    "owner": {"mid": 123, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9989, "part": "Unselected space video", "duration": 3}]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input("mid123", Some(Selection::Page(1)))
            .await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::Space);
                assert_eq!(collection.selected_items[0].title, "Space video");
                assert_eq!(collection.selected_items[0].cid, 9988);
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_medialist_collection_after_filtered_cursor_page() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v1/medialist/info")
                .query_param("type", "8")
                .query_param("biz_id", "456");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "title": "Collection",
                    "intro": "Collection intro",
                    "cover": "https://example.invalid/collection.jpg"
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/medialist/resource/list")
                .query_param("biz_id", "456")
                .query_param("oid", "")
                .query_param("with_current", "true")
                .query_param("desc", "false");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": true,
                    "media_list": [{
                        "id": 170_000,
                        "title": "Filtered video",
                        "attr": 1,
                        "pages": [{"id": 9987, "page": 1, "title": "Filtered"}]
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/medialist/resource/list")
                .query_param("biz_id", "456")
                .query_param("oid", "170000")
                .query_param("with_current", "false")
                .query_param("desc", "false");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": false,
                    "media_list": [{
                        "id": 170_001,
                        "bv_id": "BV1xx411c7mD",
                        "title": "Visible video",
                        "intro": "Visible intro",
                        "cover": "https://example.invalid/visible.jpg",
                        "attr": 0,
                        "pages": [{"id": 9988, "page": 1, "title": "Main", "duration": 3}]
                    }]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client.resolve_input("collection456", None).await?;

        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.kind, VideoCollectionKind::Collection);
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].title, "Visible video");
                assert_eq!(
                    collection.selected_items[0].bvid.as_deref(),
                    Some("BV1xx411c7mD")
                );
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_medialist_item_without_pages_from_video_metadata() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v1/medialist/info")
                .query_param("type", "8")
                .query_param("biz_id", "456");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "title": "Collection",
                    "intro": "Collection intro"
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/medialist/resource/list")
                .query_param("biz_id", "456")
                .query_param("oid", "")
                .query_param("with_current", "true")
                .query_param("desc", "false");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": false,
                    "media_list": [{
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "title": "Visible video",
                        "intro": "Visible intro",
                        "attr": 0
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "bvid": "BV1xx411c7mD",
                    "title": "Visible video",
                    "desc": "Video description",
                    "owner": {"mid": 1, "name": "Uploader"},
                    "pages": [{"page": 1, "cid": 9988, "part": "Visible video", "duration": 3}]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("collection456", None)
            .await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 1);
                assert_eq!(collection.selected_items[0].cid, 9988);
                assert_eq!(collection.selected_items[0].title, "Visible video");
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn deduplicates_medialist_current_item_cursor_pages() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v1/medialist/info")
                .query_param("type", "8")
                .query_param("biz_id", "456");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "title": "Collection"
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/medialist/resource/list")
                .query_param("biz_id", "456")
                .query_param("oid", "")
                .query_param("with_current", "true")
                .query_param("desc", "false");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": true,
                    "media_list": [{
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "title": "First video",
                        "attr": 0,
                        "pages": [{"id": 9988, "page": 1, "title": "Main"}]
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v2/medialist/resource/list")
                .query_param("biz_id", "456")
                .query_param("oid", "170001")
                .query_param("with_current", "false")
                .query_param("desc", "false");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "has_more": false,
                    "media_list": [{
                        "id": 170_001,
                        "bvid": "BV1xx411c7mD",
                        "title": "First video",
                        "attr": 0,
                        "pages": [{"id": 9988, "page": 1, "title": "Main"}]
                    }, {
                        "id": 170_002,
                        "bvid": "BV1xx411c7mE",
                        "title": "Second video",
                        "attr": 0,
                        "pages": [{"id": 9989, "page": 1, "title": "Main"}]
                    }]
                }
            }));
        });

        let resolved = test_client(&server)
            .resolve_input("collection456", None)
            .await?;
        match resolved {
            ResolvedContent::Collection(collection) => {
                assert_eq!(collection.collection.items.len(), 2);
                assert_eq!(collection.collection.items[0].aid, 170_001);
                assert_eq!(collection.collection.items[1].aid, 170_002);
                assert_eq!(collection.collection.items[1].index, 2);
            }
            ResolvedContent::Video(_) | ResolvedContent::Season(_) => {
                return Err(anyhow::anyhow!("expected collection"));
            }
        }
        Ok(())
    }

    #[test]
    fn collection_latest_selects_first_parsed_item() -> anyhow::Result<()> {
        let collection = VideoCollectionMetadata {
            id: Some(456),
            kind: VideoCollectionKind::Favorite,
            title: "Favorite".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            items: vec![
                collection_item(1, "Newest video"),
                collection_item(2, "Older video"),
            ],
        };

        let resolved =
            BiliClient::resolve_collection_selection(collection, Some(&Selection::Latest))?;

        assert_eq!(resolved.selected_items[0].title, "Newest video");
        Ok(())
    }

    #[test]
    fn video_index_selection_preserves_selector_order_and_deduplicates() -> anyhow::Result<()> {
        let video = VideoMetadata {
            aid: 170_001,
            bvid: Some("BV1xx411c7mD".to_owned()),
            title: "Multi page".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            tags: Vec::new(),
            pages: (1..=4).map(page_metadata).collect(),
        };
        let selection = IndexSelection::new([
            IndexSelector::range(3, 4),
            IndexSelector::index(1),
            IndexSelector::range(2, 3),
        ])?;

        let pages = BiliClient::select_video_pages(&video, Some(&Selection::Indices(selection)))?;

        assert_eq!(
            pages.iter().map(|page| page.index).collect::<Vec<_>>(),
            [3, 4, 1, 2]
        );
        Ok(())
    }

    #[test]
    fn video_index_selection_rejects_partial_hits() -> anyhow::Result<()> {
        let video = VideoMetadata {
            aid: 170_001,
            bvid: Some("BV1xx411c7mD".to_owned()),
            title: "Multi page".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            tags: Vec::new(),
            pages: (1..=3).map(page_metadata).collect(),
        };
        let selection = IndexSelection::new([IndexSelector::index(1), IndexSelector::index(999)])?;

        let Err(error) =
            BiliClient::select_video_pages(&video, Some(&Selection::Indices(selection)))
        else {
            return Err(anyhow::anyhow!("partial index hits should fail"));
        };

        assert!(matches!(error, Error::MissingField("selected page")));
        Ok(())
    }

    #[test]
    fn season_index_selection_selects_episode_indices() -> anyhow::Result<()> {
        let season = SeasonMetadata {
            season_id: Some(123),
            media_id: Some(456),
            title: "A Season".to_owned(),
            description: String::new(),
            cover_url: None,
            main_episode_count: 4,
            areas: Vec::new(),
            tags: Vec::new(),
            episodes: (1..=4).map(episode_metadata).collect(),
        };
        let selection = IndexSelection::new([
            IndexSelector::index(2),
            IndexSelector::range(4, 4),
            IndexSelector::index(2),
        ])?;

        let resolved = BiliClient::resolve_season_selection(
            season,
            Some(&Selection::Indices(selection)),
            None,
            "season",
        )?;

        assert_eq!(
            resolved
                .selected_episodes
                .iter()
                .map(|episode| episode.index)
                .collect::<Vec<_>>(),
            [2, 4]
        );
        Ok(())
    }

    #[test]
    fn collection_index_selection_selects_list_and_ranges() -> anyhow::Result<()> {
        let collection = VideoCollectionMetadata {
            id: Some(456),
            kind: VideoCollectionKind::Favorite,
            title: "Favorite".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            items: vec![
                collection_item(1, "Newest video"),
                collection_item(2, "Middle video"),
                collection_item(3, "Older video"),
            ],
        };
        let selection = IndexSelection::new([
            IndexSelector::range(2, 3),
            IndexSelector::index(1),
            IndexSelector::index(2),
        ])?;

        let resolved = BiliClient::resolve_collection_selection(
            collection,
            Some(&Selection::Indices(selection)),
        )?;

        assert_eq!(
            resolved
                .selected_items
                .iter()
                .map(|item| item.title.as_str())
                .collect::<Vec<_>>(),
            ["Middle video", "Older video", "Newest video"]
        );
        Ok(())
    }

    #[test]
    fn collection_index_fetch_mode_stops_after_max_requested_index() -> anyhow::Result<()> {
        let selection = IndexSelection::new([IndexSelector::index(1), IndexSelector::range(3, 4)])?;
        let fetch_mode = BiliClient::collection_fetch_mode(Some(&Selection::Indices(selection)))?;

        assert!(!fetch_mode.is_satisfied_by(3));
        assert!(fetch_mode.is_satisfied_by(4));
        Ok(())
    }

    #[test]
    fn collection_all_selection_allows_empty_items() -> anyhow::Result<()> {
        let collection = VideoCollectionMetadata {
            id: Some(456),
            kind: VideoCollectionKind::Favorite,
            title: "Favorite".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            items: Vec::new(),
        };

        let default_resolved = BiliClient::resolve_collection_selection(collection.clone(), None)?;
        assert!(default_resolved.selected_items.is_empty());
        let all_resolved =
            BiliClient::resolve_collection_selection(collection, Some(&Selection::All))?;
        assert!(all_resolved.selected_items.is_empty());
        Ok(())
    }

    #[test]
    fn collection_current_selection_is_rejected() {
        let collection = VideoCollectionMetadata {
            id: Some(456),
            kind: VideoCollectionKind::Favorite,
            title: "Favorite".to_owned(),
            description: String::new(),
            cover_url: None,
            pub_time: None,
            owner: None,
            items: vec![collection_item(1, "Saved video")],
        };

        let error =
            BiliClient::resolve_collection_selection(collection, Some(&Selection::Current)).err();

        assert!(
            matches!(error, Some(Error::InvalidInput(message)) if message.contains("current selection"))
        );
    }

    #[tokio::test]
    async fn resolves_short_link_input_from_redirect_target() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET).path("/short");
            then.status(302).header("location", "/video/av170001");
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/view")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "aid": 170_001,
                    "title": "Redirected video",
                    "pages": [{"page": 1, "cid": 9988, "part": "Main"}]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/tag/archive/tags")
                .query_param("aid", "170001");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": []
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve(
                Input::ShortLink(format!("{}/short", server.base_url())),
                None,
            )
            .await?;
        match resolved {
            ResolvedContent::Video(video) => assert_eq!(video.title, "Redirected video"),
            ResolvedContent::Season(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected video"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn intl_access_key_is_redacted_from_http_errors() -> anyhow::Result<()> {
        let server = MockServer::start();
        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials {
                cookie: None,
                access_key: Some("TOKEN_SHOULD_REDACT_12345".to_owned()),
                tv_access_key: None,
            },
            restricted_area: RestrictedAreaConfig::default(),
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        });

        let Err(error) = client
            .resolve_input("https://www.bilibili.tv/en/play/34613/341736", None)
            .await
        else {
            return Err(anyhow::anyhow!("HTTP status failure should propagate"));
        };
        let debug = format!("{error:?}");
        assert!(!debug.contains("TOKEN_SHOULD_REDACT_12345"));
        assert!(!debug.contains("access_key"));
        Ok(())
    }

    #[tokio::test]
    async fn resolves_intl_episode_from_module_episodes() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/intl/gateway/v2/ogv/view/app/season")
                .query_param("ep_id", "341736");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 34613,
                    "title": "Intl Season",
                    "modules": [{
                        "data": {
                            "episodes": [
                                {"aid": 7, "cid": 70, "episode_id": 341_736, "title": "1", "long_title": "Start"}
                            ]
                        }
                    }, {
                        "data": {
                            "episodes": [
                                {"aid": 8, "cid": 80, "id": 341_737, "title": "2", "long_title": "Wrong module"}
                            ]
                        }
                    }],
                    "areas": [{"name": "Thailand"}],
                    "styles": [{"name": "Anime"}]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input(
                "https://www.bilibili.tv/en/play/34613/341736",
                Some(Selection::Latest),
            )
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.title, "Intl Season");
                assert_eq!(season.season.episodes.len(), 1);
                assert_eq!(season.selected_episodes[0].epid, 341_736);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn intl_region_limit_returns_access_restricted_error() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/intl/gateway/v2/ogv/view/app/season")
                .query_param("ep_id", "341736");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 34613,
                    "title": "Intl Season",
                    "status": 13,
                    "limit": {
                        "content": "Sorry, this content is not available in your region"
                    },
                    "modules": []
                }
            }));
        });

        let client = test_client(&server);
        let Err(error) = client
            .resolve_input("https://www.bilibili.tv/en/play/34613/341736", None)
            .await
        else {
            return Err(anyhow::anyhow!("region-limited intl season should fail"));
        };

        match error {
            Error::AccessRestricted(message) => {
                assert!(message.contains("not available in your region"));
            }
            other => {
                return Err(anyhow::anyhow!(
                    "expected access restriction, got {other:?}"
                ));
            }
        }
        Ok(())
    }

    #[tokio::test]
    #[allow(clippy::too_many_lines)]
    async fn plans_intl_download_with_access_key_for_subtitles() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/intl/gateway/v2/ogv/view/app/season")
                .query_param("ep_id", "341736")
                .query_param("access_key", "intl-token");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 34613,
                    "title": "Intl Season",
                    "modules": [{
                        "data": {
                            "episodes": [
                                {"aid": 7, "cid": 70, "id": 341_736, "title": "1", "long_title": "Start"}
                            ]
                        }
                    }]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/intl/gateway/v2/ogv/playurl")
                .query_param("ep_id", "341736")
                .query_param("cid", "70")
                .query_param("platform", "android")
                .query_param("mobi_app", "bstar_a")
                .query_param("area", "th")
                .query_param("s_locale", "zh_SG")
                .query_param("access_key", "intl-token")
                .query_param_exists("sign");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "video_info": {
                        "timelength": 42_000,
                        "stream_list": [{
                            "stream_info": {"quality": 80},
                            "dash_video": {
                                "base_url": "https://intl.example/video.m4s",
                                "width": 1280,
                                "height": 720,
                                "bandwidth": 900,
                                "codecs": "avc1"
                            }
                        }],
                        "dash_audio": [{
                            "id": 30280,
                            "base_url": "https://intl.example/audio.m4s",
                            "bandwidth": 128_000,
                            "codecs": "mp4a.40.2"
                        }]
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/intl/gateway/web/v2/subtitle")
                .query_param("episode_id", "341736")
                .query_param("platform", "web")
                .query_param("s_locale", "en_US")
                .query_param("access_key", "intl-token");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "subtitles": [{
                        "lang_key": "en",
                        "lang_doc": "English",
                        "url": "https://subtitle.example/en.ass"
                    }]
                }
            }));
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials {
                cookie: None,
                access_key: Some("intl-token".to_owned()),
                tv_access_key: None,
            },
            restricted_area: RestrictedAreaConfig::default(),
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        });
        let plan = client
            .plan_download("https://www.bilibili.tv/en/play/34613/341736", None)
            .await?;

        assert_eq!(plan.entries[0].source, StreamSource::IntlWeb);
        assert_eq!(plan.entries[0].subtitles[0].language, "en");
        assert_eq!(plan.entries[0].subtitles[0].format, SubtitleFormat::Ass);
        Ok(())
    }

    #[tokio::test]
    async fn plans_pgc_download_from_video_info_payload() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/web/v2/playurl")
                .query_param("avid", "10")
                .query_param("cid", "100")
                .query_param("ep_id", "1000")
                .query_param("module", "bangumi");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 456,
                            "video": [{
                            "id": 64,
                            "baseUrl": "https://video.example/64.m4s",
                            "base_url": "https://video.example/64.m4s",
                            "backupUrl": [],
                            "backup_url": [],
                            "codecs": "hev1",
                            "bandwidth": 900,
                            "mimeType": "video/mp4",
                            "mime_type": "video/mp4"
                        }],
                            "audio": []
                        }
                    }
                }
            }));
        });

        let client = test_client(&server);
        let plan = client.plan_download("ep1000", None).await?;

        assert_eq!(plan.entries.len(), 1);
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcWeb);
        assert_eq!(entry.epid, Some(1000));
        assert_eq!(entry.title, "1 Start");
        assert_eq!(entry.streams.videos[0].id, 64);
        assert_eq!(
            entry.streams.videos[0].mime_type.as_deref(),
            Some("video/mp4")
        );
        Ok(())
    }

    #[tokio::test]
    async fn plans_pgc_download_with_tv_playurl_mode() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
                    ]
                }
            }));
        });
        let tv_playurl = server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/api/playurltv")
                .query_param("access_key", "TV_ACCESS")
                .query_param("appkey", TV_PLAYURL_APPKEY)
                .query_param("cid", "100")
                .query_param("ep_id", "1000")
                .query_param("expire", "0")
                .query_param("mobi_app", "android_tv_yst")
                .query_param("object_id", "10")
                .query_param("platform", "android")
                .query_param("playurl_type", "1")
                .query_param_exists("ts")
                .query_param_exists("sign")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 456,
                        "video": [{
                            "id": 64,
                            "base_url": "https://tv.example/pgc-64.m4s",
                            "codecs": "hev1",
                            "bandwidth": 900,
                            "mime_type": "video/mp4"
                        }],
                        "audio": []
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "10")
                .query_param("cid", "100");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_pgc_base(server.base_url())
                        .with_tv_api_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_cookie("SESSDATA=WEB_COOKIE")
                        .with_tv_access_key("TV_ACCESS"),
                )
                .with_playurl_mode(PlayurlMode::Tv),
        );
        let plan = client.plan_download("ep1000", None).await?;

        tv_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcTv);
        assert_eq!(entry.epid, Some(1000));
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://tv.example/pgc-64.m4s"
        );
        Ok(())
    }

    #[tokio::test]
    async fn plans_pgc_download_with_app_playurl_mode() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
                    ]
                }
            }));
        });
        let app_response =
            app_playurl::test_play_view_response_frame("https://app.example/pgc-80.m4s")?;
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::PGC_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 APP_ACCESS")
                .header_exists("x-bili-metadata-bin")
                .header_missing("cookie");
            then.status(200).body(app_response.clone());
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "10")
                .query_param("cid", "100");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_pgc_base(server.base_url())
                        .with_app_pgc_grpc_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_cookie("SESSDATA=WEB_COOKIE")
                        .with_access_key("APP_ACCESS"),
                )
                .with_playurl_mode(PlayurlMode::App),
        );
        let plan = client.plan_download("ep1000", None).await?;

        app_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcApp);
        assert_eq!(entry.epid, Some(1000));
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://app.example/pgc-80.m4s"
        );
        assert_eq!(entry.streams.videos[0].codecs, None);
        assert_eq!(
            entry.streams.videos[0].codec_family,
            Some(CodecFamily::Hevc)
        );
        Ok(())
    }

    fn mock_pgc_app_proxy_playurl(server: &MockServer) -> httpmock::Mock<'_> {
        server.mock(|when, then| {
            when.method(GET)
                .path("/proxy-playurl")
                .query_param("proxy_token", "PROXY_SECRET")
                .query_param("ep_id", "1000")
                .query_param("area", "hk")
                .query_param("access_key", "ACCESS_SECRET")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 456,
                            "video": [{
                                "id": 64,
                                "baseUrl": "https://proxy.example/64.m4s",
                                "base_url": "https://proxy.example/64.m4s",
                                "codecs": "hev1",
                                "bandwidth": 900,
                                "mimeType": "video/mp4",
                                "mime_type": "video/mp4"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        })
    }

    fn pgc_app_proxy_client(server: &MockServer) -> BiliClient {
        BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_pgc_base(server.base_url())
                        .with_app_pgc_grpc_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_tv_access_key("TV_ACCESS")
                        .with_access_key("ACCESS_SECRET"),
                )
                .with_restricted_area(
                    RestrictedAreaConfig::default()
                        .with_area_hint(RestrictedArea::Hk)
                        .with_proxy(RestrictedAreaProxy::playurl(
                            format!(
                                "{}/proxy-playurl?proxy_token=PROXY_SECRET",
                                server.base_url()
                            ),
                            Some(RestrictedArea::Hk),
                        )),
                )
                .with_playurl_mode(PlayurlMode::App),
        )
    }

    #[tokio::test]
    async fn pgc_app_streams_fall_back_to_restricted_area_proxy() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_pgc_episode_metadata(&server);
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::PGC_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 TV_ACCESS")
                .header_missing("cookie");
            then.status(200)
                .header("grpc-status", "7")
                .header("grpc-message", "area%20restricted");
        });
        let proxy_playurl = server.mock(|when, then| {
            when.method(GET)
                .path("/proxy-playurl")
                .query_param("proxy_token", "PROXY_SECRET")
                .query_param("ep_id", "1000")
                .query_param("area", "hk")
                .query_param("access_key", "ACCESS_SECRET")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 456,
                            "video": [{
                                "id": 64,
                                "baseUrl": "https://proxy.example/64.m4s",
                                "base_url": "https://proxy.example/64.m4s",
                                "codecs": "hev1",
                                "bandwidth": 900,
                                "mimeType": "video/mp4",
                                "mime_type": "video/mp4"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        });
        server_mock_player_v2(&server, 10, 100);

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_pgc_base(server.base_url())
                        .with_app_pgc_grpc_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_tv_access_key("TV_ACCESS")
                        .with_access_key("ACCESS_SECRET"),
                )
                .with_restricted_area(
                    RestrictedAreaConfig::default()
                        .with_area_hint(RestrictedArea::Hk)
                        .with_proxy(RestrictedAreaProxy::playurl(
                            format!(
                                "{}/proxy-playurl?proxy_token=PROXY_SECRET",
                                server.base_url()
                            ),
                            Some(RestrictedArea::Hk),
                        )),
                )
                .with_playurl_mode(PlayurlMode::App),
        );
        let plan = client.plan_download("ep1000", None).await?;

        app_playurl.assert();
        proxy_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcProxy);
        assert_eq!(entry.diagnostics.attempts.len(), 2);
        assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcApp);
        assert_eq!(entry.diagnostics.attempts[1].source, StreamSource::PgcProxy);
        assert_eq!(entry.diagnostics.attempts[1].area.as_deref(), Some("hk"));
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://proxy.example/64.m4s"
        );
        Ok(())
    }

    #[tokio::test]
    async fn pgc_app_status_code_falls_back_to_restricted_area_proxy() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_pgc_episode_metadata(&server);
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::PGC_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 TV_ACCESS")
                .header_missing("cookie");
            then.status(200).header("grpc-status", "7");
        });
        let proxy_playurl = mock_pgc_app_proxy_playurl(&server);
        server_mock_player_v2(&server, 10, 100);

        let client = pgc_app_proxy_client(&server);
        let plan = client.plan_download("ep1000", None).await?;

        app_playurl.assert();
        proxy_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcProxy);
        assert_eq!(entry.diagnostics.attempts.len(), 2);
        assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcApp);
        assert_eq!(
            entry.diagnostics.attempts[0].message.as_deref(),
            Some("API code 7: APP playurl gRPC request failed")
        );
        assert_eq!(entry.diagnostics.attempts[1].source, StreamSource::PgcProxy);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://proxy.example/64.m4s"
        );
        Ok(())
    }

    #[tokio::test]
    async fn pgc_app_body_limit_falls_back_to_restricted_area_proxy() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_pgc_episode_metadata(&server);
        let app_response = app_playurl::test_pgc_region_limit_response_frame("area restricted")?;
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::PGC_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 TV_ACCESS")
                .header_missing("cookie");
            then.status(200).body(app_response.clone());
        });
        let proxy_playurl = server.mock(|when, then| {
            when.method(GET)
                .path("/proxy-playurl")
                .query_param("proxy_token", "PROXY_SECRET")
                .query_param("ep_id", "1000")
                .query_param("area", "hk")
                .query_param("access_key", "ACCESS_SECRET")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 456,
                            "video": [{
                                "id": 64,
                                "baseUrl": "https://proxy.example/64.m4s",
                                "base_url": "https://proxy.example/64.m4s",
                                "codecs": "hev1",
                                "bandwidth": 900,
                                "mimeType": "video/mp4",
                                "mime_type": "video/mp4"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        });
        server_mock_player_v2(&server, 10, 100);

        let client = BiliClient::new(
            ClientConfig::default()
                .with_endpoints(
                    EndpointConfig::default()
                        .with_api_base(server.base_url())
                        .with_pgc_base(server.base_url())
                        .with_app_pgc_grpc_base(server.base_url()),
                )
                .with_credentials(
                    Credentials::default()
                        .with_tv_access_key("TV_ACCESS")
                        .with_access_key("ACCESS_SECRET"),
                )
                .with_restricted_area(
                    RestrictedAreaConfig::default()
                        .with_area_hint(RestrictedArea::Hk)
                        .with_proxy(RestrictedAreaProxy::playurl(
                            format!(
                                "{}/proxy-playurl?proxy_token=PROXY_SECRET",
                                server.base_url()
                            ),
                            Some(RestrictedArea::Hk),
                        )),
                )
                .with_playurl_mode(PlayurlMode::App),
        );
        let plan = client.plan_download("ep1000", None).await?;

        app_playurl.assert();
        proxy_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcProxy);
        assert_eq!(entry.diagnostics.attempts.len(), 2);
        assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcApp);
        assert_eq!(entry.diagnostics.attempts[1].source, StreamSource::PgcProxy);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://proxy.example/64.m4s"
        );
        Ok(())
    }

    #[tokio::test]
    async fn pgc_app_preview_only_body_falls_back_to_restricted_area_proxy() -> anyhow::Result<()> {
        let server = MockServer::start();
        mock_pgc_episode_metadata(&server);
        let app_response =
            app_playurl::test_pgc_preview_only_response_frame_with_message(Some("preview ended"))?;
        let app_playurl = server.mock(|when, then| {
            when.method(POST)
                .path(app_playurl::PGC_PLAYURL_PATH)
                .header("content-type", "application/grpc")
                .header("authorization", "identify_v1 TV_ACCESS")
                .header_missing("cookie");
            then.status(200).body(app_response.clone());
        });
        let proxy_playurl = mock_pgc_app_proxy_playurl(&server);
        server_mock_player_v2(&server, 10, 100);

        let client = pgc_app_proxy_client(&server);
        let plan = client.plan_download("ep1000", None).await?;

        app_playurl.assert();
        proxy_playurl.assert();
        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcProxy);
        assert_eq!(entry.diagnostics.attempts.len(), 2);
        assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcApp);
        assert_eq!(
            entry.diagnostics.attempts[0].message.as_deref(),
            Some("access restricted: APP playurl returned preview-only streams: preview ended")
        );
        assert_eq!(entry.diagnostics.attempts[1].source, StreamSource::PgcProxy);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://proxy.example/64.m4s"
        );
        Ok(())
    }

    #[test]
    fn endpoint_client_and_restricted_area_builders_configure_embedding_inputs() {
        let endpoints = EndpointConfig::default()
            .with_api_base("https://api.test")
            .with_pgc_base("https://pgc.test")
            .with_intl_base("https://intl.test")
            .with_comment_base("https://comment.test")
            .with_passport_base("https://passport.test")
            .with_tv_api_base("https://tv-api.test")
            .with_app_grpc_base("https://app-grpc.test")
            .with_app_pgc_grpc_base("https://app-pgc-grpc.test")
            .with_tv_passport_base("https://tv-passport.test")
            .with_tv_passport_poll_base("https://tv-poll.test");
        let restricted_area = RestrictedAreaConfig::default()
            .with_area_hint(RestrictedArea::Tw)
            .with_proxy(RestrictedAreaProxy::playurl(
                "https://generic.example/playurl",
                None,
            ))
            .with_proxies([RestrictedAreaProxy::bilibili_api(
                "https://tw.example/api",
                Some(RestrictedArea::Tw),
            )]);
        let credentials = Credentials::default()
            .with_cookie("SESSDATA=redacted")
            .with_access_key("access-key");

        let config = ClientConfig::default()
            .with_endpoints(endpoints)
            .with_credentials(credentials)
            .with_restricted_area(restricted_area)
            .with_playurl_mode(PlayurlMode::App)
            .with_user_agent("embedding-test/1.0")
            .with_request_timeout(Duration::from_secs(7));

        assert_eq!(config.endpoints.api_base, "https://api.test");
        assert_eq!(config.endpoints.tv_api_base, "https://tv-api.test");
        assert_eq!(config.endpoints.app_grpc_base, "https://app-grpc.test");
        assert_eq!(
            config.endpoints.app_pgc_grpc_base,
            "https://app-pgc-grpc.test"
        );
        assert_eq!(
            config.endpoints.tv_passport_poll_base,
            "https://tv-poll.test"
        );
        assert_eq!(config.credentials.access_key.as_deref(), Some("access-key"));
        assert_eq!(config.restricted_area.area_hint, Some(RestrictedArea::Tw));
        assert_eq!(config.restricted_area.proxies.len(), 2);
        assert_eq!(config.playurl_mode, PlayurlMode::App);
        assert_eq!(config.user_agent, "embedding-test/1.0");
        assert_eq!(config.request_timeout, Duration::from_secs(7));
    }

    #[test]
    fn endpoint_defaults_use_app_grpc_playurl_host() {
        let endpoints = EndpointConfig::default();

        assert_eq!(endpoints.app_grpc_base, "https://grpc.biliapi.net");
        assert_eq!(endpoints.app_pgc_grpc_base, "https://grpc.biliapi.net");
    }

    #[test]
    fn restricted_area_proxies_are_ordered_by_hint_then_generic_then_area() {
        let config = RestrictedAreaConfig::new(
            Some(RestrictedArea::Hk),
            [
                RestrictedAreaProxy::playurl("https://generic.example/playurl", None),
                RestrictedAreaProxy::bilibili_api(
                    "https://tw.example/api",
                    Some(RestrictedArea::Tw),
                ),
                RestrictedAreaProxy::bilibili_api(
                    "https://hk.example/api",
                    Some(RestrictedArea::Hk),
                ),
            ],
        );

        let ordered = config.ordered_proxies();
        assert_eq!(ordered[0].area, Some(RestrictedArea::Hk));
        assert_eq!(ordered[1].area, None);
        assert_eq!(ordered[2].area, Some(RestrictedArea::Tw));
    }

    #[test]
    fn restricted_area_message_accepts_common_chinese_unavailable_phrasing() {
        for message in [
            "您所在地区不可观看",
            "所在地区无法观看",
            "您所在地區不可觀看",
            "您所在的地区无法观看",
            "您所在的地區無法觀看",
        ] {
            assert!(super::is_restricted_area_message(message));
        }
    }

    #[tokio::test]
    async fn pgc_streams_fall_back_to_restricted_area_proxy() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/web/v2/playurl")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -40301,
                "message": "您所在地区不可观看"
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/t/PATH_SECRET/proxy-playurl")
                .query_param("proxy_token", "PROXY_SECRET")
                .query_param("ep_id", "1000")
                .query_param("area", "hk")
                .query_param("access_key", "ACCESS_SECRET")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 456,
                            "video": [{
                                "id": 64,
                                "baseUrl": "https://proxy.example/64.m4s",
                                "base_url": "https://proxy.example/64.m4s"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials {
                cookie: Some("SESSDATA=COOKIE_SECRET".to_owned()),
                access_key: Some("ACCESS_SECRET".to_owned()),
                tv_access_key: None,
            },
            restricted_area: RestrictedAreaConfig {
                area_hint: Some(RestrictedArea::Hk),
                proxies: vec![RestrictedAreaProxy::playurl(
                    format!(
                        "{}/t/PATH_SECRET/proxy-playurl?proxy_token=PROXY_SECRET",
                        server.base_url()
                    ),
                    Some(RestrictedArea::Hk),
                )],
            },
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        });
        let plan = client.plan_download("ep1000", None).await?;

        let entry = &plan.entries[0];
        assert_eq!(entry.source, StreamSource::PgcProxy);
        assert_eq!(
            entry.streams.videos[0].base_url,
            "https://proxy.example/64.m4s"
        );
        assert_eq!(entry.diagnostics.attempts.len(), 2);
        assert_eq!(entry.diagnostics.attempts[0].source, StreamSource::PgcWeb);
        assert_eq!(entry.diagnostics.attempts[1].area.as_deref(), Some("hk"));
        let diagnostics = serde_json::to_string(&entry.diagnostics)?;
        assert!(!diagnostics.contains("ACCESS_SECRET"));
        assert!(!diagnostics.contains("COOKIE_SECRET"));
        assert!(!diagnostics.contains("PATH_SECRET"));
        assert!(!diagnostics.contains("PROXY_SECRET"));
        assert!(!diagnostics.contains("access_key"));
        Ok(())
    }

    #[tokio::test]
    async fn pgc_proxy_invalid_candidate_diagnostics_redact_endpoint() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/web/v2/playurl")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -40301,
                "message": "area restricted"
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/proxy-playurl")
                .query_param("ep_id", "1000")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "video": [{
                                "id": 64,
                                "baseUrl": "https://proxy.example/64.m4s",
                                "base_url": "https://proxy.example/64.m4s"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials::default(),
            restricted_area: RestrictedAreaConfig {
                area_hint: Some(RestrictedArea::Hk),
                proxies: vec![
                    RestrictedAreaProxy::playurl(
                        "https://user:pass@proxy.example:bad/t/PATH_SECRET?proxy_token=PROXY_SECRET",
                        Some(RestrictedArea::Hk),
                    ),
                    RestrictedAreaProxy::playurl(
                        format!("{}/proxy-playurl", server.base_url()),
                        Some(RestrictedArea::Hk),
                    ),
                ],
            },
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        });

        let plan = client.plan_download("ep1000", None).await?;
        let diagnostics = serde_json::to_string(&plan.entries[0].diagnostics)?;

        assert!(diagnostics.contains("https://proxy.example:bad"));
        for sensitive in ["user:pass", "PATH_SECRET", "PROXY_SECRET", "proxy_token"] {
            assert!(
                !diagnostics.contains(sensitive),
                "diagnostics leaked {sensitive}: {diagnostics}"
            );
        }
        Ok(())
    }

    #[tokio::test]
    async fn pgc_proxy_fallback_requires_area_restriction() -> anyhow::Result<()> {
        for code in [403_i64, -40301_i64] {
            let server = MockServer::start();
            server.mock(|when, then| {
                when.method(GET)
                    .path("/pgc/view/web/season")
                    .query_param("ep_id", "1000");
                then.status(200).json_body_obj(&serde_json::json!({
                    "code": 0,
                    "result": {
                        "season_id": 123,
                        "title": "A Season",
                        "episodes": [
                            {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
                        ]
                    }
                }));
            });
            server.mock(|when, then| {
                when.method(GET)
                    .path("/pgc/player/web/v2/playurl")
                    .query_param("ep_id", "1000");
                then.status(200).json_body_obj(&serde_json::json!({
                    "code": code,
                    "message": "vip required"
                }));
            });
            server.mock(|when, then| {
                when.method(GET).path("/proxy-playurl");
                then.status(200).json_body_obj(&serde_json::json!({
                    "code": 0,
                    "result": {
                        "video_info": {
                            "dash": {
                                "video": [{
                                    "id": 64,
                                    "baseUrl": "https://proxy.example/64.m4s",
                                    "base_url": "https://proxy.example/64.m4s"
                                }],
                                "audio": []
                            }
                        }
                    }
                }));
            });

            let client = BiliClient::new(ClientConfig {
                endpoints: EndpointConfig {
                    api_base: server.base_url(),
                    pgc_base: server.base_url(),
                    intl_base: server.base_url(),
                    comment_base: server.base_url(),
                    passport_base: server.base_url(),
                    tv_api_base: server.base_url(),
                    app_grpc_base: server.base_url(),
                    app_pgc_grpc_base: server.base_url(),
                    tv_passport_base: server.base_url(),
                    tv_passport_poll_base: server.base_url(),
                },
                credentials: Credentials {
                    cookie: None,
                    access_key: Some("ACCESS_SECRET".to_owned()),
                    tv_access_key: None,
                },
                restricted_area: RestrictedAreaConfig {
                    area_hint: Some(RestrictedArea::Hk),
                    proxies: vec![RestrictedAreaProxy::playurl(
                        format!("{}/proxy-playurl", server.base_url()),
                        Some(RestrictedArea::Hk),
                    )],
                },
                playurl_mode: PlayurlMode::Web,
                user_agent: "test".to_owned(),
                request_timeout: Duration::from_secs(30),
            });

            let Err(error) = client.plan_download("ep1000", None).await else {
                return Err(anyhow::anyhow!("non-area PGC error should not use proxy"));
            };
            assert!(matches!(error, Error::Api { code: error_code, .. } if error_code == code));
        }
        Ok(())
    }

    #[tokio::test]
    async fn pgc_restricted_area_failure_redacts_sensitive_messages() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/web/v2/playurl")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -40301,
                "message": "area restricted access_key=OFFICIAL_SECRET"
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/proxy-playurl")
                .query_param("ep_id", "1000")
                .query_param("access_key", "ACCESS_SECRET")
                .header_missing("cookie");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -40301,
                "message": "proxy rejected https://user:pass@proxy.example/api?proxy_token=PROXY_SECRET&access_key=ACCESS_SECRET cookie=SESSDATA=COOKIE_SECRET"
            }));
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials {
                cookie: Some("SESSDATA=COOKIE_SECRET".to_owned()),
                access_key: Some("ACCESS_SECRET".to_owned()),
                tv_access_key: None,
            },
            restricted_area: RestrictedAreaConfig {
                area_hint: Some(RestrictedArea::Hk),
                proxies: vec![RestrictedAreaProxy::playurl(
                    format!("{}/proxy-playurl", server.base_url()),
                    None,
                )],
            },
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        });

        let Err(error) = client.plan_download("ep1000", None).await else {
            return Err(anyhow::anyhow!("all resolver candidates should fail"));
        };
        let message = error.to_string();
        assert!(message.contains("restricted-area resolver failed"));
        for sensitive in [
            "OFFICIAL_SECRET",
            "ACCESS_SECRET",
            "PROXY_SECRET",
            "COOKIE_SECRET",
            "access_key",
            "proxy_token",
            "cookie",
            "user:pass",
        ] {
            assert!(
                !message.contains(sensitive),
                "message leaked {sensitive}: {message}"
            );
        }
        Ok(())
    }

    #[test]
    fn pgc_bilibili_api_proxy_preserves_base_query_for_web_and_v2_paths() -> anyhow::Result<()> {
        let client = BiliClient::new(ClientConfig {
            credentials: Credentials {
                cookie: None,
                access_key: Some("ACCESS_SECRET".to_owned()),
                tv_access_key: None,
            },
            ..ClientConfig::default()
        });
        let proxy = RestrictedAreaProxy::bilibili_api(
            "https://proxy.example/base?proxy_token=a%3Db",
            Some(RestrictedArea::Hk),
        );

        let urls = BiliClient::pgc_proxy_playurl_urls(
            &proxy,
            10,
            100,
            1000,
            client.config.credentials.access_key.as_deref(),
        )?;
        let actual = urls.iter().map(url::Url::as_str).collect::<Vec<_>>();
        assert_eq!(
            actual,
            vec![
                "https://proxy.example/base/pgc/player/web/playurl?proxy_token=a%3Db&avid=10&cid=100&ep_id=1000&module=bangumi&qn=0&fnval=4048&fnver=0&fourk=1&otype=json&area=hk&access_key=ACCESS_SECRET",
                "https://proxy.example/base/pgc/player/web/v2/playurl?proxy_token=a%3Db&avid=10&cid=100&ep_id=1000&module=bangumi&qn=0&fnval=4048&fnver=0&fourk=1&otype=json&area=hk&access_key=ACCESS_SECRET"
            ]
        );
        Ok(())
    }

    #[tokio::test]
    async fn pgc_bilibili_api_proxy_falls_back_to_v2_path() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1"}
                    ]
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/player/web/v2/playurl")
                .query_param("ep_id", "1000")
                .query_param("module", "bangumi");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": -40301,
                "message": "area restricted"
            }));
        });
        let web_proxy = server.mock(|when, then| {
            when.method(GET)
                .path("/proxy/pgc/player/web/playurl")
                .query_param("ep_id", "1000")
                .query_param("area", "hk");
            then.status(404);
        });
        let v2_proxy = server.mock(|when, then| {
            when.method(GET)
                .path("/proxy/pgc/player/web/v2/playurl")
                .query_param("ep_id", "1000")
                .query_param("area", "hk");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "video_info": {
                        "dash": {
                            "duration": 3,
                            "video": [{
                                "id": 80,
                                "baseUrl": "https://proxy.example/video.m4s",
                                "base_url": "https://proxy.example/video.m4s"
                            }],
                            "audio": []
                        }
                    }
                }
            }));
        });
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", "10")
                .query_param("cid", "100");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            restricted_area: RestrictedAreaConfig {
                area_hint: Some(RestrictedArea::Hk),
                proxies: vec![RestrictedAreaProxy::bilibili_api(
                    format!("{}/proxy", server.base_url()),
                    Some(RestrictedArea::Hk),
                )],
            },
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
            ..ClientConfig::default()
        });
        let plan = client.plan_download("ep1000", None).await?;

        web_proxy.assert();
        v2_proxy.assert();
        assert_eq!(plan.entries[0].source, StreamSource::PgcProxy);
        assert_eq!(plan.entries[0].streams.videos[0].id, 80);
        assert_eq!(plan.entries[0].diagnostics.attempts.len(), 3);
        Ok(())
    }

    #[test]
    fn resolver_error_message_redacts_sensitive_values() {
        let message = super::resolver_error_message(&Error::Api {
            code: -40301,
            message: "proxy rejected https://user:pass@proxy.example/api?proxy_token=PROXY_SECRET&access_key=ACCESS_SECRET cookie=SESSDATA=COOKIE_SECRET token=TOKEN_SECRET jwt=JWT_SECRET x-api-key: API_KEY_SECRET authorization: Bearer AUTH_SECRET".to_owned(),
        });

        assert!(message.starts_with("API code -40301:"));
        for sensitive in [
            "ACCESS_SECRET",
            "PROXY_SECRET",
            "COOKIE_SECRET",
            "TOKEN_SECRET",
            "JWT_SECRET",
            "API_KEY_SECRET",
            "AUTH_SECRET",
            "proxy_token",
            "access_key",
            "x-api-key",
            "authorization",
            "cookie",
            "user:pass",
        ] {
            assert!(
                !message.contains(sensitive),
                "message leaked {sensitive}: {message}"
            );
        }
    }

    #[test]
    fn resolver_error_message_redacts_mixed_case_urls() {
        let message = super::resolver_error_message(&Error::Api {
            code: -40301,
            message: "proxy rejected HtTpS://user:pass@proxy.example/t/PATH_SECRET?x=1".to_owned(),
        });

        assert!(message.contains("https://proxy.example"));
        for sensitive in ["user:pass", "PATH_SECRET", "?x=1"] {
            assert!(
                !message.contains(sensitive),
                "message leaked {sensitive}: {message}"
            );
        }
    }

    #[tokio::test]
    async fn request_timeout_bounds_hung_endpoint() -> anyhow::Result<()> {
        use std::net::TcpListener;

        let listener = TcpListener::bind("127.0.0.1:0")?;
        let address = listener.local_addr()?;
        let handle = std::thread::spawn(move || {
            if let Ok((_stream, _address)) = listener.accept() {
                std::thread::sleep(Duration::from_millis(200));
            }
        });

        let client = BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: format!("http://{address}"),
                pgc_base: "http://127.0.0.1:1".to_owned(),
                intl_base: "http://127.0.0.1:1".to_owned(),
                comment_base: "http://127.0.0.1:1".to_owned(),
                passport_base: "http://127.0.0.1:1".to_owned(),
                tv_api_base: "http://127.0.0.1:1".to_owned(),
                app_grpc_base: "http://127.0.0.1:1".to_owned(),
                app_pgc_grpc_base: "http://127.0.0.1:1".to_owned(),
                tv_passport_base: "http://127.0.0.1:1".to_owned(),
                tv_passport_poll_base: "http://127.0.0.1:1".to_owned(),
            },
            credentials: Credentials::default(),
            restricted_area: RestrictedAreaConfig::default(),
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_millis(30),
        });

        let started = Instant::now();
        let Err(error) = client.resolve_input("av170001", None).await else {
            return Err(anyhow::anyhow!("hung endpoint should time out"));
        };
        let elapsed = started.elapsed();
        handle
            .join()
            .map_err(|_| anyhow::anyhow!("timeout test server panicked"))?;

        assert!(matches!(error, Error::Http(_)));
        assert!(elapsed < Duration::from_secs(1));
        Ok(())
    }

    #[tokio::test]
    async fn season_links_require_selection() -> anyhow::Result<()> {
        let server = MockServer::start();
        let client = test_client(&server);
        let error = client.resolve_input("ss123", None).await.err();
        assert!(matches!(
            error,
            Some(Error::SelectionRequired {
                input_kind: "season"
            })
        ));
        Ok(())
    }

    #[tokio::test]
    async fn resolves_season_latest() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("season_id", "123");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "media_id": 456,
                    "title": "A Season",
                    "evaluate": "Season desc",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 9000, "ep_id": 1000, "title": "1", "long_title": "Start"},
                        {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 9001, "ep_id": 1001, "title": "2", "long_title": "Next"}
                    ],
                    "areas": [{"name": "Japan"}],
                    "styles": ["Anime", "Action"]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input("ss123", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.title, "A Season");
                assert_eq!(season.season.tags, ["Anime", "Action"]);
                assert_eq!(season.selected_episodes.len(), 1);
                assert_eq!(season.selected_episodes[0].epid, 1001);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn resolves_episode_from_section() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "2000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "evaluate": "Season desc",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "title": "1", "long_title": "Start"}
                    ],
                    "section": [{
                        "title": "Extras",
                        "episodes": [
                            {"aid": 12, "bvid": "BV1cc", "cid": 102, "episode_id": 2000, "title": "SP", "long_title": "Special"}
                        ]
                    }]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client.resolve_input("ep2000", None).await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.episodes.len(), 2);
                assert_eq!(season.selected_episodes[0].epid, 2000);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn latest_ignores_section_extras() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("season_id", "123");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "title": "1"},
                        {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 1001, "title": "2"}
                    ],
                    "section": [{
                        "title": "Extras",
                        "episodes": [
                            {"aid": 12, "bvid": "BV1cc", "cid": 102, "id": 2000, "title": "PV"}
                        ]
                    }]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input("ss123", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.episodes.len(), 3);
                assert_eq!(season.season.main_episode_count, 2);
                assert_eq!(season.selected_episodes[0].epid, 1001);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[tokio::test]
    async fn latest_uses_filtered_main_episode_count() -> anyhow::Result<()> {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("season_id", "123");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"bvid": "BVbad", "cid": 99, "id": 999, "title": "invalid"},
                        {"aid": 11, "bvid": "BV1bb", "cid": 101, "id": 1001, "title": "2"}
                    ],
                    "section": [{
                        "title": "Extras",
                        "episodes": [
                            {"aid": 12, "bvid": "BV1cc", "cid": 102, "id": 2000, "title": "PV"}
                        ]
                    }]
                }
            }));
        });

        let client = test_client(&server);
        let resolved = client
            .resolve_input("ss123", Some(Selection::Latest))
            .await?;
        match resolved {
            ResolvedContent::Season(season) => {
                assert_eq!(season.season.main_episode_count, 1);
                assert_eq!(season.selected_episodes[0].epid, 1001);
            }
            ResolvedContent::Video(_) | ResolvedContent::Collection(_) => {
                return Err(anyhow::anyhow!("expected season"));
            }
        }
        Ok(())
    }

    #[test]
    fn endpoint_url_preserves_path_prefix() -> anyhow::Result<()> {
        let url =
            BiliClient::endpoint_url("http://proxy.example/bili/api", "/x/web-interface/view")?;
        assert_eq!(
            url.as_str(),
            "http://proxy.example/bili/api/x/web-interface/view"
        );
        Ok(())
    }

    fn test_client(server: &MockServer) -> BiliClient {
        BiliClient::new(ClientConfig {
            endpoints: EndpointConfig {
                api_base: server.base_url(),
                pgc_base: server.base_url(),
                intl_base: server.base_url(),
                comment_base: server.base_url(),
                passport_base: server.base_url(),
                tv_api_base: server.base_url(),
                app_grpc_base: server.base_url(),
                app_pgc_grpc_base: server.base_url(),
                tv_passport_base: server.base_url(),
                tv_passport_poll_base: server.base_url(),
            },
            credentials: Credentials::default(),
            restricted_area: RestrictedAreaConfig::default(),
            playurl_mode: PlayurlMode::Web,
            user_agent: "test".to_owned(),
            request_timeout: Duration::from_secs(30),
        })
    }

    fn mock_wbi_nav(server: &MockServer) {
        server.mock(|when, then| {
            when.method(GET).path("/x/web-interface/nav");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "wbi_img": {
                        "img_url": "https://i0.hdslb.com/bfs/wbi/0123456789abcdef0123456789abcdef.png",
                        "sub_url": "https://i0.hdslb.com/bfs/wbi/fedcba9876543210fedcba9876543210.png"
                    }
                }
            }));
        });
    }

    fn mock_favorite_collection(server: &MockServer) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v3/fav/resource/list")
                .query_param("media_id", "456")
                .query_param("pn", "1")
                .query_param("ps", "20")
                .query_param("type", "0");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "info": {
                        "media_count": 1,
                        "title": "Favorite",
                        "intro": "Favorite intro",
                        "cover": "https://example.invalid/favorite.jpg",
                        "ctime": 1_700_000_000,
                        "upper": {"mid": 1, "name": "Tester"}
                    },
                    "medias": [{
                        "id": 170_001,
                        "type": 2,
                        "bvid": "BV1xx411c7mD",
                        "title": "Saved video",
                        "intro": "Saved intro",
                        "cover": "https://example.invalid/saved.jpg",
                        "pubtime": 1_700_000_001,
                        "duration": 3,
                        "attr": 0,
                        "page": 1,
                        "upper": {"mid": 1, "name": "Tester"},
                        "ugc": {"first_cid": 9988}
                    }]
                }
            }));
        });
    }

    fn mock_favorite_collection_page(
        server: &MockServer,
        page_number: u32,
        media_count: usize,
        aid: u64,
        bvid: &str,
        title: &str,
        cid: u64,
    ) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/v3/fav/resource/list")
                .query_param("media_id", "456")
                .query_param("pn", page_number.to_string())
                .query_param("ps", "20")
                .query_param("type", "0");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "info": {
                        "media_count": media_count,
                        "title": "Favorite",
                        "intro": "Favorite intro",
                        "cover": "https://example.invalid/favorite.jpg",
                        "ctime": 1_700_000_000,
                        "upper": {"mid": 1, "name": "Tester"}
                    },
                    "medias": [{
                        "id": aid,
                        "type": 2,
                        "bvid": bvid,
                        "title": title,
                        "intro": "Saved intro",
                        "cover": "https://example.invalid/saved.jpg",
                        "pubtime": 1_700_000_001,
                        "duration": 3,
                        "attr": 0,
                        "page": 1,
                        "upper": {"mid": 1, "name": "Tester"},
                        "ugc": {"first_cid": cid}
                    }]
                }
            }));
        });
    }

    fn mock_history_page(
        server: &MockServer,
        max: u64,
        view_at: u64,
        business: &str,
        cursor: &serde_json::Value,
        list: &[serde_json::Value],
    ) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/web-interface/history/cursor")
                .query_param("max", max.to_string())
                .query_param("view_at", view_at.to_string())
                .query_param("business", business)
                .query_param("type", "archive")
                .query_param("ps", "20");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "cursor": cursor,
                    "list": list
                }
            }));
        });
    }

    fn mock_pgc_episode_metadata(server: &MockServer) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/pgc/view/web/season")
                .query_param("ep_id", "1000");
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "result": {
                    "season_id": 123,
                    "title": "A Season",
                    "episodes": [
                        {"aid": 10, "bvid": "BV1aa", "cid": 100, "id": 1000, "ep_id": 1000, "title": "1", "long_title": "Start"}
                    ]
                }
            }));
        });
    }

    fn server_mock_playurl(server: &MockServer, aid: u64, cid: u64, label: &str) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/playurl")
                .query_param("avid", aid.to_string())
                .query_param("cid", cid.to_string());
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {
                    "dash": {
                        "duration": 3,
                        "video": [{
                            "id": 80,
                            "baseUrl": format!("https://video.example/{label}.m4s"),
                            "base_url": format!("https://video.example/{label}.m4s")
                        }],
                        "audio": [{
                            "id": 30280,
                            "baseUrl": format!("https://audio.example/{label}.m4s"),
                            "base_url": format!("https://audio.example/{label}.m4s")
                        }]
                    }
                }
            }));
        });
    }

    fn server_mock_player_v2(server: &MockServer, aid: u64, cid: u64) {
        server.mock(|when, then| {
            when.method(GET)
                .path("/x/player/v2")
                .query_param("aid", aid.to_string())
                .query_param("cid", cid.to_string());
            then.status(200).json_body_obj(&serde_json::json!({
                "code": 0,
                "data": {"subtitle": {"subtitles": []}}
            }));
        });
    }

    fn collection_item(index: u32, title: &str) -> VideoCollectionItem {
        VideoCollectionItem {
            index,
            aid: 170_000 + u64::from(index),
            bvid: Some(format!("BV1mock{index}")),
            cid: 9_000 + u64::from(index),
            title: title.to_owned(),
            cover_url: None,
            description: String::new(),
            pub_time: None,
            owner: None,
            duration_seconds: None,
        }
    }

    fn page_metadata(index: u32) -> PageMetadata {
        PageMetadata {
            index,
            aid: 170_001,
            cid: 9_000 + u64::from(index),
            epid: None,
            title: format!("P{index}"),
            duration_seconds: None,
        }
    }

    fn episode_metadata(index: u32) -> EpisodeMetadata {
        EpisodeMetadata {
            index,
            aid: 170_000 + u64::from(index),
            bvid: Some(format!("BV1episode{index}")),
            cid: 9_000 + u64::from(index),
            epid: 1_000 + u64::from(index),
            title: index.to_string(),
            long_title: Some(format!("Episode {index}")),
            pub_time: None,
        }
    }
}
