<?php
namespace BLR\Backline\Core;

use BLR\Backline\Modules\Events\Module as EventsModule;

if (!defined('ABSPATH')) {
    exit;
}

/**
 * Public, theme-safe data access for Backline records.
 *
 * This layer intentionally returns arrays instead of markup so themes can build
 * their own front-end displays and audio players without depending on Backline's
 * dashboard views or dashboard asset stack.
 */
final class PublicApi {
    private const MAX_LIMIT = 100;

    /**
     * Query public-facing events and return normalized item arrays.
     *
     * Supported args:
     * - limit: int, default 10. Use -1 for all.
     * - page: int, default 1.
     * - timeframe: upcoming|past|all, default upcoming.
     * - status: confirmed|hold|cancelled|any, default confirmed.
     * - show_frontend: bool|null, default true. Null skips this filter.
     * - search: string.
     * - orderby: start|title|date|modified, default start.
     * - order: ASC|DESC, default ASC for upcoming, DESC for past.
     */
    public static function events(array $args = []): array {
        $query = self::event_query($args);
        $items = [];

        foreach ((array) $query->posts as $post) {
            if ($post instanceof \WP_Post) {
                $items[] = self::event_item((int) $post->ID, $post);
            }
        }

        wp_reset_postdata();

        /**
         * Filters normalized public event items returned by Backline.
         *
         * @param array     $items Normalized event arrays.
         * @param array     $args  Caller-supplied args.
         * @param \WP_Query $query Query object.
         */
        return (array) apply_filters('blr_backline_public_events', $items, $args, $query);
    }

    /**
     * Return one normalized event array, or an empty array when unavailable.
     *
     * By default this respects the public front-end flag so themes do not
     * accidentally expose dashboard-only events. Pass show_frontend => null to
     * skip that check, or false to fetch only hidden events.
     */
    public static function event(int $event_id, array $args = []): array {
        $event_id = absint($event_id);
        if ($event_id <= 0 || !post_type_exists('blr_event') || !SettingsPage::is_module_enabled('events')) {
            return [];
        }

        $post = get_post($event_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_event' || $post->post_status !== 'publish') {
            return [];
        }

        $args = wp_parse_args($args, [
            'show_frontend' => true,
            'status'        => '',
        ]);

        $show_frontend = self::nullable_bool($args['show_frontend']);
        if ($show_frontend !== null) {
            $is_public = (string) get_post_meta($event_id, 'blr_show_frontend', true) === '1';
            if ($is_public !== $show_frontend) {
                return [];
            }
        }

        $status = strtolower(trim(sanitize_text_field((string) $args['status'])));
        if ($status !== '' && $status !== 'any') {
            if (class_exists(EventsModule::class)) {
                $status = EventsModule::normalize_status($status);
            }

            if ((string) get_post_meta($event_id, 'blr_event_status', true) !== $status) {
                return [];
            }
        }

        return self::event_item($event_id, $post);
    }

