<?php
namespace EncypherProvenance;

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

/**
 * PRD E4: opt-in "Ad Layouts" mode.
 *
 * Renders fenced, enumerable ad containers and registers each activated slot
 * as an AdCP property placement through the enrollment API (PRD E1). This
 * class NEVER injects third-party ad JavaScript, never fetches a creative, and
 * never counts an impression: it emits a container and, when the publisher has
 * pasted their own ad-server tag for that slot, that tag verbatim inside the
 * container. Fulfillment stays with the publisher's ad server or a contracted
 * connector.
 *
 * Trust model for pasted tags: the same trust class as WordPress core's own
 * Custom HTML widget. A stored tag is output raw, so writing one requires the
 * `unfiltered_html` capability in addition to the `manage_options` capability
 * and settings nonce that guard the options page. On multisite that means
 * super admins only, which is exactly core's Custom HTML boundary. Users who
 * lack the capability keep the stored value untouched and see an error.
 *
 * Everything is OFF by default and fails closed: with the mode disabled no
 * hook renders anything and no request is made; with no enrollment id the mode
 * renders locally and registers nothing.
 */
final class AdLayouts
{
    /** Layout-mode settings option (separate from encypher_provenance_settings). */
    public const OPTION = 'encypher_ad_layouts_settings';

    /** Placement registration ledger: placement_key => sync record. */
    public const STATE_OPTION = 'encypher_ad_layouts_placements';

    public const SETTINGS_GROUP = 'encypher_ad_layouts_group';
    public const MENU_SLUG = 'encypher-ad-layouts';
    public const SYNC_HOOK = 'encypher_ad_layouts_sync';
    public const BLOCK_NAME = 'encypher/ad-slot';

    private const DEMO_POST_META = '_encypher_ad_layouts_demo_article';
    private const DEMO_ASSET_META = '_encypher_ad_layouts_demo_asset';
    private const DEMO_VERSION = '1';
    /** `^[a-z0-9][a-z0-9-]{1,62}$` — the frozen cross-slice placement_key format. */
    public const PLACEMENT_KEY_PATTERN = '/^[a-z0-9][a-z0-9-]{1,62}$/';

    /** Bounded retry budget for placement registration. */
    private const MAX_SYNC_ATTEMPTS = 8;

    /** Upper bound on a stored ad tag. Generous for GPT/GAM snippets. */
    public const MAX_AD_TAG_BYTES = 20000;

    /** Server accepts at most 64 placements per bulk upsert (W1 contract). */
    private const MAX_PLACEMENTS_PER_CALL = 64;

    /**
     * The four PRD layout presets. `format_id` values come from the AdCP
     * creative-format id namespace already used in the product-projection
     * fixtures (`display_{W}x{H}`); the accepted set is exactly these four.
     */
    private const LAYOUTS = [
        'leaderboard_top' => [
            'slug' => 'leaderboard-top',
            'format_id' => 'display_728x90',
        ],
        'in_content' => [
            'slug' => 'in-content',
            'format_id' => 'display_300x250',
        ],
        'sidebar_rail' => [
            'slug' => 'sidebar-rail',
            'format_id' => 'display_300x600',
        ],
        'sticky_footer' => [
            'slug' => 'sticky-footer',
            'format_id' => 'display_320x50',
        ],
    ];

    private const DEFAULT_IN_CONTENT_EVERY_N = 3;
    private const MIN_IN_CONTENT_EVERY_N = 1;
    private const MAX_IN_CONTENT_EVERY_N = 20;

    // -----------------------------------------------------------------------
    // Pure contract helpers (no WordPress I/O; unit-tested standalone)
    // -----------------------------------------------------------------------

    /** @return string[] The layout_key enum, in presentation order. */
    public static function layout_keys(): array
    {
        return array_keys(self::LAYOUTS);
    }

    public static function is_layout_key(string $layout_key): bool
    {
        return isset(self::LAYOUTS[$layout_key]);
    }

    /** AdCP format id for a layout, or '' for an unknown layout. */
    public static function format_for(string $layout_key): string
    {
        return self::LAYOUTS[$layout_key]['format_id'] ?? '';
    }

    /** Hyphenated CSS/placement slug for a layout, or '' when unknown. */
    public static function slug_for(string $layout_key): string
    {
        return self::LAYOUTS[$layout_key]['slug'] ?? '';
    }

    public static function label_for(string $layout_key): string
    {
        switch ($layout_key) {
            case 'leaderboard_top':
                return __('Leaderboard (top of page)', 'encypher-provenance');
            case 'in_content':
                return __('In content (between paragraphs)', 'encypher-provenance');
            case 'sidebar_rail':
                return __('Sidebar rail (widget)', 'encypher-provenance');
            case 'sticky_footer':
                return __('Sticky footer', 'encypher-provenance');
        }
        return $layout_key;
    }

    public static function is_valid_placement_key(string $placement_key): bool
    {
        return 1 === preg_match(self::PLACEMENT_KEY_PATTERN, $placement_key);
    }

