<?php
/**
 * AdCP managed-seller enrollment (PRD E3).
 *
 * Turns domain verification and seller enrollment into a wp-admin flow that a
 * publisher can complete without an Encypher operator:
 *
 *   1. Register a PublisherIdentity for THIS exact host with the organization
 *      Bearer key already stored by the connect flow.
 *   2. Serve the returned challenge at https://{host}/.well-known/encypher.json
 *      from WordPress itself (rewrite rule + template_redirect), so hosts with
 *      no web-root filesystem access are supported.
 *   3. Call /verify with proof_method=well_known.
 *   4. Accept the enrollment terms document (hash echoed back verbatim) and
 *      POST /api/v1/adcp/enrollment; then poll status, withdraw, or declare a
 *      sales house.
 *
 * Fail-closed by construction: every surface is hidden until the server has
 * confirmed the enrollment API exists (404 => feature absent, no notices, no
 * errors), every outbound call goes through ApiUrlGuard, and every state change
 * requires manage_options plus a valid nonce.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

final class SellerEnrollment
{
    const SETTINGS_KEY = 'encypher_provenance_settings';

    /** Shared with PublisherPublication, which stamps publications with it. */
    const IDENTITY_ID_KEY = 'publisher_identity_id';
    const IDENTITY_DOMAIN_KEY = 'adcp_identity_domain';
    const CHALLENGE_KEY = 'adcp_identity_challenge_token';
    const ENROLLMENT_ID_KEY = 'adcp_enrollment_id';
    const ENROLLMENT_DOMAIN_KEY = 'adcp_enrollment_domain';

    const WELL_KNOWN_PATH = '/.well-known/encypher.json';
    const QUERY_VAR = 'encypher_identity_doc';
    const DOCUMENT_FIELD = 'encypher_domain_verification';

    const TERMS_DOCUMENT_KEY = 'adcp-enrollment-terms';
    const ACCEPTANCE_CONTEXT = 'wordpress';

    const MENU_SLUG = 'encypher-adcp-enrollment';
    const CAPABILITY = 'manage_options';

    const ACTION_VERIFY = 'encypher_adcp_verify';
    const ACTION_ENROLL = 'encypher_adcp_enroll';
    const ACTION_WITHDRAW = 'encypher_adcp_withdraw';
    const ACTION_SALES_HOUSE = 'encypher_adcp_sales_house';

    const AVAILABILITY_TRANSIENT = 'encypher_adcp_enrollment_available';
    const AD_LAYOUTS_TRANSIENT = 'encypher_ad_layouts_account_enabled';
    /** Account visibility is refreshed every 15 minutes in either state. */
    const AVAILABILITY_TTL = 900;
    const AVAILABILITY_TTL_NEGATIVE = 900;
    const PROBE_HOOK = 'encypher_adcp_enrollment_probe';
    const NOTICE_TRANSIENT_PREFIX = 'encypher_adcp_notice_';

    const SALES_HOUSE_KEY_PATTERN = '/^[a-z0-9][a-z0-9_]{1,62}$/';

    public static function register_hooks(): void
    {
        add_action('init', [self::class, 'add_rewrite_rules']);
        add_filter('query_vars', [self::class, 'add_query_vars']);
        // Priority 0: the challenge document must win before any theme or SEO
        // plugin renders a 404 page for the well-known path.
        add_action('template_redirect', [self::class, 'maybe_serve_identity_document'], 0);

        add_action('admin_menu', [self::class, 'register_admin_menu']);
        add_action('admin_post_' . self::ACTION_VERIFY, [self::class, 'handle_verify']);
        add_action('admin_post_' . self::ACTION_ENROLL, [self::class, 'handle_enroll']);
        add_action('admin_post_' . self::ACTION_WITHDRAW, [self::class, 'handle_withdraw']);
        add_action('admin_post_' . self::ACTION_SALES_HOUSE, [self::class, 'handle_sales_house']);
        add_action(self::PROBE_HOOK, [self::class, 'refresh_availability']);
    }

    /* ---------------------------------------------------------------------
     * Lifecycle
     * ------------------------------------------------------------------ */

    /**
     * Register the well-known rewrite rule and persist it. Run on activation so
     * the challenge path resolves on hosts whose rewrite cache is cold.
     */
    public static function on_activate(): void
    {
        self::add_rewrite_rules();
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules -- one-time plugin activation hook; registers the .well-known challenge route.
        flush_rewrite_rules();

        if (function_exists('is_multisite') && is_multisite()) {
            self::scrub_network_enrollments();
        }
    }

    /**
     * Stop advertising a route WordPress no longer serves, and drop the cached
     * capability probe so a re-activation re-probes instead of trusting stale
     * state.
     */
    public static function on_deactivate(): void
    {
        delete_transient(self::AVAILABILITY_TRANSIENT);
        delete_transient(self::AD_LAYOUTS_TRANSIENT);
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules -- one-time plugin deactivation hook; removes the .well-known challenge route.
        flush_rewrite_rules();
    }

    public static function add_rewrite_rules(): void
    {
        add_rewrite_rule('^\.well-known/encypher\.json$', 'index.php?' . self::QUERY_VAR . '=1', 'top');
    }

    /**
     * @param array<int,string> $vars
     * @return array<int,string>
     */
    public static function add_query_vars($vars): array
    {
        $vars = is_array($vars) ? $vars : [];
        $vars[] = self::QUERY_VAR;
        return $vars;
    }

    /* ---------------------------------------------------------------------
     * Challenge document
     * ------------------------------------------------------------------ */

    /**
     * Serve https://{host}/.well-known/encypher.json.
     *
     * Matches the rewrite query var when rewrite rules are live and falls back
     * to an exact REQUEST_URI path match, so verification also succeeds on
     * installs whose rewrite cache was never flushed (plain permalinks, cold
     * caches, hosts that route unknown paths straight to index.php).
     */
    public static function maybe_serve_identity_document(): void
    {
        if (! self::is_identity_document_request()) {
            return;
        }

        $document = self::identity_document_response();
        nocache_headers();
        header('Content-Type: application/json; charset=utf-8');
        status_header($document['status']);
        echo $document['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- machine-read JSON proof document built by wp_json_encode().
        exit;
    }

    /**
     * The status and body served at the well-known path. Extracted from the
     * exiting handler so the proof contract is directly testable.
     *
     * @return array{status:int,body:string}
     */
    public static function identity_document_response(): array
    {
        $token = self::stored_challenge_token();
        if ('' === $token) {
            // No live challenge for THIS host: never invent one, never 200 an
            // empty proof.
            return ['status' => 404, 'body' => (string) wp_json_encode(['error' => 'no_active_challenge'])];
        }

        return ['status' => 200, 'body' => self::identity_document($token)];
    }

    private static function is_identity_document_request(): bool
    {
        if ('' !== (string) get_query_var(self::QUERY_VAR)) {
            return true;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- unauthenticated public proof document; path-only read, no state change.
        $request_uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '';
        if ('' === $request_uri) {
            return false;
        }

        return self::path_matches((string) wp_parse_url($request_uri, PHP_URL_PATH));
    }

    /** Exact path match (one optional trailing slash); no prefix collapse. */
    public static function path_matches(string $path): bool
    {
        return self::WELL_KNOWN_PATH === rtrim($path, '/') || self::WELL_KNOWN_PATH === $path;
    }

    /** The exact JSON body publisher_identity.verify_identity() matches on. */
    public static function identity_document(string $token): string
    {
        return (string) wp_json_encode([self::DOCUMENT_FIELD => $token]);
    }

    public static function challenge_url(string $domain, string $token): string
    {
        return '' === $token ? '' : 'https://' . $domain . self::WELL_KNOWN_PATH;
    }

    /* ---------------------------------------------------------------------
     * Settings state
     * ------------------------------------------------------------------ */

    /** @return array<string,mixed> */
    private static function settings(): array
    {
        $settings = get_option(self::SETTINGS_KEY, []);
        return is_array($settings) ? $settings : [];
    }

    /** @param array<string,mixed> $settings */
    private static function save(array $settings): void
    {
        update_option(self::SETTINGS_KEY, $settings);
    }

    /** Lowercased, root-dot-stripped host of this site. */
    public static function site_domain(): string
    {
        $host = (string) wp_parse_url(home_url('/'), PHP_URL_HOST);
        return strtolower(rtrim($host, '.'));
    }

    private static function stored_challenge_token(): string
    {
        $settings = self::settings();
        $token = isset($settings[self::CHALLENGE_KEY]) ? trim((string) $settings[self::CHALLENGE_KEY]) : '';
        if ('' === $token) {
            return '';
        }

        // A challenge propagated from another site in a multisite network is not
        // ours to serve: it would prove the wrong host.
        return self::domain_scoped($settings, self::IDENTITY_DOMAIN_KEY) ? $token : '';
    }

    /**
     * Whether a stored *_domain marker matches this site, so network-propagated
     * settings (class-encypher-multisite.php copies the connection to every
     * site) can never make one site act on another site's identity/enrollment.
     *
     * @param array<string,mixed> $settings
     */
    private static function domain_scoped(array $settings, string $domain_key): bool
    {
        $stored = isset($settings[$domain_key]) ? strtolower(trim((string) $settings[$domain_key])) : '';
        return '' !== $stored && $stored === self::site_domain();
    }

    /**
     * Identity id verified for THIS site, or '' when none.
     *
     * Public so other plugin surfaces can read it without duplicating the
     * multisite domain-scoping rule.
     */
    public static function current_identity_id(): string
    {
        $settings = self::settings();
        if (! self::domain_scoped($settings, self::IDENTITY_DOMAIN_KEY)) {
            return '';
        }
        return isset($settings[self::IDENTITY_ID_KEY]) ? trim((string) $settings[self::IDENTITY_ID_KEY]) : '';
    }

    /**
     * Enrollment id belonging to THIS site, or '' when this site is not
     * enrolled. E4 (ad layouts) reads placements scope from here.
     */
    public static function current_enrollment_id(): string
    {
        $settings = self::settings();
        if (! self::domain_scoped($settings, self::ENROLLMENT_DOMAIN_KEY)) {
            return '';
        }
        return isset($settings[self::ENROLLMENT_ID_KEY]) ? trim((string) $settings[self::ENROLLMENT_ID_KEY]) : '';
    }

    private static function store_identity(string $identity_id, string $challenge_token): void
    {
        $settings = self::settings();
        $settings[self::IDENTITY_ID_KEY] = sanitize_text_field($identity_id);
        $settings[self::IDENTITY_DOMAIN_KEY] = self::site_domain();
        if ('' === $challenge_token) {
            unset($settings[self::CHALLENGE_KEY]);
        } else {
            $settings[self::CHALLENGE_KEY] = sanitize_text_field($challenge_token);
        }
        self::save($settings);
    }

    private static function store_enrollment(string $enrollment_id): void
    {
        $settings = self::settings();
        $settings[self::ENROLLMENT_ID_KEY] = sanitize_text_field($enrollment_id);
        $settings[self::ENROLLMENT_DOMAIN_KEY] = self::site_domain();
        self::save($settings);
    }

    /**
     * Multisite: enrollment is per site, but the network batch-attach copies the
     * primary site's settings everywhere. Drop identity/enrollment identifiers
     * on every site whose own host does not match the stored marker, following
     * the get_sites() + switch_to_blog() pattern of Multisite::attach_all_sites().
     *
     * @return int Number of sites scrubbed.
     */
    public static function scrub_network_enrollments(): int
    {
        if (! function_exists('get_sites') || ! function_exists('switch_to_blog')) {
            return 0;
        }

        $scrubbed = 0;
        foreach (get_sites(['number' => 0, 'fields' => 'ids']) as $blog_id) {
            switch_to_blog((int) $blog_id);

            $settings = self::settings();
            $changed = false;
            foreach ([self::IDENTITY_DOMAIN_KEY => [self::IDENTITY_ID_KEY, self::CHALLENGE_KEY], self::ENROLLMENT_DOMAIN_KEY => [self::ENROLLMENT_ID_KEY]] as $domain_key => $owned_keys) {
                if (! isset($settings[$domain_key]) || self::domain_scoped($settings, $domain_key)) {
                    continue;
                }
                foreach (array_merge([$domain_key], $owned_keys) as $key) {
                    if (isset($settings[$key])) {
                        unset($settings[$key]);
                        $changed = true;
                    }
                }
            }
            if ($changed) {
                self::save($settings);
                $scrubbed++;
            }

            restore_current_blog();
        }

        return $scrubbed;
    }

    /* ---------------------------------------------------------------------
     * Transport
     * ------------------------------------------------------------------ */

    /**
     * One guarded JSON call against the organization-authenticated API.
     *
     * DELETE (sales-house revoke) is dispatched through the POST wrapper with an
     * explicit method override: WP_Http::post() merges caller args over its
     * own `method` default, so the request goes out as DELETE while still
     * passing through the single SSRF chokepoint every other plugin call uses.
     *
     * @param string                   $method  GET, POST or DELETE.
     * @param string                   $path    Path appended to api_base_url.
     * @param array<string,mixed>|null $payload JSON body; null sends no body.
     * @return array{status:int,outcome:string,code:string,message:string,body:array<string,mixed>}
     */
    private static function request(string $method, string $path, ?array $payload = null): array
    {
        $settings = self::settings();
        $base = rtrim((string) ($settings['api_base_url'] ?? ''), '/');
        $key = trim((string) ($settings['api_key'] ?? ''));
        if ('' === $base || '' === $key) {
            return self::result(0, 'not_connected', '', __('Connect this site to an Encypher organization first.', 'encypher-provenance'), []);
        }

        $method = strtoupper($method);
        $args = [
            'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin-initiated enrollment action, never a front-end request.
            'headers' => [
                'Authorization' => 'Bearer ' . $key,
                'Accept' => 'application/json',
            ],
        ];
        if (null !== $payload) {
            $args['headers']['Content-Type'] = 'application/json';
            $args['body'] = wp_json_encode($payload);
        }
        if ('GET' !== $method) {
            $args['method'] = $method;
        }

        $url = $base . $path;
        $response = 'GET' === $method
            ? ApiUrlGuard::guarded_remote_get($url, $args)
            : ApiUrlGuard::guarded_remote_post($url, $args);

        if (is_wp_error($response)) {
            return self::result(0, 'transport', '', $response->get_error_message(), []);
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        $decoded = json_decode((string) wp_remote_retrieve_body($response), true);
        $raw = is_array($decoded) ? $decoded : [];
        $body = isset($raw['data']) && is_array($raw['data']) ? $raw['data'] : $raw;

        return self::result(
            $status,
            self::classify($status),
            self::error_code($raw),
            self::error_message($raw),
            $body
        );
    }

    /**
     * @param array<string,mixed> $body
     * @return array{status:int,outcome:string,code:string,message:string,body:array<string,mixed>}
     */
    private static function result(int $status, string $outcome, string $code, string $message, array $body): array
    {
        return ['status' => $status, 'outcome' => $outcome, 'code' => $code, 'message' => $message, 'body' => $body];
    }

    /**
     * Map an HTTP status onto the enrollment outcomes this plugin reacts to.
     * 404 is "feature absent" everywhere: the enrollment API 404s uniformly
     * when its server flag is off, so the plugin must render nothing rather
     * than surface an error.
     */
    public static function classify(int $status): string
    {
        if ($status >= 200 && $status < 300) {
            return 'ok';
        }
        switch ($status) {
            case 404:
                return 'feature_absent';
            case 409:
                return 'conflict';
            case 410:
                return 'challenge_expired';
            case 422:
                return 'invalid';
            case 401:
                return 'unauthorized';
            case 403:
                return 'forbidden';
            case 429:
                return 'rate_limited';
        }
        return 'error';
    }

    /** @param array<string,mixed> $raw */
    public static function error_code(array $raw): string
    {
        $detail = $raw['detail'] ?? null;
        if (is_array($detail) && isset($detail['code']) && is_string($detail['code'])) {
            return $detail['code'];
        }
        if (isset($raw['code']) && is_string($raw['code'])) {
            return $raw['code'];
        }
        return '';
    }

    /** @param array<string,mixed> $raw */
    public static function error_message(array $raw): string
    {
        $detail = $raw['detail'] ?? null;
        if (is_string($detail) && '' !== $detail) {
            return sanitize_text_field($detail);
        }
        if (is_array($detail) && isset($detail['message']) && is_string($detail['message'])) {
            return sanitize_text_field($detail['message']);
        }
        if (isset($raw['message']) && is_string($raw['message'])) {
            return sanitize_text_field($raw['message']);
        }
        return '';
    }

    /* ---------------------------------------------------------------------
     * Capability probe
     * ------------------------------------------------------------------ */

    /**
     * Whether this Encypher account is entitled to the Ad Layouts WordPress surface.
     * Unknown state schedules the shared background probe and fails closed.
     */
    public static function is_ad_layouts_entitled(): bool
    {
        $cached = get_transient(self::AD_LAYOUTS_TRANSIENT);
        if ('yes' === $cached) {
            return true;
        }
        if ('no' === $cached) {
            return false;
        }

        self::schedule_probe();
        return false;
    }


    /**
     * Whether the server exposes the enrollment API. Never performs a blocking
     * request: unknown state schedules a background probe and reports false, so
     * a server with the flag off (404) or an unreachable API keeps the whole
     * surface hidden.
     */
    public static function is_available(): bool
    {
        $cached = get_transient(self::AVAILABILITY_TRANSIENT);
        if ('yes' === $cached) {
            return true;
        }
        if ('no' === $cached) {
            return false;
        }

        self::schedule_probe();
        return false;
    }

    private static function schedule_probe(): void
    {
        $settings = self::settings();
        if ('' === trim((string) ($settings['api_key'] ?? ''))) {
            return;
        }
        if (! wp_next_scheduled(self::PROBE_HOOK)) {
            wp_schedule_single_event(time() + 30, self::PROBE_HOOK);
        }
    }

    /**
     * Probe the account-level WordPress integration entitlement before the AdCP
     * enrollment API. Missing, denied, or unreachable status fails closed, so
     * the sidebar never advertises a feature the Encypher account has not been
     * granted.
     */
    public static function refresh_availability(): bool
    {
        $account = self::request('GET', '/integrations/wordpress/status');
        $entitled = 'ok' === $account['outcome']
            && true === ($account['body']['ad_layouts_entitled'] ?? false);
        set_transient(
            self::AD_LAYOUTS_TRANSIENT,
            $entitled ? 'yes' : 'no',
            self::AVAILABILITY_TTL_NEGATIVE
        );
        if (! $entitled) {
            set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
            return false;
        }

        $result = self::request('GET', '/adcp/enrollment');
        $available = 'ok' === $result['outcome'];
        set_transient(
            self::AVAILABILITY_TRANSIENT,
            $available ? 'yes' : 'no',
            $available ? self::AVAILABILITY_TTL : self::AVAILABILITY_TTL_NEGATIVE
        );
        return $available;
    }

    /* ---------------------------------------------------------------------
     * Admin surface
     * ------------------------------------------------------------------ */

    public static function register_admin_menu(): void
    {
        if (! current_user_can(self::CAPABILITY) || ! self::is_available()) {
            return;
        }

        add_submenu_page(
            'encypher',
            __('AdCP Seller Network', 'encypher-provenance'),
            __('Seller Network', 'encypher-provenance'),
            self::CAPABILITY,
            self::MENU_SLUG,
            [self::class, 'render_page']
        );
    }

    public static function page_url(): string
    {
        return admin_url('admin.php?page=' . self::MENU_SLUG);
    }

    /**
     * Render the enrollment card. A live status call that 404s means the server
     * flag went off since the last probe: re-cache and render nothing.
     */
    public static function render_page(): void
    {
        if (! current_user_can(self::CAPABILITY)) {
            wp_die(esc_html__('You are not allowed to manage AdCP enrollment.', 'encypher-provenance'));
        }

        $status = self::request('GET', '/adcp/enrollment');
        if ('feature_absent' === $status['outcome']) {
            set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
            return;
        }

        $domain = self::site_domain();
        $enrollment = self::find_enrollment($status['body'], $domain);
        $identity_id = self::current_identity_id();
        $settings = self::settings();
        $challenge_token = self::stored_challenge_token();

        // Terms bytes are only needed while an un-enrolled, verified site is
        // deciding. Never fetched (and never rendered) otherwise.
        $terms = null;
        if (null === $enrollment && '' !== $identity_id) {
            $terms = self::fetch_terms();
        }

        $view = [
            'domain' => $domain,
            'identity_id' => $identity_id,
            'identity_verified' => '' !== $identity_id && '' === $challenge_token,
            'challenge_url' => self::challenge_url($domain, $challenge_token),
            'enrollment' => $enrollment,
            'enrollment_id' => self::current_enrollment_id(),
            'evidence_count' => self::evidence_count($enrollment),
            'placement_count' => self::placement_count($enrollment),
            'sales_house' => is_array($enrollment) && isset($enrollment['sales_house']) && is_array($enrollment['sales_house']) ? $enrollment['sales_house'] : null,
            'terms' => $terms,
            'status_error' => 'ok' === $status['outcome'] ? '' : $status['message'],
            'notice' => self::take_notice(),
            'action_url' => admin_url('admin-post.php'),
            'is_connected' => '' !== trim((string) ($settings['api_key'] ?? '')),
        ];

        include dirname(__DIR__) . '/admin/partials/adcp-enrollment-card.php';
    }

    /**
     * Pick this site's enrollment out of a status payload. Accepts either a
     * bare list or an {"enrollments": [...]} envelope, and only ever matches on
     * the exact verified domain.
     *
     * @param array<string,mixed> $body
     * @return array<string,mixed>|null
     */
    public static function find_enrollment(array $body, string $domain): ?array
    {
        $rows = $body;
        if (isset($body['enrollments']) && is_array($body['enrollments'])) {
            $rows = $body['enrollments'];
        }
        if (! is_array($rows)) {
            return null;
        }

        $domain = strtolower(rtrim($domain, '.'));
        foreach ($rows as $row) {
            if (! is_array($row)) {
                continue;
            }
            $row_domain = strtolower(rtrim((string) ($row['verified_domain'] ?? $row['domain'] ?? ''), '.'));
            if ('' !== $row_domain && $row_domain === $domain) {
                return $row;
            }
        }

        return null;
    }

    /** @param array<string,mixed>|null $enrollment */
    public static function evidence_count(?array $enrollment): int
    {
        if (null === $enrollment) {
            return 0;
        }
        foreach (['evidence_count', 'signed_publication_count'] as $key) {
            if (isset($enrollment[$key]) && is_numeric($enrollment[$key])) {
                return max(0, (int) $enrollment[$key]);
            }
        }
        $evidence = $enrollment['evidence'] ?? null;
        if (is_array($evidence) && isset($evidence['count']) && is_numeric($evidence['count'])) {
            return max(0, (int) $evidence['count']);
        }
        return 0;
    }

    /** @param array<string,mixed>|null $enrollment */
    public static function placement_count(?array $enrollment): int
    {
        if (null === $enrollment) {
            return 0;
        }
        if (isset($enrollment['placement_count']) && is_numeric($enrollment['placement_count'])) {
            return max(0, (int) $enrollment['placement_count']);
        }
        if (isset($enrollment['placements']) && is_array($enrollment['placements'])) {
            return count($enrollment['placements']);
        }
        return 0;
    }

    /**
     * Fetch the enrollment terms document and verify the server-declared hash
     * against the bytes we actually received. A mismatch (or any non-2xx)
     * returns null, which keeps the accept control off the page: an acceptance
     * whose hash we cannot stand behind is never offered.
     *
     * @return array{document_key:string,document_sha256:string,content:string}|null
     */
    public static function fetch_terms(): ?array
    {
        $result = self::request('GET', '/adcp/enrollment/terms');
        if ('ok' !== $result['outcome']) {
            return null;
        }
        return self::validate_terms($result['body']);
    }

    /**
     * @param array<string,mixed> $body
     * @return array{document_key:string,document_sha256:string,content:string}|null
     */
    public static function validate_terms(array $body): ?array
    {
        $key = isset($body['document_key']) ? (string) $body['document_key'] : '';
        $sha = isset($body['document_sha256']) ? strtolower(trim((string) $body['document_sha256'])) : '';
        $content = isset($body['content']) ? (string) $body['content'] : '';

        if (self::TERMS_DOCUMENT_KEY !== $key || '' === $content) {
            return null;
        }
        if (! preg_match('/^[0-9a-f]{64}$/', $sha) || ! hash_equals($sha, hash('sha256', $content))) {
            return null;
        }

        return ['document_key' => $key, 'document_sha256' => $sha, 'content' => $content];
    }

    /* ---------------------------------------------------------------------
     * Notices
     * ------------------------------------------------------------------ */

    private static function notice(string $type, string $message): void
    {
        set_transient(
            self::NOTICE_TRANSIENT_PREFIX . get_current_user_id(),
            ['type' => $type, 'message' => $message],
            300
        );
    }

    /** @return array{type:string,message:string}|null */
    private static function take_notice(): ?array
    {
        $key = self::NOTICE_TRANSIENT_PREFIX . get_current_user_id();
        $notice = get_transient($key);
        delete_transient($key);
        if (! is_array($notice) || ! isset($notice['type'], $notice['message'])) {
            return null;
        }
        return ['type' => (string) $notice['type'], 'message' => (string) $notice['message']];
    }

    /**
     * The takeover-protection message. A 409 on register/verify means another
     * organization holds an active verification for this exact domain; the
     * publisher cannot resolve that themselves, so point them at support with
     * the domain in hand.
     */
    public static function conflict_message(string $domain): string
    {
        return sprintf(
            /* translators: %s: this site's domain. */
            __('%s is already verified by another Encypher organization, so it cannot be claimed here. Contact support@encypher.com from an address at this domain to transfer it.', 'encypher-provenance'),
            $domain
        );
    }

    /* ---------------------------------------------------------------------
     * Action handlers
     * ------------------------------------------------------------------ */

    /**
     * Capability half of the form gate. The nonce half is deliberately NOT
     * hidden behind this helper: every handler calls check_admin_referer()
     * inline with its own action, immediately before it touches request data
     * (the same shape as EncypherMultisite::handle_attach()). That keeps the
     * verification statically visible at each $_POST read site.
     */
    private static function require_capability(): void
    {
        if (! current_user_can(self::CAPABILITY)) {
            wp_die(esc_html__('You are not allowed to manage AdCP enrollment.', 'encypher-provenance'));
        }
    }

    private static function done(): void
    {
        wp_safe_redirect(self::page_url());
        exit;
    }

    /**
     * Register (when needed) then verify the PublisherIdentity for this exact
     * host, proving control with the well-known document this class serves.
     */
    public static function handle_verify(): void
    {
        self::require_capability();
        check_admin_referer(self::ACTION_VERIFY);

        $domain = self::site_domain();
        if ('' === $domain) {
            self::notice('error', __('Could not determine this site host.', 'encypher-provenance'));
            self::done();
        }

        $identity_id = self::current_identity_id();
        $token = self::stored_challenge_token();

        if ('' === $identity_id || '' === $token) {
            $registered = self::request('POST', '/provenance/publisher-identities', ['domain' => $domain]);
            if ('conflict' === $registered['outcome']) {
                self::notice('error', self::conflict_message($domain));
                self::done();
            }
            if ('feature_absent' === $registered['outcome']) {
                set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
                self::done();
            }
            if ('ok' !== $registered['outcome']) {
                self::notice('error', self::fallback_message($registered, __('Could not register this domain with Encypher.', 'encypher-provenance')));
                self::done();
            }

            $identity_id = (string) ($registered['body']['identity_id'] ?? '');
            $token = self::challenge_token_from($registered['body']);
            if ('' === $identity_id) {
                self::notice('error', __('Encypher did not return a publisher identity for this domain.', 'encypher-provenance'));
                self::done();
            }
            self::store_identity($identity_id, $token);

            // The API is about to fetch our well-known URL: make sure the
            // rewrite rule is live for this host before asking it to.
            self::add_rewrite_rules();
            // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules -- once per domain-challenge issuance, immediately before remote verification fetches the route.
            flush_rewrite_rules(false);
        }

        $verified = self::request('POST', '/provenance/publisher-identities/' . rawurlencode($identity_id) . '/verify', ['method' => 'well_known']);
        if ('conflict' === $verified['outcome']) {
            self::notice('error', self::conflict_message($domain));
            self::done();
        }
        if ('challenge_expired' === $verified['outcome']) {
            // Drop the dead challenge so the next attempt registers fresh.
            self::store_identity($identity_id, '');
            self::notice('error', __('The domain challenge expired. Run verification again to start a new one.', 'encypher-provenance'));
            self::done();
        }
        if ('ok' !== $verified['outcome'] || 'verified' !== (string) ($verified['body']['status'] ?? '')) {
            self::notice(
                'error',
                sprintf(
                    /* translators: %s: the challenge URL the Encypher API fetches. */
                    __('Encypher could not read the verification document. Confirm %s is publicly reachable over HTTPS, then try again.', 'encypher-provenance'),
                    self::challenge_url($domain, $token)
                )
            );
            self::done();
        }

        // Verified: the challenge is spent, stop serving it.
        self::store_identity($identity_id, '');
        self::notice('success', sprintf(
            /* translators: %s: this site's domain. */
            __('%s is verified. You can now accept the terms and enroll.', 'encypher-provenance'),
            $domain
        ));
        self::done();
    }

    /** @param array<string,mixed> $body */
    public static function challenge_token_from(array $body): string
    {
        $challenge = $body['challenge'] ?? null;
        if (is_array($challenge) && isset($challenge['token']) && is_string($challenge['token'])) {
            return trim($challenge['token']);
        }
        return '';
    }

    /**
     * Accept the terms and enroll this property. The acceptance hash is always
     * the one the server just handed us for bytes we hashed ourselves; a stale
     * rendered page cannot submit an outdated acceptance.
     */
    public static function handle_enroll(): void
    {
        self::require_capability();
        check_admin_referer(self::ACTION_ENROLL);

        $identity_id = self::current_identity_id();
        if ('' === $identity_id) {
            self::notice('error', __('Verify this domain before enrolling.', 'encypher-provenance'));
            self::done();
        }
        if (! isset($_POST['encypher_adcp_accept_terms'])) {
            self::notice('error', __('You must accept the enrollment terms to enroll.', 'encypher-provenance'));
            self::done();
        }

        $terms = self::fetch_terms();
        if (null === $terms) {
            self::notice('error', __('Could not load the enrollment terms document. Nothing was accepted.', 'encypher-provenance'));
            self::done();
        }

        $shown_sha = isset($_POST['encypher_adcp_terms_sha256'])
            ? strtolower(sanitize_text_field(wp_unslash((string) $_POST['encypher_adcp_terms_sha256'])))
            : '';
        if (! hash_equals($terms['document_sha256'], $shown_sha)) {
            self::notice('error', __('The enrollment terms changed while this page was open. Review the new terms and accept again.', 'encypher-provenance'));
            self::done();
        }

        $payload = [
            'domain' => self::site_domain(),
            'terms_acceptance' => [
                'document_key' => $terms['document_key'],
                'document_sha256' => $terms['document_sha256'],
                'acceptance_context' => self::ACCEPTANCE_CONTEXT,
            ],
        ];
        $sales_house = self::sales_house_from_request(self::ACTION_ENROLL);
        if (null !== $sales_house) {
            $payload['sales_house'] = $sales_house;
        }

        $result = self::request('POST', '/adcp/enrollment', $payload);
        if ('feature_absent' === $result['outcome']) {
            set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
            self::done();
        }
        if ('conflict' === $result['outcome']) {
            // Already enrolled: adopt the server's id rather than reporting an
            // error the publisher cannot act on.
            $existing = self::find_enrollment(self::request('GET', '/adcp/enrollment')['body'], self::site_domain());
            if (is_array($existing) && '' !== (string) ($existing['id'] ?? '')) {
                self::store_enrollment((string) $existing['id']);
                self::notice('success', __('This property is already enrolled in the AdCP seller network.', 'encypher-provenance'));
                self::done();
            }
            self::notice('error', self::fallback_message($result, __('This property could not be enrolled.', 'encypher-provenance')));
            self::done();
        }
        if ('ok' !== $result['outcome']) {
            self::notice('error', self::fallback_message($result, __('Encypher rejected the enrollment request.', 'encypher-provenance')));
            self::done();
        }

        $enrollment_id = (string) ($result['body']['id'] ?? $result['body']['enrollment_id'] ?? '');
        if ('' === $enrollment_id) {
            self::notice('error', __('Encypher did not return an enrollment id.', 'encypher-provenance'));
            self::done();
        }
        self::store_enrollment($enrollment_id);
        self::notice('success', __('This property is enrolled. Exposure to buyers still requires an Encypher contract and grant.', 'encypher-provenance'));
        self::done();
    }

    public static function handle_withdraw(): void
    {
        self::require_capability();
        check_admin_referer(self::ACTION_WITHDRAW);

        $enrollment_id = self::current_enrollment_id();
        if ('' === $enrollment_id) {
            self::notice('error', __('This site has no enrollment to withdraw.', 'encypher-provenance'));
            self::done();
        }

        $reason = isset($_POST['encypher_adcp_withdrawal_reason'])
            ? sanitize_text_field(wp_unslash((string) $_POST['encypher_adcp_withdrawal_reason']))
            : '';
        // 255 is the server column bound; a longer reason is truncated, never rejected.
        $payload = '' === $reason ? [] : ['withdrawal_reason' => mb_substr($reason, 0, 255)];

        $result = self::request('POST', '/adcp/enrollment/' . rawurlencode($enrollment_id) . '/withdraw', $payload);
        if ('feature_absent' === $result['outcome']) {
            set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
            self::done();
        }
        if ('ok' !== $result['outcome']) {
            self::notice('error', self::fallback_message($result, __('Encypher could not withdraw this enrollment.', 'encypher-provenance')));
            self::done();
        }

        self::notice('success', __('This property is withdrawn from the AdCP seller network.', 'encypher-provenance'));
        self::done();
    }

    public static function handle_sales_house(): void
    {
        self::require_capability();
        check_admin_referer(self::ACTION_SALES_HOUSE);

        $enrollment_id = self::current_enrollment_id();
        if ('' === $enrollment_id) {
            self::notice('error', __('Enroll this property before declaring a sales house.', 'encypher-provenance'));
            self::done();
        }

        $path = '/adcp/enrollment/' . rawurlencode($enrollment_id) . '/sales-house';
        $revoke = isset($_POST['encypher_adcp_revoke_sales_house']);
        if ($revoke) {
            // Revoke is DELETE on the declaration path, no body (frozen contract).
            $result = self::request('DELETE', $path);
            $success = __('Sales-house delegation revoked.', 'encypher-provenance');
        } else {
            $sales_house = self::sales_house_from_request(self::ACTION_SALES_HOUSE);
            if (null === $sales_house) {
                self::notice('error', __('A sales house needs a valid registry key (lowercase letters, digits and underscores) and a display name.', 'encypher-provenance'));
                self::done();
            }
            $result = self::request('POST', $path, $sales_house);
            $success = __('Sales house declared. Fulfillment routes through them once Encypher contracts that route.', 'encypher-provenance');
        }

        if ('feature_absent' === $result['outcome']) {
            set_transient(self::AVAILABILITY_TRANSIENT, 'no', self::AVAILABILITY_TTL_NEGATIVE);
            self::done();
        }
        if ('ok' !== $result['outcome']) {
            self::notice('error', self::fallback_message($result, __('Encypher could not update the sales-house declaration.', 'encypher-provenance')));
            self::done();
        }

        self::notice('success', $success);
        self::done();
    }

    /**
     * Validated sales-house declaration from the submitted form, or null when
     * absent/invalid. Registry keys follow the frozen contract
     * `^[a-z0-9][a-z0-9_]{1,62}$`; anything else is rejected locally.
     *
     * Re-verifies the caller's nonce: this reader is the only place the
     * sales-house fields are pulled off the request, so the check belongs
     * here rather than one frame up. check_admin_referer() dies on failure,
     * so a second call after the handler's own is a no-op on the happy path.
     *
     * @param string $action Nonce action the submitting form was rendered with.
     *
     * @return array{key:string,display_name:string,seat_id?:string}|null
     */
    private static function sales_house_from_request(string $action): ?array
    {
        check_admin_referer($action);

        $key = isset($_POST['encypher_adcp_sales_house_key'])
            ? strtolower(sanitize_text_field(wp_unslash((string) $_POST['encypher_adcp_sales_house_key'])))
            : '';
        $display_name = isset($_POST['encypher_adcp_sales_house_name'])
            ? sanitize_text_field(wp_unslash((string) $_POST['encypher_adcp_sales_house_name']))
            : '';
        $seat_id = isset($_POST['encypher_adcp_sales_house_seat'])
            ? sanitize_text_field(wp_unslash((string) $_POST['encypher_adcp_sales_house_seat']))
            : '';

        return self::build_sales_house($key, $display_name, $seat_id);
    }

    /**
     * @return array{key:string,display_name:string,seat_id?:string}|null
     */
    public static function build_sales_house(string $key, string $display_name, string $seat_id = ''): ?array
    {
        $key = strtolower(trim($key));
        $display_name = trim($display_name);
        $seat_id = trim($seat_id);

        if ('' === $key && '' === $display_name) {
            return null;
        }
        if (! preg_match(self::SALES_HOUSE_KEY_PATTERN, $key) || '' === $display_name) {
            return null;
        }

        $declaration = [
            'key' => $key,
            'display_name' => mb_substr($display_name, 0, 200),
        ];
        if ('' !== $seat_id) {
            $declaration['seat_id'] = mb_substr($seat_id, 0, 200);
        }
        return $declaration;
    }

    /**
     * @param array{status:int,outcome:string,code:string,message:string,body:array<string,mixed>} $result
     */
    private static function fallback_message(array $result, string $fallback): string
    {
        return '' !== $result['message'] ? $result['message'] : $fallback;
    }
}

// Rewrite-rule persistence has to be armed at plugin-load time: the activation
// hook fires in a request where `plugins_loaded` has already passed.
if (function_exists('register_activation_hook') && defined('ENCYPHER_PROVENANCE_PLUGIN_FILE')) {
    register_activation_hook(ENCYPHER_PROVENANCE_PLUGIN_FILE, [SellerEnrollment::class, 'on_activate']);
    register_deactivation_hook(ENCYPHER_PROVENANCE_PLUGIN_FILE, [SellerEnrollment::class, 'on_deactivate']);
}