    /**
     * Build and run the Backline public events query.
     */
    public static function event_query(array $args = []): \WP_Query {
        if (!post_type_exists('blr_event') || !SettingsPage::is_module_enabled('events')) {
            return self::empty_query('blr_event');
        }

        $defaults = [
            'limit'         => 10,
            'page'          => 1,
            'timeframe'     => 'upcoming',
            'status'        => 'confirmed',
            'show_frontend' => true,
            'search'        => '',
            'orderby'       => 'start',
            'order'         => '',
        ];
        $args = wp_parse_args($args, $defaults);

        $limit = self::normalize_limit($args['limit'], 10);
        $page = max(1, absint($args['page']));
        $timeframe = self::normalize_choice((string) $args['timeframe'], ['upcoming', 'past', 'all'], 'upcoming');
        $orderby = self::normalize_choice((string) $args['orderby'], ['start', 'title', 'date', 'modified'], 'start');
        $order = self::normalize_order((string) $args['order']);
        if ($order === '') {
            $order = $timeframe === 'past' ? 'DESC' : 'ASC';
        }

        $query_args = [
            'post_type'           => 'blr_event',
            'post_status'         => ['publish'],
            'posts_per_page'      => $limit,
            'paged'               => $page,
            'ignore_sticky_posts' => true,
            'no_found_rows'       => $limit === -1,
        ];

        $search = sanitize_text_field((string) $args['search']);
        if ($search !== '') {
            $query_args['s'] = $search;
        }

        $meta_query = [];
        $show_frontend = self::nullable_bool($args['show_frontend']);
        if ($show_frontend !== null) {
            $meta_query[] = [
                'key'   => 'blr_show_frontend',
                'value' => $show_frontend ? '1' : '0',
            ];
        }

        $status = strtolower(trim(sanitize_text_field((string) $args['status'])));
        if ($status !== '' && $status !== 'any') {
            if (class_exists(EventsModule::class)) {
                $status = EventsModule::normalize_status($status);
            }

            if ($status !== '') {
                $meta_query[] = [
                    'key'   => 'blr_event_status',
                    'value' => $status,
                ];
            }
        }

        if ($timeframe !== 'all') {
            $meta_query[] = [
                'key'     => 'blr_event_start',
                'value'   => current_time('Y-m-d H:i'),
                'compare' => $timeframe === 'past' ? '<' : '>=',
                'type'    => 'CHAR',
            ];
        }

        if (!empty($meta_query)) {
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Public event listings intentionally filter by event meta.
            $query_args['meta_query'] = PostQueries::and_meta_query($meta_query);
        }

        switch ($orderby) {
            case 'title':
                $query_args['orderby'] = 'title';
                break;
            case 'date':
                $query_args['orderby'] = 'date';
                break;
            case 'modified':
                $query_args['orderby'] = 'modified';
                break;
            case 'start':
            default:
                $query_args['meta_key'] = 'blr_event_start';
                $query_args['orderby'] = 'meta_value';
                break;
        }
        $query_args['order'] = $order;

        /**
         * Filters the WP_Query args used for public Backline event queries.
         *
         * @param array $query_args WP_Query args.
         * @param array $args       Caller-supplied args.
         */
        $query_args = (array) apply_filters('blr_backline_public_event_query_args', $query_args, $args);

        return new \WP_Query($query_args);
    }

    /**
     * Query albums/releases and return normalized item arrays for theme/front-end use.
     *
     * Supported args:
     * - limit: int, default 25. Use -1 for all.
     * - page: int, default 1.
     * - ids: int[]|comma-separated IDs.
     * - exclude_ids: int[]|comma-separated IDs.
     * - release_status: taxonomy slug, comma-separated slugs, array of slugs, or any.
     * - release_type: taxonomy slug, comma-separated slugs, array of slugs, or any.
     * - search: string.
     * - orderby: release_date|title|date|modified|post__in, default release_date.
     * - order: ASC|DESC, default DESC unless ids are supplied.
     */
    public static function albums(array $args = []): array {
        $query = self::album_query($args);
        $items = [];

        foreach ((array) $query->posts as $post) {
            if ($post instanceof \WP_Post) {
                $items[] = self::album_item((int) $post->ID, $post);
            }
        }

        wp_reset_postdata();

        /**
         * Filters normalized public album/release items returned by Backline.
         *
         * @param array     $items Normalized album arrays.
         * @param array     $args  Caller-supplied args.
         * @param \WP_Query $query Query object.
         */
        return (array) apply_filters('blr_backline_public_albums', $items, $args, $query);
    }

    /**
     * Alias for themes that prefer the internal Backline release terminology.
     */
    public static function releases(array $args = []): array {
        return self::albums($args);
    }