    /**
     * Placement key that is stable for one site + one slot and never collides
     * across sites in a multisite network (the site URL is hashed in). Shape:
     * `wp-{12 hex of sha256(site_url)}-{layout-slug}`, always inside
     * `^[a-z0-9][a-z0-9-]{1,62}$`.
     *
     * @param string $site_url   Canonical site home URL.
     * @param string $layout_key One of the layout_key enum values.
     */
    public static function placement_key(string $site_url, string $layout_key): string
    {
        $slug = self::slug_for($layout_key);
        if ('' === $slug) {
            return '';
        }
        $site_url = strtolower(trim($site_url));
        if ('' === $site_url) {
            return '';
        }

        return 'wp-' . substr(hash('sha256', $site_url), 0, 12) . '-' . $slug;
    }

    /**
     * Normalize submitted layout settings against the stored ones.
     *
     * Pure: returns the settings to store plus human-readable errors, so the
     * capability rule for raw ad tags is testable without WordPress.
     *
     * @param array $raw     Submitted (already unslashed) form values.
     * @param array $current Currently stored settings.
     * @param bool  $can_raw Whether the acting user may store raw HTML.
     * @return array{settings: array, errors: array<string, string>}
     */
    public static function normalize_settings(array $raw, array $current, bool $can_raw): array
    {
        $current = self::normalize_stored($current);
        $errors = [];

        $out = [
            'enabled' => ! empty($raw['enabled']),
            'in_content_every_n' => self::clamp_every_n($raw['in_content_every_n'] ?? $current['in_content_every_n']),
            'slots' => [],
        ];

        $raw_slots = isset($raw['slots']) && is_array($raw['slots']) ? $raw['slots'] : [];
        foreach (self::layout_keys() as $layout_key) {
            $submitted = isset($raw_slots[$layout_key]) && is_array($raw_slots[$layout_key])
                ? $raw_slots[$layout_key]
                : [];
            $stored_tag = (string) $current['slots'][$layout_key]['ad_tag'];

            $ad_tag = $stored_tag;
            if (array_key_exists('ad_tag', $submitted)) {
                $proposed = self::clean_ad_tag((string) $submitted['ad_tag']);
                if ($proposed === $stored_tag) {
                    $ad_tag = $stored_tag;
                } elseif (! $can_raw) {
                    // Fail closed: raw output demands the raw-HTML capability.
                    $errors[$layout_key . '_ad_tag_capability'] = sprintf(
                        /* translators: %s: layout name */
                        __('The ad tag for "%s" was not changed. Storing an ad tag that is output as raw HTML requires the unfiltered_html capability, the same rule WordPress applies to its Custom HTML widget.', 'encypher-provenance'),
                        self::label_for($layout_key)
                    );
                } elseif (strlen($proposed) > self::MAX_AD_TAG_BYTES) {
                    $errors[$layout_key . '_ad_tag_length'] = sprintf(
                        /* translators: 1: layout name, 2: byte limit */
                        __('The ad tag for "%1$s" was not changed because it exceeds %2$d bytes.', 'encypher-provenance'),
                        self::label_for($layout_key),
                        self::MAX_AD_TAG_BYTES
                    );
                } else {
                    $ad_tag = $proposed;
                }
            }

            $out['slots'][$layout_key] = [
                'active' => ! empty($submitted['active']),
                'ad_tag' => $ad_tag,
            ];
        }

        return ['settings' => $out, 'errors' => $errors];
    }

    /** Fill in every documented key so callers never probe for existence. */
    public static function normalize_stored($stored): array
    {
        $stored = is_array($stored) ? $stored : [];
        $slots = isset($stored['slots']) && is_array($stored['slots']) ? $stored['slots'] : [];

        $out = [
            'enabled' => ! empty($stored['enabled']),
            'in_content_every_n' => self::clamp_every_n($stored['in_content_every_n'] ?? null),
            'slots' => [],
        ];
        foreach (self::layout_keys() as $layout_key) {
            $slot = isset($slots[$layout_key]) && is_array($slots[$layout_key]) ? $slots[$layout_key] : [];
            $out['slots'][$layout_key] = [
                'active' => ! empty($slot['active']),
                'ad_tag' => isset($slot['ad_tag']) ? (string) $slot['ad_tag'] : '',
            ];
        }

        return $out;
    }

    /** Which layouts should currently hold an active placement. */
    public static function desired_active(array $settings): array
    {
        $settings = self::normalize_stored($settings);
        if (! $settings['enabled']) {
            // Turning the mode off soft-removes every placement.
            return [];
        }

        $active = [];
        foreach (self::layout_keys() as $layout_key) {
            if ($settings['slots'][$layout_key]['active']) {
                $active[] = $layout_key;
            }
        }

        return $active;
    }

