<?php
/**
 * File Path: includes/helpers/class-mbs-icon.php
 *
 * MBS icon rendering helper.
 *
 * Single Responsibility: render registered MBS icons safely.
 * The class is autoloaded when MBS_Icon:: is first referenced. However,
 * mbs_icon() is a standalone function — PHP class autoloading does NOT fire
 * on function calls. class-mbs-icon.php MUST also be explicitly require_once'd
 * in bookshowroom.php to guarantee mbs_icon() is available in templates (SPEC7-002).
 * Never instantiate directly.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

/**
 * MBS icon rendering helper.
 *
 * Single Responsibility: render registered MBS icons safely.
 * The class is autoloaded when MBS_Icon:: is first referenced. However,
 * mbs_icon() is a standalone function — PHP class autoloading does NOT fire
 * on function calls. class-mbs-icon.php MUST also be explicitly require_once'd
 * in bookshowroom.php to guarantee mbs_icon() is available in templates (SPEC7-002).
 * Never instantiate directly.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */
final class MBS_Icon { // Final: security-critical class. Extend via filter hooks, not inheritance.

    /**
     * Registry of all valid icon slugs mapped to their filenames.
     * Validate $slug against this constant before any path construction.
     * Add new slugs here when new icons are added to assets/icons/.
     *
     * @since 1.0.0
     */
    private const REGISTRY = [
        // A-1 — Admin Settings Tab Icons (10)
        'tab-general'              => 'tab-general.svg',
        'tab-appearance'           => 'tab-appearance.svg',
        'tab-formatting'           => 'tab-formatting.svg',
        'tab-dashboard'            => 'tab-dashboard.svg',
        'tab-catalog'              => 'tab-catalog.svg',
        'tab-import-export'        => 'tab-import-export.svg',
        'tab-shortcodes'           => 'tab-shortcodes.svg',
        'tab-promote'              => 'tab-promote.svg',
        'tab-publisher-tools'      => 'tab-publisher-tools.svg',
        'tab-advanced'             => 'tab-advanced.svg',
        // A-2 — Catalog Entity Icons (5)
        'entity-authors'           => 'entity-authors.svg',
        'entity-series'            => 'entity-series.svg',
        'entity-collections'       => 'entity-collections.svg',
        'entity-universes'         => 'entity-universes.svg',
        'entity-reading-order'     => 'entity-reading-order.svg',
        // A-3 — Book Format Icons (6)
        'format-hardcover'         => 'format-hardcover.svg',
        'format-paperback'         => 'format-paperback.svg',
        'format-ebook'             => 'format-ebook.svg',
        'format-audiobook'         => 'format-audiobook.svg',
        'format-board-book'        => 'format-board-book.svg',
        'format-special'           => 'format-special.svg',
        // A-4 — Book Section Icons (17: 11 on disk + 1 reuse alias + 5 pending creation per SPEC10-001)
        'section-about-the-book'      => 'section-about-the-book.svg',
        'section-look-inside'         => 'section-look-inside.svg',
        'section-endorsements'        => 'section-endorsements.svg',
        'section-reviews'             => 'section-reviews.svg',
        'section-related-books'       => 'section-related-books.svg',
        'section-reader-platforms'    => 'section-reader-platforms.svg',
        'section-awards-accolades'    => 'section-awards-accolades.svg',
        'section-audio-sample'        => 'section-audio-sample.svg',
        'section-amazon-reviews'      => 'section-amazon-reviews.svg',
        'section-kindle-preview'      => 'section-kindle-preview.svg',
        'section-book-club-resources' => 'section-book-club-resources.svg',
        'section-series-order'        => 'entity-reading-order.svg',     // Reused from A-2
        // A-4 sections added per SPEC10-001 (Pass 10, 2026-05-15) — SVG files pending creation
        'section-book-details'          => 'section-book-details.svg',
        'section-more-in-collection'    => 'section-more-in-collection.svg',
        'section-more-in-universe'      => 'section-more-in-universe.svg',
        'section-more-by-author'        => 'section-more-by-author.svg',
        'section-trailer'               => 'section-trailer.svg',
        // A-5 — Dashboard Hub Action Icons (8)
        'action-add-book'          => 'action-add-book.svg',
        'action-import'            => 'action-import.svg',
        'action-appearance'        => 'action-appearance.svg',
        'action-settings'          => 'action-settings.svg',
        'action-preview'           => 'action-preview.svg',
        'action-analytics'         => 'action-analytics.svg',
        'action-reading-lists'     => 'action-reading-lists.svg',
        'action-tutorial'          => 'action-tutorial.svg',
        // A-6 — Import/Export Scenario Card Icons (5)
        'scenario-run-migrations'  => 'scenario-run-migrations.svg',
        'scenario-csv-import'      => 'scenario-csv-import.svg',
        'scenario-export-archive'  => 'scenario-export-archive.svg',
        'scenario-amazon-lookup'   => 'scenario-amazon-lookup.svg',
        'scenario-search-discover' => 'scenario-search-discover.svg',
        // A-7 — Tier / License Badge Icons (3)
        'badge-free'               => 'badge-free.svg',
        'badge-pro'                => 'badge-pro.svg',
        'badge-pub'                => 'badge-pub.svg',
        // A-8 — Miscellaneous Brand Surfaces (11)
        'misc-pro-lock'            => 'misc-pro-lock.svg',
        'misc-coming-soon'         => 'misc-coming-soon.svg',
        'misc-bestseller'          => 'misc-bestseller.svg',
        'misc-series-position'     => 'misc-series-position.svg',
        'misc-arc-invite'          => 'misc-arc-invite.svg',
        'misc-arc-campaign-active' => 'misc-arc-campaign-active.svg',
        'misc-wizard-welcome'      => 'misc-wizard-welcome.svg',
        'misc-wizard-books'        => 'misc-wizard-books.svg',
        'misc-wizard-appearance'   => 'misc-wizard-appearance.svg',
        'misc-wizard-complete'     => 'misc-wizard-complete.svg',
        'mbs-mark'                 => 'mbs-mark.svg',
        // A-9 — Book Editor Metabox Domain Tab Icons (10)
        'editor-core-book-info'     => 'editor-core-book-info.svg',
        'editor-book-details'       => 'editor-book-details.svg',
        'editor-reader-links'       => 'editor-reader-links.svg',
        'editor-formats'            => 'editor-formats.svg',
        'editor-publication-status' => 'editor-publication-status.svg',
        'editor-book-club'          => 'section-book-club-resources.svg', // Reused from A-4
        'editor-arc'                => 'editor-arc.svg',
        'editor-classification'     => 'editor-classification.svg',
        'editor-section-order'      => 'editor-section-order.svg',
        'editor-onix'               => 'editor-onix.svg',
        // A-9 — ONIX Sub-section Tab Icons (2)
        'onix-record'               => 'onix-record.svg',
        'onix-product-relations'    => 'onix-product-relations.svg',
        // A-9 — Core Book Info Sub-section Tab Icons (4)
        'cbi-main'                  => 'cbi-main.svg',
        'cbi-media'                 => 'cbi-media.svg',
        'cbi-awards'                => 'section-awards-accolades.svg',    // Reused from A-4
        'cbi-admin'                 => 'cbi-admin.svg',
        // A-9 — Book Details Sub-section Tab Icons (2)
        'bkd-discovery'             => 'bkd-discovery.svg',
        'bkd-details'               => 'bkd-details.svg',
        // A-9 — Reader Links Sub-section Tab Icons (2)
        'rl-platforms'              => 'section-reader-platforms.svg',    // Reused from A-4
        'rl-buy-links'              => 'rl-buy-links.svg',
        // A-9 — Publication & Status Sub-section Tab Icons (3)
        'ps-publication'            => 'ps-publication.svg',
        'ps-contributors'           => 'ps-contributors.svg',
        'ps-publisher'              => 'tab-publisher-tools.svg',         // Reused from A-1
        // A-9 — Classification Sub-section Tab Icons (3)
        'cls-codes'             => 'cls-codes.svg',
        'cls-target-audience'   => 'cls-target-audience.svg',
        'cls-trade-data'        => 'cls-trade-data.svg',
    ];