    /**
     * Build and run the Backline public albums/releases query.
     */
    public static function album_query(array $args = []): \WP_Query {
        if (!post_type_exists('blr_release') || !SettingsPage::is_module_enabled('discography')) {
            return self::empty_query('blr_release');
        }

        $defaults = [
            'limit'          => 25,
            'page'           => 1,
            'ids'            => [],
            'exclude_ids'    => [],
            'release_status' => 'released', // Default to only released albums unless overridden.
            'release_type'   => 'any',
            'search'         => '',
            'orderby'        => '',
            'order'          => '',
        ];
        $args = wp_parse_args($args, $defaults);

        $ids = self::id_list($args['ids']);
        $limit = self::normalize_limit($args['limit'], 25);
        $page = max(1, absint($args['page']));
        $exclude_ids = self::id_list($args['exclude_ids']);
        $orderby_default = !empty($ids) ? 'post__in' : 'release_date';
        $orderby = self::normalize_choice((string) ($args['orderby'] ?: $orderby_default), ['release_date', 'post__in', 'title', 'date', 'modified'], $orderby_default);
        $order = self::normalize_order((string) $args['order']);
        if ($order === '') {
            $order = in_array($orderby, ['post__in', 'title'], true) ? 'ASC' : 'DESC';
        }

        $query_args = [
            'post_type'           => 'blr_release',
            'post_status'         => ['publish'],
            'posts_per_page'      => $limit,
            'paged'               => $page,
            'ignore_sticky_posts' => true,
            'no_found_rows'       => $limit === -1,
        ];

        if (!empty($ids)) {
            $query_args['post__in'] = $ids;
        }

        if (!empty($exclude_ids)) {
            $query_args['post__not_in'] = $exclude_ids;
        }

        $search = sanitize_text_field((string) $args['search']);
        if ($search !== '') {
            $query_args['s'] = $search;
        }

        $tax_query = [];
        $release_status = self::term_slug_list($args['release_status']);
        if (!empty($release_status)) {
            $status_clause = self::tax_query_clause('blr_release_status', $release_status);
            if ($status_clause === false) {
                $query_args['post__in'] = [0];
            } elseif (is_array($status_clause)) {
                $tax_query[] = $status_clause;
            }
        }

        $release_type = self::term_slug_list($args['release_type']);
        if (!empty($release_type)) {
            $type_clause = self::tax_query_clause('blr_release_type', $release_type);
            if ($type_clause === false) {
                $query_args['post__in'] = [0];
            } elseif (is_array($type_clause)) {
                $tax_query[] = $type_clause;
            }
        }

        if (!empty($tax_query)) {
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Public album queries intentionally filter by release taxonomies.
            $query_args['tax_query'] = PostQueries::and_tax_query($tax_query);
        }

        switch ($orderby) {
            case 'post__in':
                $query_args['orderby'] = 'post__in';
                break;
            case 'title':
                $query_args['orderby'] = 'title';
                break;
            case 'modified':
                $query_args['orderby'] = 'modified';
                break;
            case 'date':
                $query_args['orderby'] = 'date';
                break;
            case 'release_date':
            default:
                $query_args['meta_key'] = 'blr_release_date';
                $query_args['orderby'] = 'meta_value';
                break;
        }
        $query_args['order'] = $order;

        /**
         * Filters the WP_Query args used for public Backline album/release queries.
         *
         * @param array $query_args WP_Query args.
         * @param array $args       Caller-supplied args.
         */
        $query_args = (array) apply_filters('blr_backline_public_album_query_args', $query_args, $args);

        return new \WP_Query($query_args);
    }

    /**
     * Alias for themes that prefer the internal Backline release terminology.
     */
    public static function release_query(array $args = []): \WP_Query {
        return self::album_query($args);
    }

    /**
     * Return one normalized album/release array, or an empty array when unavailable.
     */
    public static function album(int $album_id, array $args = []): array {
        $album_id = absint($album_id);
        if ($album_id <= 0 || !post_type_exists('blr_release') || !SettingsPage::is_module_enabled('discography')) {
            return [];
        }

        $post = get_post($album_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_release' || $post->post_status !== 'publish') {
            return [];
        }

        $args = wp_parse_args($args, [
            'release_status' => 'any',
            'release_type'   => 'any',
        ]);

        $release_status = self::term_slug_list($args['release_status']);
        if (!empty($release_status) && !self::post_has_any_term_slug($album_id, 'blr_release_status', $release_status)) {
            return [];
        }

        $release_type = self::term_slug_list($args['release_type']);
        if (!empty($release_type) && !self::post_has_any_term_slug($album_id, 'blr_release_type', $release_type)) {
            return [];
        }

        return self::album_item($album_id, $post);
    }

    /**
     * Alias for themes that prefer the internal Backline release terminology.
     */
    public static function release(int $release_id, array $args = []): array {
        return self::album($release_id, $args);
    }