    /**
     * Diff the desired active layouts against the registration ledger.
     *
     * @param string[] $desired  Layout keys that should be active.
     * @param array    $state    Ledger: placement_key => record.
     * @param string   $site_url Canonical site home URL.
     * @return array{register: array<int, array{placement_key: string, layout_key: string, format_id: string}>, remove: string[]}
     */
    public static function plan_sync(array $desired, array $state, string $site_url): array
    {
        $register = [];
        $desired_keys = [];

        foreach ($desired as $layout_key) {
            $placement_key = self::placement_key($site_url, (string) $layout_key);
            if ('' === $placement_key) {
                continue;
            }
            $desired_keys[$placement_key] = true;

            $record = isset($state[$placement_key]) && is_array($state[$placement_key]) ? $state[$placement_key] : null;
            $already = null !== $record
                && 'active' === ($record['state'] ?? '')
                && ! empty($record['synced'])
                && self::format_for((string) $layout_key) === ($record['format_id'] ?? '');
            if ($already) {
                continue;
            }

            $register[] = [
                'placement_key' => $placement_key,
                'layout_key' => (string) $layout_key,
                'format_id' => self::format_for((string) $layout_key),
            ];
        }

        $remove = [];
        foreach ($state as $placement_key => $record) {
            $placement_key = (string) $placement_key;
            if (isset($desired_keys[$placement_key]) || ! is_array($record)) {
                continue;
            }
            if ('removed' === ($record['state'] ?? '') && ! empty($record['synced'])) {
                continue;
            }
            $remove[] = $placement_key;
        }

        return ['register' => $register, 'remove' => $remove];
    }

    /**
     * The fenced container. Collapsed and empty in the default "reserved"
     * state; when the publisher pasted an ad tag it is emitted verbatim inside
     * the container and the state flips to "filled". A connector can later
     * fill the same container without any markup change.
     *
     * @param string $placement_key Registered placement key.
     * @param string $layout_key    Layout preset key.
     * @param string $ad_tag        Publisher-pasted ad-server tag, or ''.
     */
    public static function container_html(string $placement_key, string $layout_key, string $ad_tag): string
    {
        $format_id = self::format_for($layout_key);
        $slug = self::slug_for($layout_key);
        if ('' === $format_id || '' === $slug || ! self::is_valid_placement_key($placement_key)) {
            return '';
        }

        $filled = '' !== trim($ad_tag);

        return '<div class="encypher-ad-slot encypher-ad-slot--' . esc_attr($slug) . '"'
            . ' data-encypher-placement="' . esc_attr($placement_key) . '"'
            . ' data-format="' . esc_attr($format_id) . '"'
            . ' data-encypher-layout="' . esc_attr($layout_key) . '"'
            . ' data-encypher-state="' . ($filled ? 'filled' : 'reserved') . '">'
            // Publisher-owned ad-server tag, stored by an unfiltered_html user
            // and emitted verbatim ONLY here. See the class docblock for the
            // trust model. Escaping it would break every ad tag by design.
            . ($filled ? $ad_tag : '')
            . '</div>';
    }