    /**
     * wp_kses() whitelist for inline SVG sanitization.
     * Elements and attributes are limited to those actually used in assets/icons/.
     * script, use, foreignObject, a, and all event handlers are intentionally excluded.
     * Defined as a class constant so it is not reconstructed on every render() call.
     * Referenced in render() as self::SVG_KSES.
     *
     * @since 1.0.0
     */
    private const SVG_KSES = [
        'svg'      => [ 'viewbox' => true, 'xmlns' => true, 'width' => true,
                        'height' => true, 'aria-hidden' => true, 'aria-labelledby' => true,
                        'role' => true, 'class' => true ],
        'title'    => [ 'id' => true ],
        'defs'     => [],
        'clipPath' => [ 'id' => true ],      // SA-001: used in editor-reader-links.svg (clip-path interlock)
        'g'        => [ 'transform' => true, 'opacity' => true, 'fill' => true,
                        'clip-path' => true ],
        'path'     => [ 'd' => true, 'fill' => true, 'opacity' => true,
                        'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true,
                        'stroke-linejoin' => true ],
        'circle'   => [ 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true,
                        'opacity' => true, 'stroke' => true, 'stroke-width' => true ],
        'rect'     => [ 'x' => true, 'y' => true, 'width' => true, 'height' => true,
                        'rx' => true, 'ry' => true, 'fill' => true, 'opacity' => true,
                        'transform' => true, 'clip-path' => true ],
        'line'     => [ 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true,
                        'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true,
                        'opacity' => true ],
        'polyline' => [ 'points' => true, 'fill' => true, 'stroke' => true,
                        'stroke-width' => true, 'stroke-linecap' => true,
                        'stroke-linejoin' => true, 'opacity' => true ],
        'polygon'  => [ 'points' => true, 'fill' => true, 'opacity' => true ],
    ];