    /**
     * Query tracks and return normalized item arrays for theme/front-end use.
     *
     * Supported args:
     * - limit: int, default 25. Use -1 for all.
     * - page: int, default 1.
     * - ids: int[]|comma-separated IDs.
     * - exclude_ids: int[]|comma-separated IDs.
     * - release_id: int. Preserves release track order by default.
     * - release_status: taxonomy slug, or any.
     * - has_audio: bool|null. True only returns tracks with audio URLs.
     * - search: string.
     * - orderby: release_order|track_number|release_date|title|date|modified|post__in, default date.
     * - order: ASC|DESC, default DESC unless release_id/ids are supplied.
     */
    public static function tracks(array $args = []): array {
        $query = self::track_query($args);
        $release_id = absint($args['release_id'] ?? 0);
        $items = [];

        foreach ((array) $query->posts as $post) {
            if ($post instanceof \WP_Post) {
                $items[] = self::track_item((int) $post->ID, $post, $release_id);
            }
        }

        wp_reset_postdata();

        /**
         * Filters normalized public track items returned by Backline.
         *
         * @param array     $items Normalized track arrays.
         * @param array     $args  Caller-supplied args.
         * @param \WP_Query $query Query object.
         */
        return (array) apply_filters('blr_backline_public_tracks', $items, $args, $query);
    }

    /**
     * Build and run the Backline public tracks query.
     */
    public static function track_query(array $args = []): \WP_Query {
        if (!post_type_exists('blr_track') || !SettingsPage::is_module_enabled('discography')) {
            return self::empty_query('blr_track');
        }

        $defaults = [
            'limit'          => 25,
            'page'           => 1,
            'ids'            => [],
            'exclude_ids'    => [],
            'release_id'     => 0,
            'release_status' => '',
            'has_audio'      => null,
            'search'         => '',
            'orderby'        => '',
            'order'          => '',
        ];
        $args = wp_parse_args($args, $defaults);

        $ids = self::id_list($args['ids']);
        $release_id = absint($args['release_id']);
        if ($release_id > 0) {
            $release_ids = self::release_track_ids($release_id);
            $ids = !empty($ids) ? array_values(array_intersect($release_ids, $ids)) : $release_ids;
        }

        $limit = self::normalize_limit($args['limit'], 25);
        $page = max(1, absint($args['page']));
        $exclude_ids = self::id_list($args['exclude_ids']);
        $orderby_default = !empty($ids) ? 'post__in' : 'date';
        $orderby = self::normalize_choice((string) ($args['orderby'] ?: $orderby_default), ['release_order', 'post__in', 'track_number', 'release_date', 'title', 'date', 'modified'], $orderby_default);
        if ($orderby === 'release_order') {
            $orderby = 'post__in';
        }

        $order = self::normalize_order((string) $args['order']);
        if ($order === '') {
            $order = in_array($orderby, ['post__in', 'track_number', 'title', 'release_date'], true) ? 'ASC' : 'DESC';
        }

        $query_args = [
            'post_type'           => 'blr_track',
            'post_status'         => ['publish'],
            'posts_per_page'      => $limit,
            'paged'               => $page,
            'ignore_sticky_posts' => true,
            'no_found_rows'       => $limit === -1,
        ];

        if ($release_id > 0 || !empty($ids)) {
            $query_args['post__in'] = !empty($ids) ? $ids : [0];
        }

        if (!empty($exclude_ids)) {
            $query_args['post__not_in'] = $exclude_ids;
        }

        $search = sanitize_text_field((string) $args['search']);
        if ($search !== '') {
            $query_args['s'] = $search;
        }

        $meta_query = [];
        $has_audio = self::nullable_bool($args['has_audio']);
        if ($has_audio === true) {
            $meta_query[] = [
                'key'     => 'blr_audio_url',
                'value'   => '',
                'compare' => '!=',
            ];
        } elseif ($has_audio === false) {
            $meta_query[] = [
                'relation' => 'OR',
                [
                    'key'     => 'blr_audio_url',
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key'     => 'blr_audio_url',
                    'value'   => '',
                    'compare' => '=',
                ],
            ];
        }

        if (!empty($meta_query)) {
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Public track queries intentionally filter by track meta.
            $query_args['meta_query'] = PostQueries::and_meta_query($meta_query);
        }

        $release_status = sanitize_key((string) $args['release_status']);
        if ($release_status !== '' && $release_status !== 'any' && taxonomy_exists('blr_release_status')) {
            $term = get_term_by('slug', $release_status, 'blr_release_status');
            if ($term && !is_wp_error($term)) {
                $query_args['tax_query'] = [
                    [
                        'taxonomy' => 'blr_release_status',
                        'field'    => 'slug',
                        'terms'    => [$release_status],
                    ],
                ];
            }
        }

        switch ($orderby) {
            case 'post__in':
                $query_args['orderby'] = 'post__in';
                break;
            case 'track_number':
                $query_args['meta_key'] = 'blr_track_number';
                $query_args['orderby'] = 'meta_value_num';
                break;
            case 'release_date':
                $query_args['meta_key'] = 'blr_release_date';
                $query_args['orderby'] = 'meta_value';
                break;
            case 'title':
                $query_args['orderby'] = 'title';
                break;
            case 'modified':
                $query_args['orderby'] = 'modified';
                break;
            case 'date':
            default:
                $query_args['orderby'] = 'date';
                break;
        }
        $query_args['order'] = $order;

        /**
         * Filters the WP_Query args used for public Backline track queries.
         *
         * @param array $query_args WP_Query args.
         * @param array $args       Caller-supplied args.
         */
        $query_args = (array) apply_filters('blr_backline_public_track_query_args', $query_args, $args);

        return new \WP_Query($query_args);
    }