    /**
     * Insert $html after every $every_n top-level paragraphs of $content.
     *
     * Operates on `</p>` boundaries, never inside one, and never appends a
     * trailing copy after the final paragraph (that is what the sticky footer
     * and leaderboard slots are for).
     */
    public static function insert_after_paragraphs(string $content, string $html, int $every_n): string
    {
        $every_n = self::clamp_every_n($every_n);
        if ('' === $html || '' === trim($content)) {
            return $content;
        }

        $parts = preg_split('#(</p>)#i', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
        if (! is_array($parts)) {
            return $content;
        }

        $out = '';
        $closed = 0;
        $total = 0;
        foreach ($parts as $part) {
            if (1 === preg_match('#^</p>$#i', $part)) {
                $total++;
            }
        }

        foreach ($parts as $part) {
            $out .= $part;
            if (1 !== preg_match('#^</p>$#i', $part)) {
                continue;
            }
            $closed++;
            if ($closed >= $total) {
                // Never inject after the last paragraph.
                continue;
            }
            if (0 === $closed % $every_n) {
                $out .= $html;
            }
        }

        return $out;
    }

    /**
     * Placement payload for the bulk upsert body, chunked to the server limit.
     *
     * @param array<int, array{placement_key: string, layout_key: string, format_id: string}> $register
     * @return array<int, array<int, array<string, string>>>
     */
    public static function registration_batches(array $register): array
    {
        $payload = [];
        foreach ($register as $item) {
            $placement_key = (string) ($item['placement_key'] ?? '');
            $layout_key = (string) ($item['layout_key'] ?? '');
            $format_id = (string) ($item['format_id'] ?? '');
            if (! self::is_valid_placement_key($placement_key) || ! self::is_layout_key($layout_key) || '' === $format_id) {
                continue;
            }
            $payload[] = [
                'placement_key' => $placement_key,
                'layout_key' => $layout_key,
                'format_id' => $format_id,
                'source' => 'wordpress_plugin',
            ];
        }

        return [] === $payload ? [] : array_chunk($payload, self::MAX_PLACEMENTS_PER_CALL);
    }

    private static function clamp_every_n($value): int
    {
        $value = (int) $value;
        if ($value < self::MIN_IN_CONTENT_EVERY_N) {
            return self::DEFAULT_IN_CONTENT_EVERY_N;
        }
        if ($value > self::MAX_IN_CONTENT_EVERY_N) {
            return self::MAX_IN_CONTENT_EVERY_N;
        }

        return $value;
    }

    /** Strip control bytes an ad tag never needs; preserve everything else. */
    public static function clean_ad_tag(string $tag): string
    {
        $tag = str_replace("\0", '', $tag);

        return trim($tag);
    }

    // -----------------------------------------------------------------------
    // WordPress wiring
    // -----------------------------------------------------------------------

    public static function register_hooks(): void
    {
        if (! self::account_entitled()) {
            // Fail closed before registering the page, block, widget, sync, or
            // front-end hooks. The Encypher account entitlement owns this surface.
            return;
        }

        // The WordPress-local mode remains independently opt-in once the
        // account is entitled to the surface.
        add_action('admin_menu', [self::class, 'register_admin_page'], 20);
        add_action('admin_init', [self::class, 'register_settings']);
        add_action('update_option_' . self::OPTION, [self::class, 'on_settings_saved'], 10, 0);
        add_action('add_option_' . self::OPTION, [self::class, 'on_settings_saved'], 10, 0);
        add_action(self::SYNC_HOOK, [self::class, 'run_sync']);
        add_action('init', [self::class, 'register_block']);
        add_action('widgets_init', [self::class, 'register_widget']);
        add_action('admin_post_encypher_seed_ad_layouts_demo', [self::class, 'handle_seed_demo_content']);

        if (! self::is_enabled()) {
            // Fail closed: no front-end hook, no markup, no request.
            return;
        }

        add_action('wp_enqueue_scripts', [self::class, 'enqueue_slot_styles']);
        add_action('wp_body_open', [self::class, 'render_leaderboard_top']);
        add_action('wp_footer', [self::class, 'render_sticky_footer'], 100);
        add_filter('the_content', [self::class, 'filter_the_content'], 30);
    }

    public static function settings(): array
    {
        return self::normalize_stored(get_option(self::OPTION, []));
    }

    public static function is_enabled(): bool
    {
        return self::account_entitled() && self::settings()['enabled'];
    }

    public static function account_entitled(): bool
    {
        return class_exists(SellerEnrollment::class)
            && SellerEnrollment::is_ad_layouts_entitled();
    }

    /** Registration ledger. */
    public static function state(): array
    {
        $state = get_option(self::STATE_OPTION, []);

        return is_array($state) ? $state : [];
    }

    /**
     * Enrollment id for THIS site, from the enrollment slice (PRD E3). Empty
     * means "not enrolled": the mode still renders locally but registers
     * nothing.
     *
     * Read through SellerEnrollment::current_enrollment_id(), which rejects an
     * enrollment id inherited from another site by the multisite settings
     * propagation. Falls back to the stored pair only when that class is
     * absent, applying the same domain check here.
     */
    public static function enrollment_id(): string
    {
        if (method_exists(SellerEnrollment::class, 'current_enrollment_id')) {
            return trim((string) SellerEnrollment::current_enrollment_id());
        }

        $settings = get_option('encypher_provenance_settings', []);
        if (! is_array($settings)) {
            return '';
        }
        $enrollment_id = trim((string) ($settings['adcp_enrollment_id'] ?? ''));
        $enrolled_domain = strtolower(trim((string) ($settings['adcp_enrollment_domain'] ?? '')));
        if ('' === $enrollment_id || '' === $enrolled_domain) {
            return '';
        }
        $host = wp_parse_url(self::site_url(), PHP_URL_HOST);
        $host = is_string($host) ? strtolower(rtrim($host, '.')) : '';
        if ('' === $host || $host !== rtrim($enrolled_domain, '.')) {
            return '';
        }

        return $enrollment_id;
    }

    public static function site_url(): string
    {
        return (string) home_url('/');
    }

    public static function placement_key_for(string $layout_key): string
    {
        return self::placement_key(self::site_url(), $layout_key);
    }

    public static function register_admin_page(): void
    {
        if (! self::account_entitled()) {
            return;
        }

        add_submenu_page(
            'encypher',
            __('Ad Layouts', 'encypher-provenance'),
            __('Ad Layouts', 'encypher-provenance'),
            'manage_options',
            self::MENU_SLUG,
            [self::class, 'render_admin_page']
        );
    }

    public static function render_admin_page(): void
    {
        if (! current_user_can('manage_options')) {
            wp_die(esc_html__('You do not have permission to manage Encypher ad layouts.', 'encypher-provenance'));
        }
        if (! self::account_entitled()) {
            return;
        }

        include ENCYPHER_PROVENANCE_PLUGIN_DIR . 'admin/partials/ad-layouts-tab.php';
    }

    /**
     * Create or refresh the signed multimedia demo article, then place it on
     * the same deferred-sign queue used by normal editor and bulk actions.
     */
    public static function handle_seed_demo_content(): void
    {
        if (! current_user_can('manage_options') || ! self::account_entitled()) {
            wp_die(esc_html__('You do not have permission to create this demo.', 'encypher-provenance'));
        }
        check_admin_referer('encypher_seed_ad_layouts_demo');

        $post_id = self::seed_demo_content();
        if (is_wp_error($post_id)) {
            wp_die(esc_html($post_id->get_error_message()));
        }

        $redirect = add_query_arg(
            [
                'page' => self::MENU_SLUG,
                'encypher_demo_created' => 1,
                'encypher_demo_post' => (int) $post_id,
            ],
            admin_url('admin.php')
        );
        wp_safe_redirect($redirect);
        exit;
    }

    /**
     * Seed one idempotent article with bundled image, audio, and video assets.
     *
     * @return int|\WP_Error Post id or a WordPress error.
     */
    public static function seed_demo_content()
    {
        $assets = [];
        foreach (
            [
                'image' => ['encypher-demo-provenance.png', __('Verified origin illustration', 'encypher-provenance')],
                'audio' => ['encypher-demo-source-note.mp3', __('Source note: one verifiable record', 'encypher-provenance')],
                'video' => ['encypher-demo-origin-story.mp4', __('Verified origin in motion', 'encypher-provenance')],
            ] as $kind => $asset
        ) {
            $attachment_id = self::seed_demo_attachment($asset[0], $asset[1]);
            if (is_wp_error($attachment_id)) {
                return $attachment_id;
            }
            $assets[$kind] = [
                'id' => (int) $attachment_id,
                'url' => (string) wp_get_attachment_url($attachment_id),
            ];
        }

        $existing = get_posts(
            [
                'post_type' => 'post',
                'post_status' => 'any',
                'meta_key' => self::DEMO_POST_META,
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one manual demo post lookup against a plugin-owned marker.
                'meta_value' => self::DEMO_VERSION,
                'fields' => 'ids',
                'posts_per_page' => 1,
                'no_found_rows' => true,
            ]
        );
        $post = [
            'post_title' => __('The Proof Layer: How publishers keep trust when content moves', 'encypher-provenance'),
            'post_excerpt' => __('A signed multimedia field guide to source trust, content provenance, and agent-readable ad inventory.', 'encypher-provenance'),
            'post_content' => self::demo_post_content($assets),
            'post_status' => 'publish',
            'post_type' => 'post',
            'post_author' => get_current_user_id(),
        ];
        if (! empty($existing)) {
            $post['ID'] = (int) $existing[0];
            $post_id = wp_update_post($post, true);
        } else {
            $post_id = wp_insert_post($post, true);
        }
        if (is_wp_error($post_id)) {
            return $post_id;
        }

        update_post_meta($post_id, self::DEMO_POST_META, self::DEMO_VERSION);
        set_post_thumbnail($post_id, $assets['image']['id']);

        $admin = new Admin();
        $admin->handle_bulk_sign_action(
            admin_url('edit.php'),
            'encypher_sign',
            [(int) $post_id]
        );

        return (int) $post_id;
    }

    /**
     * Build the Gutenberg article body consumed by rich signing. Every media
     * item is inline, so the existing composite pipeline signs all three.
     *
     * @param array<string,array{id:int,url:string}> $assets
     */
    public static function demo_post_content(array $assets): string
    {
        foreach (['image', 'audio', 'video'] as $kind) {
            if (! isset($assets[$kind]['id'], $assets[$kind]['url'])) {
                return '';
            }
        }
        $image_id = (int) $assets['image']['id'];
        $audio_id = (int) $assets['audio']['id'];
        $video_id = (int) $assets['video']['id'];
        $image_url = htmlspecialchars((string) $assets['image']['url'], ENT_QUOTES, 'UTF-8');
        $audio_url = htmlspecialchars((string) $assets['audio']['url'], ENT_QUOTES, 'UTF-8');
        $video_url = htmlspecialchars((string) $assets['video']['url'], ENT_QUOTES, 'UTF-8');

        return '<!-- wp:paragraph {"fontSize":"large"} -->'
            . '<p class="has-large-font-size">A story now leaves the newsroom before the ink is dry. It is quoted by search engines, summarized by AI agents, clipped into feeds, and sold beside ads. The publisher needs one fact to travel with it: where this work came from.</p>'
            . '<!-- /wp:paragraph -->'
            . '<!-- wp:image ' . wp_json_encode(['id' => $image_id, 'sizeSlug' => 'full', 'linkDestination' => 'none']) . ' -->'
            . '<figure class="wp-block-image size-full"><img src="' . $image_url . '" alt="A human source record connected to a verified origin seal" class="wp-image-' . $image_id . '"/><figcaption class="wp-element-caption">Text, image, audio, and video can share one signed provenance record.</figcaption></figure>'
            . '<!-- /wp:image -->'
            . '<!-- wp:heading --><h2 class="wp-block-heading">The file is not the story</h2><!-- /wp:heading -->'
            . '<!-- wp:paragraph --><p>A file can be copied without context. A provenance record binds the publisher, the source material, the rights, and the history of edits to the work itself. Readers get a clear trust signal. Machines get structured evidence instead of a marketing claim.</p><!-- /wp:paragraph -->'
            . '<!-- wp:quote --><blockquote class="wp-block-quote"><p>Trust should survive the handoff from the person who made the work to every system that carries it.</p><cite>Encypher provenance principle</cite></blockquote><!-- /wp:quote -->'
            . '<!-- wp:heading --><h2 class="wp-block-heading">Listen to the source note</h2><!-- /wp:heading -->'
            . '<!-- wp:paragraph --><p>This short recording is part of the same article. The plugin discovers it in the page, sends it through rich signing, and writes the signed media back to WordPress.</p><!-- /wp:paragraph -->'
            . '<!-- wp:audio ' . wp_json_encode(['id' => $audio_id]) . ' --><figure class="wp-block-audio"><audio controls src="' . $audio_url . '"></audio><figcaption class="wp-element-caption">A seven-second source note included in the signed article.</figcaption></figure><!-- /wp:audio -->'
            . '<!-- wp:heading --><h2 class="wp-block-heading">A market can see the proof</h2><!-- /wp:heading -->'
            . '<!-- wp:paragraph --><p>Ad Layouts publishes stable, enumerable placement keys through the site&apos;s AdCP seller enrollment. A buyer agent can discover the inventory and request a placement through the managed seller workflow. WordPress only owns the container. The publisher&apos;s ad server or contracted connector still owns the creative and delivery.</p><!-- /wp:paragraph -->'
            . '<!-- wp:encypher/ad-slot {"layoutKey":"in_content"} /-->'
            . '<!-- wp:heading --><h2 class="wp-block-heading">Watch the proof travel</h2><!-- /wp:heading -->'
            . '<!-- wp:video ' . wp_json_encode(['id' => $video_id]) . ' --><figure class="wp-block-video"><video controls src="' . $video_url . '"></video><figcaption class="wp-element-caption">A signed motion asset traveling with the article.</figcaption></figure><!-- /wp:video -->'
            . '<!-- wp:heading --><h2 class="wp-block-heading">One article, one evidence chain</h2><!-- /wp:heading -->'
            . '<!-- wp:list --><ul><li>The article text receives a verifiable document signature.</li><li>The image, audio, and video are discovered and signed as rich media.</li><li>The active ad container maps to a stable AdCP placement key.</li><li>Readers and agents can inspect the result without trusting this page on faith.</li></ul><!-- /wp:list -->'
            . '<!-- wp:paragraph --><p>The result is ordinary WordPress content with a proof layer. It stays useful to a reader, legible to an agent, and under the publisher&apos;s control.</p><!-- /wp:paragraph -->';
    }

    /**
     * @return int|\WP_Error
     */
    private static function seed_demo_attachment(string $filename, string $title)
    {
        $existing = get_posts(
            [
                'post_type' => 'attachment',
                'post_status' => 'inherit',
                'meta_key' => self::DEMO_ASSET_META,
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one manual demo asset lookup against a plugin-owned marker.
                'meta_value' => $filename,
                'fields' => 'ids',
                'posts_per_page' => 1,
                'no_found_rows' => true,
            ]
        );
        if (! empty($existing)) {
            return (int) $existing[0];
        }

        $source = ENCYPHER_PROVENANCE_PLUGIN_DIR . 'assets/images/' . $filename;
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- fixed plugin-owned demo asset, never a user-controlled path or remote URL.
        $bytes = file_get_contents($source);
        if (false === $bytes) {
            return new \WP_Error('encypher_demo_asset_missing', __('A bundled demo asset could not be read.', 'encypher-provenance'));
        }
        $uploaded = wp_upload_bits($filename, null, $bytes);
        if (! empty($uploaded['error'])) {
            return new \WP_Error('encypher_demo_asset_upload', (string) $uploaded['error']);
        }
        $filetype = wp_check_filetype($uploaded['file'], null);
        $attachment_id = wp_insert_attachment(
            [
                'post_mime_type' => (string) ($filetype['type'] ?? ''),
                'post_title' => $title,
                'post_content' => '',
                'post_status' => 'inherit',
            ],
            $uploaded['file']
        );
        if (is_wp_error($attachment_id)) {
            return $attachment_id;
        }

        if (! function_exists('wp_generate_attachment_metadata')) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
        }
        $metadata = wp_generate_attachment_metadata($attachment_id, $uploaded['file']);
        if (is_array($metadata)) {
            wp_update_attachment_metadata($attachment_id, $metadata);
        }
        update_post_meta($attachment_id, self::DEMO_ASSET_META, $filename);

        return (int) $attachment_id;
    }