    /**
     * Prevent instantiation. MBS_Icon is a static utility class.
     * All access is via MBS_Icon::render() or the mbs_icon() wrapper.
     * A private constructor is the PHP enforcement; the comment alone is insufficient.
     */
    private function __construct() {}

    /**
     * Render a registered MBS icon as an HTML string.
     *
     * @param string $slug  Icon slug — must be a key in self::REGISTRY.
     * @param array  $args {
     *     @type string $class       CSS class(es) for the wrapper element.
     *     @type string $aria_label  Accessible label. If empty, aria-hidden="true" is set.
     *     @type string $size        CSS width/height value (e.g. '20px'). Default '24px'.
     *     @type string $mode        'img' (default) or 'inline'. Inline mode is admin-context
     *                               only. Never pass 'inline' on a front-end (public) page.
     *                               Front-end icons (A-3 format badges, A-7 badges, A-8 misc
     *                               front-end) MUST use 'img'. Unknown values fall back to 'img'.
     * }
     * @since  1.0.0
     * @return string Sanitized HTML. Empty string if slug is not registered.
     */
    public static function render( string $slug, array $args = [] ): string {
        if ( ! array_key_exists( $slug, self::REGISTRY ) ) {
            _doing_it_wrong( __METHOD__, 'Icon slug not found: ' . esc_attr( $slug ), '4.34.0' );
            return '';
        }

        // Sanitize and extract $args — MUST run before any output.
        $mode_raw   = $args['mode'] ?? 'img';
        $mode       = in_array( $mode_raw, [ 'img', 'inline' ], true ) ? $mode_raw : 'img';
        // SPEC5-003 / SPEC3-002: runtime enforcement of Security Req 7 two-layer requirement.
        // Degrades $mode to 'img' on public pages — the class-level layer of the two-layer defense.
        // Callers MUST NOT rely on this guard as the primary control — see Security Req 7.
        if ( 'inline' === $mode && ! is_admin() ) {
            $mode = 'img';
        }
        $aria_label = isset( $args['aria_label'] ) ? sanitize_text_field( $args['aria_label'] ) : '';
        $size_raw   = $args['size'] ?? '24px';
        $size       = ( 1 === preg_match( '/^\d+(\.\d+)?(px|em|rem|%)$/', $size_raw ) )
                        ? $size_raw : '24px';
        $class_raw  = $args['class'] ?? '';
        // array_filter() strips empty tokens produced by consecutive spaces in $class_raw.
        $class      = implode( ' ', array_filter( array_map( 'sanitize_html_class', explode( ' ', $class_raw ) ) ) );

        $filename   = self::REGISTRY[ $slug ]; // Registry validated above — safe to access directly.

        // SA-002: file-existence check — registered-but-missing SVG files produce silent
        // broken <img> in img mode. Fires for the 5 pending A-4 section icons until delivered.
        if ( ! file_exists( MBS_ASSETS_DIR . 'icons/' . $filename ) ) {
            _doing_it_wrong( __METHOD__, 'MBS_Icon::render(): registered icon file missing: ' . esc_attr( $filename ), '4.34.0' );
        }

        // Build shared HTML attribute strings used by both output modes.
        $class_attr = trim( 'mbs-icon ' . $class );
        $style_attr = 'width:' . $size . ';height:' . $size . ';';
        $aria_attrs = self::build_aria_attrs( $aria_label );

        if ( 'inline' === $mode ) {
            // Inline SVG — admin context only (enforced above by is_admin() check).
            // Initialize WP_Filesystem only if not already available.
            global $wp_filesystem;
            if ( empty( $wp_filesystem ) ) {
                if ( ! function_exists( 'WP_Filesystem' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/file.php';
                }
                WP_Filesystem();
            }
            // SPEC5-009: verify 'direct' filesystem method before attempting a local file read.
            // FTP/SSH-FTP methods cannot safely read plugin assets at runtime without
            // triggering a credential-collection flow. Degrade to img mode if not direct.
            if ( $wp_filesystem instanceof WP_Filesystem_Base && 'direct' === $wp_filesystem->method ) {
                $file_path   = MBS_ASSETS_DIR . 'icons/' . $filename;
                $svg_content = $wp_filesystem->get_contents( $file_path );
                if ( ! empty( $svg_content ) ) {
                    return '<span class="' . esc_attr( $class_attr ) . '" '
                        . 'style="' . esc_attr( $style_attr ) . '" '
                        . $aria_attrs . '>'
                        . wp_kses( $svg_content, self::SVG_KSES )
                        . '</span>';
                }
            }
            // WP_Filesystem unavailable or not 'direct' — fall through to img mode below.
        }

        // img mode — default, and graceful fallback from failed inline reads.
        // $filename comes from self::REGISTRY — validated by array_key_exists() above; safe.
        // SA-006: img elements have implicit role="img"; use alt attribute only.
        // Decorative (empty aria_label): alt="" + aria-hidden="true" (belt-and-suspenders).
        // Meaningful (non-empty aria_label): alt="[label]" only — no role or aria-label needed.
        $src = esc_url( MBS_ASSETS_URL . 'icons/' . $filename );
        return '<img src="' . $src . '" '
            . 'width="24" height="24" ' // SA-005: CLS prevention; CSS style overrides at render time
            . 'alt="' . esc_attr( $aria_label ) . '" '
            . 'class="' . esc_attr( $class_attr ) . '" '
            . 'style="' . esc_attr( $style_attr ) . '" '
            . ( '' === $aria_label ? 'aria-hidden="true"' : '' ) . '>';
    }

    /**
     * Build pre-escaped aria attribute string for inline SVG <span> wrappers.
     *
     * Used by the inline SVG branch of render() only. <span> has no implicit ARIA
     * role, so role="img" + aria-label is required for meaningful icons, and
     * aria-hidden="true" for decorative icons. DO NOT use for <img> elements —
     * native img has implicit role="img" and uses the alt attribute for its label
     * (SA-006: render() img mode uses alt + conditional aria-hidden instead).
     *
     * $aria_label MUST be run through sanitize_text_field() before passing.
     * This method applies esc_attr() only — call sites MUST NOT add further escaping.
     *
     * @param  string $aria_label Sanitized accessible label. Empty = decorative icon.
     * @return string Pre-escaped HTML attribute string for <span> concatenation.
     * @since  1.0.0
     */
    private static function build_aria_attrs( string $aria_label ): string {
        return $aria_label
            ? 'role="img" aria-label="' . esc_attr( $aria_label ) . '"' 
            : 'aria-hidden="true"';
    }
}

/**
 * Convenience wrapper for MBS_Icon::render().
 * Use in templates for brevity. The class method is the canonical interface.
 *
 * @param string $slug Icon slug.
 * @param array  $args See MBS_Icon::render() $args.
 * @return string Sanitized HTML.
 * @since 1.0.0
 */
if ( ! function_exists( 'mbs_icon' ) ) {
    function mbs_icon( string $slug, array $args = [] ): string { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
        return MBS_Icon::render( $slug, $args );
    }
}