    /**
     * Return one normalized track array, or an empty array when unavailable.
     *
     * Pass has_audio => true when a theme only wants playable tracks.
     */
    public static function track(int $track_id, array $args = []): array {
        $track_id = absint($track_id);
        if ($track_id <= 0 || !post_type_exists('blr_track') || !SettingsPage::is_module_enabled('discography')) {
            return [];
        }

        $post = get_post($track_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_track' || $post->post_status !== 'publish') {
            return [];
        }

        $args = wp_parse_args($args, [
            'has_audio'  => null,
            'release_id' => 0,
        ]);

        $has_audio = self::nullable_bool($args['has_audio']);
        $audio_url = (string) get_post_meta($track_id, 'blr_audio_url', true);
        if ($has_audio === true && $audio_url === '') {
            return [];
        }
        if ($has_audio === false && $audio_url !== '') {
            return [];
        }

        return self::track_item($track_id, $post, absint($args['release_id']));
    }

    /**
     * Return a release's tracks in the release-defined order.
     */
    public static function release_tracks(int $release_id, array $args = []): array {
        $args['release_id'] = $release_id;
        $args['orderby'] = $args['orderby'] ?? 'release_order';
        $args['order'] = $args['order'] ?? 'ASC';
        $args['limit'] = $args['limit'] ?? -1;

        return self::tracks($args);
    }

    /**
     * Convenience alias for themes building custom audio players.
     */
    public static function playlist(array $args = []): array {
        if (!array_key_exists('has_audio', $args)) {
            $args['has_audio'] = true;
        }

        return self::tracks($args);
    }

    public static function event_item(int $event_id, ?\WP_Post $post = null): array {
        $post = $post ?: get_post($event_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_event') {
            return [];
        }

        $start = (string) get_post_meta($event_id, 'blr_event_start', true);
        $end = (string) get_post_meta($event_id, 'blr_event_end', true);
        $status = (string) get_post_meta($event_id, 'blr_event_status', true);
        $city = (string) get_post_meta($event_id, 'blr_venue_city', true);
        $state = (string) get_post_meta($event_id, 'blr_venue_state', true);
        $image_id = absint(get_post_thumbnail_id($event_id));

        return [
            'id'                 => $event_id,
            'title'              => get_the_title($post),
            'slug'               => (string) $post->post_name,
            'description'        => (string) $post->post_content,
            'excerpt'            => has_excerpt($post) ? (string) get_the_excerpt($post) : '',
            'start'              => $start,
            'end'                => $end,
            'start_timestamp'    => self::timestamp($start),
            'end_timestamp'      => self::timestamp($end),
            'display_start'      => Format::datetime($start),
            'display_end'        => Format::datetime($end),
            'timezone'           => (string) get_post_meta($event_id, 'blr_timezone', true),
            'venue_name'         => (string) get_post_meta($event_id, 'blr_venue_name', true),
            'venue_website'      => (string) get_post_meta($event_id, 'blr_venue_website', true),
            'venue_address'      => (string) get_post_meta($event_id, 'blr_venue_address', true),
            'venue_city'         => $city,
            'venue_state'        => $state,
            'venue_postal'       => (string) get_post_meta($event_id, 'blr_venue_postal', true),
            'venue_country'      => (string) get_post_meta($event_id, 'blr_venue_country', true),
            'location'           => Format::city_state($city, $state),
            'full_location'      => Format::event_location($event_id),
            'ticket_url'         => (string) get_post_meta($event_id, 'blr_ticket_url', true),
            'event_url'          => (string) get_post_meta($event_id, 'blr_event_url', true),
            'status'             => $status,
            'status_label'       => class_exists(EventsModule::class) ? EventsModule::status_label($status) : ucfirst($status),
            'age_restriction'    => (string) get_post_meta($event_id, 'blr_age_restriction', true),
            'doors'              => (string) get_post_meta($event_id, 'blr_doors', true),
            'set_time'           => (string) get_post_meta($event_id, 'blr_set_time', true),
            'show_frontend'      => (string) get_post_meta($event_id, 'blr_show_frontend', true) === '1',
            'image_id'           => $image_id,
            'image_url'          => $image_id ? (string) wp_get_attachment_image_url($image_id, 'large') : '',
            'thumbnail_url'      => $image_id ? (string) wp_get_attachment_image_url($image_id, 'thumbnail') : '',
        ];
    }