    public static function register_settings(): void
    {
        register_setting(self::SETTINGS_GROUP, self::OPTION, [
            'type' => 'array',
            'sanitize_callback' => [self::class, 'sanitize_settings'],
            'default' => self::normalize_stored([]),
            'show_in_rest' => false,
        ]);
    }

    /**
     * Settings API sanitize callback. Reached only through options.php, which
     * has already verified the `encypher_ad_layouts_group` nonce emitted by
     * settings_fields() and the `manage_options` capability. Raw ad-tag writes
     * additionally require `unfiltered_html`.
     *
     * @param mixed $submitted Submitted option value.
     */
    public static function sanitize_settings($submitted): array
    {
        $current = self::normalize_stored(get_option(self::OPTION, []));
        if (! is_array($submitted)) {
            return $current;
        }
        if (! current_user_can('manage_options')) {
            return $current;
        }

        $result = self::normalize_settings($submitted, $current, current_user_can('unfiltered_html'));
        foreach ($result['errors'] as $code => $message) {
            add_settings_error(self::OPTION, $code, $message, 'error');
        }

        return $result['settings'];
    }

    /** Queue a placement sync; never block the settings save on the network. */
    public static function on_settings_saved(): void
    {
        if (wp_next_scheduled(self::SYNC_HOOK)) {
            return;
        }
        wp_schedule_single_event(time() + 1, self::SYNC_HOOK);
    }

    /**
     * Reconcile the registration ledger with the enrollment API.
     *
     * Fails closed and silently: a missing enrollment id, missing credentials,
     * or a 404 (feature flag off server-side) leaves the desired state queued
     * and changes nothing about local rendering.
     */
    public static function run_sync(): void
    {
        $state = self::state();
        $plan = self::plan_sync(self::desired_active(self::settings()), $state, self::site_url());
        if ([] === $plan['register'] && [] === $plan['remove']) {
            return;
        }

        $settings = get_option('encypher_provenance_settings', []);
        $api_base = is_array($settings) ? rtrim((string) ($settings['api_base_url'] ?? ''), '/') : '';
        $api_key = is_array($settings) ? trim((string) ($settings['api_key'] ?? '')) : '';
        $enrollment_id = self::enrollment_id();
        if ('' === $api_base || '' === $api_key || '' === $enrollment_id) {
            self::queue_plan($state, $plan, 'Enrollment is not configured; placement registration is queued.');
            return;
        }

        $base = $api_base . '/adcp/enrollment/' . rawurlencode($enrollment_id) . '/placements';
        $deferred = false;

        foreach (self::registration_batches($plan['register']) as $batch) {
            $response = ApiUrlGuard::guarded_remote_post($base, [
                'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- WP-Cron placement reconciliation, never a front-end request.
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                ],
                'body' => wp_json_encode(['placements' => $batch]),
            ]);
            $status = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);
            $ok = $status >= 200 && $status < 300;