    public static function album_item(int $album_id, ?\WP_Post $post = null): array {
        $post = $post ?: get_post($album_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_release') {
            return [];
        }

        $image_id = absint(get_post_thumbnail_id($album_id));
        $release_date = (string) get_post_meta($album_id, 'blr_release_date', true);
        $type_terms = self::taxonomy_term_data($album_id, 'blr_release_type');
        $status_terms = self::taxonomy_term_data($album_id, 'blr_release_status');
        $legacy_type = (string) get_post_meta($album_id, 'blr_release_type', true);
        $release_type = $type_terms['names'][0] ?? $legacy_type;
        $release_type_slug = $type_terms['slugs'][0] ?? sanitize_key($legacy_type);
        $release_status = $status_terms['names'][0] ?? '';
        $release_status_slug = $status_terms['slugs'][0] ?? '';
        $track_ids = self::id_list(get_post_meta($album_id, 'blr_track_ids', true));
        $image_url = $image_id ? (string) wp_get_attachment_image_url($image_id, 'large') : '';
        $thumbnail_url = $image_id ? (string) wp_get_attachment_image_url($image_id, 'thumbnail') : '';

        return [
            'id'                   => $album_id,
            'title'                => get_the_title($post),
            'display_title'        => get_the_title($post),
            'slug'                 => (string) $post->post_name,
            'description'          => (string) $post->post_content,
            'excerpt'              => has_excerpt($post) ? (string) get_the_excerpt($post) : '',
            'artist'               => (string) get_post_meta($album_id, 'blr_artist', true),
            'release_date'         => $release_date,
            'release_timestamp'    => self::timestamp($release_date),
            'display_release_date' => Format::date($release_date),
            'release_type'         => $release_type,
            'release_type_slug'    => $release_type_slug,
            'release_type_names'   => $type_terms['names'],
            'release_type_slugs'   => $type_terms['slugs'],
            'release_status'       => $release_status,
            'release_status_slug'  => $release_status_slug,
            'release_status_names' => $status_terms['names'],
            'release_status_slugs' => $status_terms['slugs'],
            'label'                => (string) get_post_meta($album_id, 'blr_label', true),
            'catalog_number'       => (string) get_post_meta($album_id, 'blr_catalog_number', true),
            'upc'                  => (string) get_post_meta($album_id, 'blr_upc', true),
            'genre'                => (string) get_post_meta($album_id, 'blr_genre', true),
            'subgenre'             => (string) get_post_meta($album_id, 'blr_subgenre', true),
            'mood'                 => (string) get_post_meta($album_id, 'blr_mood', true),
            'description_short'    => (string) get_post_meta($album_id, 'blr_description_short', true),
            'credits'              => (string) get_post_meta($album_id, 'blr_credits', true),
            'presskit_url'         => (string) get_post_meta($album_id, 'blr_presskit_url', true),
            'spotify_url'          => (string) get_post_meta($album_id, 'blr_spotify_url', true),
            'apple_music_url'      => (string) get_post_meta($album_id, 'blr_apple_music_url', true),
            'bandcamp_url'         => (string) get_post_meta($album_id, 'blr_bandcamp_url', true),
            'youtube_url'          => (string) get_post_meta($album_id, 'blr_youtube_url', true),
            'soundcloud_url'       => (string) get_post_meta($album_id, 'blr_soundcloud_url', true),
            'release_notes'        => (string) get_post_meta($album_id, 'blr_release_notes', true),
            'image_id'             => $image_id,
            'image_url'            => $image_url,
            'thumbnail_url'        => $thumbnail_url,
            'cover_url'            => $image_url,
            'cover_thumbnail_url'  => $thumbnail_url,
            'track_ids'            => $track_ids,
            'track_count'          => count($track_ids),
        ];
    }