            foreach ($batch as $item) {
                $placement_key = (string) $item['placement_key'];
                if ($ok) {
                    $state[$placement_key] = [
                        'layout_key' => (string) $item['layout_key'],
                        'format_id' => (string) $item['format_id'],
                        'state' => 'active',
                        'synced' => true,
                        'attempts' => 0,
                        'last_error' => '',
                    ];
                    continue;
                }
                $state[$placement_key] = self::mark_pending(
                    $state[$placement_key] ?? [],
                    (string) $item['layout_key'],
                    (string) $item['format_id'],
                    'active',
                    self::failure_message($response, $status)
                );
                $deferred = $deferred || self::should_retry($state[$placement_key]);
            }
        }

        foreach ($plan['remove'] as $placement_key) {
            $record = is_array($state[$placement_key] ?? null) ? $state[$placement_key] : [];
            $response = ApiUrlGuard::guarded_remote_post($base . '/' . rawurlencode($placement_key), [
                'method' => 'DELETE',
                'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- WP-Cron placement reconciliation, never a front-end request.
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                ],
            ]);
            $status = is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response);
            // 404 is terminal for a removal: the placement is not there, which
            // is the state we asked for. The server-side flag being off is
            // also a 404, and re-enabling it re-registers from settings.
            if (($status >= 200 && $status < 300) || 404 === $status) {
                $state[$placement_key] = [
                    'layout_key' => (string) ($record['layout_key'] ?? ''),
                    'format_id' => (string) ($record['format_id'] ?? ''),
                    'state' => 'removed',
                    'synced' => true,
                    'attempts' => 0,
                    'last_error' => '',
                ];
                continue;
            }
            $state[$placement_key] = self::mark_pending(
                $record,
                (string) ($record['layout_key'] ?? ''),
                (string) ($record['format_id'] ?? ''),
                'removed',
                self::failure_message($response, $status)
            );
            $deferred = $deferred || self::should_retry($state[$placement_key]);
        }

        update_option(self::STATE_OPTION, $state, false);
        if ($deferred) {
            self::schedule_retry($state);
        }
    }

    /**
     * Record the desired state without contacting the server (no credentials
     * or no enrollment yet). Nothing is retried on a timer here: the next
     * settings save or enrollment completion re-runs the plan.
     */
    private static function queue_plan(array $state, array $plan, string $reason): void
    {
        foreach ($plan['register'] as $item) {
            $state[(string) $item['placement_key']] = [
                'layout_key' => (string) $item['layout_key'],
                'format_id' => (string) $item['format_id'],
                'state' => 'active',
                'synced' => false,
                'attempts' => 0,
                'last_error' => $reason,
            ];
        }
        foreach ($plan['remove'] as $placement_key) {
            $record = is_array($state[$placement_key] ?? null) ? $state[$placement_key] : [];
            $state[$placement_key] = [
                'layout_key' => (string) ($record['layout_key'] ?? ''),
                'format_id' => (string) ($record['format_id'] ?? ''),
                'state' => 'removed',
                'synced' => false,
                'attempts' => 0,
                'last_error' => $reason,
            ];
        }
        update_option(self::STATE_OPTION, $state, false);
    }

    private static function mark_pending(array $record, string $layout_key, string $format_id, string $desired_state, string $error): array
    {
        return [
            'layout_key' => '' !== $layout_key ? $layout_key : (string) ($record['layout_key'] ?? ''),
            'format_id' => '' !== $format_id ? $format_id : (string) ($record['format_id'] ?? ''),
            'state' => $desired_state,
            'synced' => false,
            'attempts' => (int) ($record['attempts'] ?? 0) + 1,
            'last_error' => $error,
        ];
    }

    private static function should_retry(array $record): bool
    {
        return (int) ($record['attempts'] ?? 0) < self::MAX_SYNC_ATTEMPTS;
    }

    private static function schedule_retry(array $state): void
    {
        if (wp_next_scheduled(self::SYNC_HOOK)) {
            return;
        }
        $attempts = 1;
        foreach ($state as $record) {
            if (is_array($record) && empty($record['synced'])) {
                $attempts = max($attempts, (int) ($record['attempts'] ?? 1));
            }
        }
        wp_schedule_single_event(time() + min(3600, 60 * (2 ** min($attempts, 6))), self::SYNC_HOOK);
    }

    /**
     * @param mixed $response wp_remote_* response or WP_Error.
     */
    private static function failure_message($response, int $status): string
    {
        if (is_wp_error($response)) {
            return sanitize_text_field((string) $response->get_error_message());
        }
        if (404 === $status) {
            return 'Enrollment API returned 404; registration is queued until the feature is available.';
        }

        return 'Enrollment API returned HTTP ' . $status . '.';
    }

    // -----------------------------------------------------------------------
    // Front-end rendering
    // -----------------------------------------------------------------------

    /**
     * Minimal inline CSS: containers collapse in the reserved state and the
     * sticky footer sticks once filled. Registered without a stylesheet file
     * so filling a container later needs no markup or asset change.
     */
    public static function enqueue_slot_styles(): void
    {
        wp_register_style('encypher-ad-slots', false, [], ENCYPHER_PROVENANCE_VERSION);
        wp_enqueue_style('encypher-ad-slots');
        wp_add_inline_style(
            'encypher-ad-slots',
            '.encypher-ad-slot[data-encypher-state="reserved"]{display:none}'
            . '.encypher-ad-slot{margin:0 auto;text-align:center}'
            . '.encypher-ad-slot--sticky-footer[data-encypher-state="filled"]'
            . '{position:fixed;left:0;right:0;bottom:0;z-index:99999;background:#fff}'
        );
    }

    /**
     * Rendered markup for one layout slot, or '' when the slot is inactive or
     * the mode is off.
     */
    public static function render_slot(string $layout_key): string
    {
        if (! self::is_layout_key($layout_key)) {
            return '';
        }
        $settings = self::settings();
        if (! $settings['enabled'] || ! $settings['slots'][$layout_key]['active']) {
            return '';
        }

        return self::container_html(
            self::placement_key_for($layout_key),
            $layout_key,
            (string) $settings['slots'][$layout_key]['ad_tag']
        );
    }

    public static function render_leaderboard_top(): void
    {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- container attributes are esc_attr()'d in container_html(); the optional inner ad tag is publisher-pasted raw HTML stored under the unfiltered_html capability (same trust class as core's Custom HTML widget).
        echo self::render_slot('leaderboard_top');
    }

    public static function render_sticky_footer(): void
    {
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- see render_leaderboard_top().
        echo self::render_slot('sticky_footer');
    }

    /**
     * In-content preset: inject the container after every N paragraphs of the
     * main singular content only. Never touches feeds, excerpts, archives, or
     * secondary queries.
     *
     * @param string $content Post content.
     */
    public static function filter_the_content($content): string
    {
        $content = (string) $content;
        if (is_admin() || is_feed()) {
            return $content;
        }
        if (! is_singular() || ! is_main_query() || ! in_the_loop()) {
            return $content;
        }

        $html = self::render_slot('in_content');
        if ('' === $html) {
            return $content;
        }

        return self::insert_after_paragraphs($content, $html, self::settings()['in_content_every_n']);
    }

    // -----------------------------------------------------------------------
    // Block + widget
    // -----------------------------------------------------------------------

    /**
     * Standard dynamic-block registration from block.json: the render callback
     * is the `render` file declared there, so no client-side build step is
     * involved and no view script is enqueued.
     *
     * WordPress only honours block.json's `render` key from 6.1, and this
     * plugin's floor is 6.0. On 6.0 exactly, pass the equivalent callback in
     * $args (which register_block_type_from_metadata() merges last, so it wins)
     * rather than letting the block silently render nothing.
     */
    public static function register_block(): void
    {
        if (! function_exists('register_block_type')) {
            return;
        }

        $args = [];
        $wp_version = isset($GLOBALS['wp_version']) ? (string) $GLOBALS['wp_version'] : '';
        if ('' !== $wp_version && version_compare($wp_version, '6.1', '<')) {
            $args['render_callback'] = static function ($attributes): string {
                return self::render_block_slot($attributes);
            };
        }

        register_block_type(ENCYPHER_PROVENANCE_PLUGIN_DIR . 'assets/blocks/ad-slot', $args);
    }

    /**
     * Render callback for `encypher/ad-slot` (invoked from the block's
     * render.php). Honours the same activation gate as the presets: an
     * inactive slot renders nothing at all.
     *
     * @param array $attributes Block attributes.
     */
    public static function render_block_slot($attributes): string
    {
        $attributes = is_array($attributes) ? $attributes : [];
        $layout_key = isset($attributes['layoutKey']) ? (string) $attributes['layoutKey'] : 'in_content';
        if (! self::is_layout_key($layout_key)) {
            return '';
        }

        return self::render_slot($layout_key);
    }

    public static function register_widget(): void
    {
        if (! function_exists('register_widget') || ! class_exists('WP_Widget')) {
            return;
        }
        require_once ENCYPHER_PROVENANCE_PLUGIN_DIR . 'includes/class-encypher-ad-slot-widget.php';
        register_widget(AdSlotWidget::class);
    }
}