    public static function track_item(int $track_id, ?\WP_Post $post = null, int $release_id = 0): array {
        $post = $post ?: get_post($track_id);
        if (!$post instanceof \WP_Post || $post->post_type !== 'blr_track') {
            return [];
        }

        $image_id = absint(get_post_thumbnail_id($track_id));
        $status_terms = taxonomy_exists('blr_release_status') ? wp_get_object_terms($track_id, 'blr_release_status') : [];
        $status_names = [];
        $status_slugs = [];
        if (!is_wp_error($status_terms)) {
            foreach ((array) $status_terms as $term) {
                if ($term instanceof \WP_Term) {
                    $status_names[] = (string) $term->name;
                    $status_slugs[] = (string) $term->slug;
                }
            }
        }

        $release = $release_id > 0 ? get_post($release_id) : null;
        $release_image_id = ($release instanceof \WP_Post && $release->post_type === 'blr_release') ? absint(get_post_thumbnail_id($release_id)) : 0;
        $release_artist = $release instanceof \WP_Post ? (string) get_post_meta($release_id, 'blr_artist', true) : '';
        $artist = (string) get_post_meta($track_id, 'blr_artist', true);

        return [
            'id'                    => $track_id,
            'title'                 => get_the_title($post),
            'display_title'         => get_the_title($post),
            'slug'                  => (string) $post->post_name,
            'description'           => (string) $post->post_content,
            'excerpt'               => has_excerpt($post) ? (string) get_the_excerpt($post) : '',
            'artist'                => $artist !== '' ? $artist : $release_artist,
            'track_number'          => (string) get_post_meta($track_id, 'blr_track_number', true),
            'disc_number'           => (string) get_post_meta($track_id, 'blr_disc_number', true),
            'duration'              => (string) get_post_meta($track_id, 'blr_duration', true),
            'bpm'                   => (string) get_post_meta($track_id, 'blr_bpm', true),
            'key'                   => (string) get_post_meta($track_id, 'blr_key', true),
            'isrc'                  => (string) get_post_meta($track_id, 'blr_isrc', true),
            'upc'                   => (string) get_post_meta($track_id, 'blr_upc', true),
            'explicit'              => (string) get_post_meta($track_id, 'blr_explicit', true),
            'instrumental'          => (string) get_post_meta($track_id, 'blr_instrumental', true),
            'lyrics'                => (string) get_post_meta($track_id, 'blr_lyrics', true),
            'credits'               => (string) get_post_meta($track_id, 'blr_credits', true),
            'audio_url'             => (string) get_post_meta($track_id, 'blr_audio_url', true),
            'audio_attachment_id'   => absint(get_post_meta($track_id, 'blr_audio_attachment_id', true)),
            'preview_start'         => (string) get_post_meta($track_id, 'blr_preview_start', true),
            'notes'                 => (string) get_post_meta($track_id, 'blr_notes', true),
            'release_date'          => (string) get_post_meta($track_id, 'blr_release_date', true),
            'release_status_names'  => $status_names,
            'release_status_slugs'  => $status_slugs,
            'spotify_url'           => (string) get_post_meta($track_id, 'blr_spotify_url', true),
            'apple_music_url'       => (string) get_post_meta($track_id, 'blr_apple_music_url', true),
            'bandcamp_url'          => (string) get_post_meta($track_id, 'blr_bandcamp_url', true),
            'youtube_url'           => (string) get_post_meta($track_id, 'blr_youtube_url', true),
            'soundcloud_url'        => (string) get_post_meta($track_id, 'blr_soundcloud_url', true),
            'release_notes'         => (string) get_post_meta($track_id, 'blr_release_notes', true),
            'image_id'              => $image_id,
            'image_url'             => $image_id ? (string) wp_get_attachment_image_url($image_id, 'large') : '',
            'thumbnail_url'         => $image_id ? (string) wp_get_attachment_image_url($image_id, 'thumbnail') : '',
            'release_id'            => $release instanceof \WP_Post ? $release_id : 0,
            'release_title'         => $release instanceof \WP_Post ? get_the_title($release) : '',
            'release_artist'        => $release_artist,
            'release_cover_url'     => $release_image_id ? (string) wp_get_attachment_image_url($release_image_id, 'large') : '',
            'release_thumbnail_url' => $release_image_id ? (string) wp_get_attachment_image_url($release_image_id, 'thumbnail') : '',
        ];
    }

    private static function tax_query_clause(string $taxonomy, array $slugs) {
        if (!taxonomy_exists($taxonomy)) {
            return false;
        }

        $valid_slugs = [];
        foreach ($slugs as $slug) {
            $term = get_term_by('slug', $slug, $taxonomy);
            if ($term instanceof \WP_Term) {
                $valid_slugs[] = (string) $term->slug;
            }
        }

        $valid_slugs = array_values(array_unique($valid_slugs));
        if (empty($valid_slugs)) {
            return false;
        }

        return [
            'taxonomy' => $taxonomy,
            'field'    => 'slug',
            'terms'    => $valid_slugs,
        ];
    }

    private static function post_has_any_term_slug(int $post_id, string $taxonomy, array $slugs): bool {
        if ($post_id <= 0 || empty($slugs) || !taxonomy_exists($taxonomy)) {
            return false;
        }

        foreach ($slugs as $slug) {
            if (has_term($slug, $taxonomy, $post_id)) {
                return true;
            }
        }

        return false;
    }

    private static function taxonomy_term_data(int $post_id, string $taxonomy): array {
        $data = [
            'names' => [],
            'slugs' => [],
        ];

        if ($post_id <= 0 || !taxonomy_exists($taxonomy)) {
            return $data;
        }

        $terms = wp_get_object_terms($post_id, $taxonomy);
        if (is_wp_error($terms)) {
            return $data;
        }

        foreach ((array) $terms as $term) {
            if ($term instanceof \WP_Term) {
                $data['names'][] = (string) $term->name;
                $data['slugs'][] = (string) $term->slug;
            }
        }

        return $data;
    }

    private static function term_slug_list($value): array {
        if (is_string($value)) {
            $value = preg_split('/[\s,]+/', $value) ?: [];
        }

        if (!is_array($value)) {
            return [];
        }

        $slugs = [];
        foreach ($value as $slug) {
            $slug = sanitize_key((string) $slug);
            if ($slug !== '' && $slug !== 'any') {
                $slugs[] = $slug;
            }
        }

        return array_values(array_unique($slugs));
    }

    private static function release_track_ids(int $release_id): array {
        $release = get_post($release_id);
        if (!$release instanceof \WP_Post || $release->post_type !== 'blr_release') {
            return [];
        }

        return self::id_list(get_post_meta($release_id, 'blr_track_ids', true));
    }

    private static function empty_query(string $post_type): \WP_Query {
        return new \WP_Query([
            'post_type'      => $post_type,
            'post__in'       => [0],
            'posts_per_page' => 0,
            'no_found_rows'  => true,
        ]);
    }

    private static function normalize_limit($value, int $default): int {
        $limit = is_numeric($value) ? (int) $value : $default;
        if ($limit === -1) {
            return -1;
        }

        if ($limit <= 0) {
            return $default;
        }

        return min($limit, self::MAX_LIMIT);
    }

    private static function normalize_order(string $value): string {
        $value = strtoupper(trim($value));
        return in_array($value, ['ASC', 'DESC'], true) ? $value : '';
    }

    private static function normalize_choice(string $value, array $allowed, string $default): string {
        $value = sanitize_key($value);
        return in_array($value, $allowed, true) ? $value : $default;
    }

    private static function nullable_bool($value): ?bool {
        if ($value === null || $value === '') {
            return null;
        }

        if (is_bool($value)) {
            return $value;
        }

        if (is_numeric($value)) {
            return ((int) $value) === 1;
        }

        $value = strtolower(trim((string) $value));
        if (in_array($value, ['1', 'true', 'yes', 'on'], true)) {
            return true;
        }

        if (in_array($value, ['0', 'false', 'no', 'off'], true)) {
            return false;
        }

        return null;
    }

    private static function id_list($value): array {
        if (is_string($value)) {
            $value = preg_split('/[\s,]+/', $value) ?: [];
        }

        if (!is_array($value)) {
            return [];
        }

        $ids = [];
        foreach ($value as $id) {
            $id = absint($id);
            if ($id > 0) {
                $ids[] = $id;
            }
        }

        return array_values(array_unique($ids));
    }

    private static function timestamp(string $value): int {
        $value = trim($value);
        if ($value === '') {
            return 0;
        }

        $timestamp = strtotime($value);
        return $timestamp ? (int) $timestamp : 0;
    }
}
