<?php
/**
 * Verification Normalizer
 *
 * Maps the provenance report payload produced by
 * Rest::handle_provenance_request() into the display sections consumed by
 * templates/provenance-report.php.
 *
 * Two design rules.
 *
 * ONE VERDICT. The trust banner reads the canonical `reader_verdict` through
 * \EncypherProvenance\ReaderVerdict and nothing else. It does not inspect
 * `valid`, `tampered`, `validated`, or any signer field to decide a verdict: the
 * report, the reader badge, the dialog, and the admin screens must agree about
 * the same bytes, and they can only do that by reading one answer. When no live
 * verdict is available the report falls closed to the same conservative local
 * pre-check state the badge uses, never to a positive one.
 *
 * PUBLISHER-READABLE FACTS. Everything below the banner is descriptive, and it
 * is descriptive only when the verdict says the manifest validated. Rights read
 * out of an unvalidated manifest are an unproven claim about how a work may be
 * used, so they are omitted rather than shown with a caveat. The raw payload is
 * still available, but as an explicit technical disclosure rather than as the
 * primary content of the page.
 *
 * Lives in the GLOBAL namespace because the (non-namespaced) report template
 * references it as a bare `Encypher_Verification_Normalizer`.
 *
 * @package EncypherProvenance
 */

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

require_once __DIR__ . '/class-encypher-reader-verdict.php';

use EncypherProvenance\ReaderVerdict;

class Encypher_Verification_Normalizer
{
    /**
     * Banner styling per canonical reader verdict.
     *
     * Keys are exactly the contract's five terminal states plus the two local
     * display states the server may render before a live check. Labels and
     * descriptions are NOT duplicated here: they come from ReaderVerdict, so a
     * change to reader copy cannot leave the report page saying something else.
     *
     * `trusted` is the only green banner. Painting `valid_untrusted` green was
     * the visual half of the contradiction this release removes.
     *
     * @var array<string,array{bg:string,color:string}>
     */
    public const VERDICT_STATES = [
        'trusted' => [
            'bg' => '#dcfce7',
            'color' => '#166534',
        ],
        'valid_untrusted' => [
            'bg' => '#fef3c7',
            'color' => '#92400e',
        ],
        'changed' => [
            'bg' => '#fee2e2',
            'color' => '#991b1b',
        ],
        'unavailable' => [
            'bg' => '#f3f4f6',
            'color' => '#6b7280',
        ],
        'not_present' => [
            'bg' => '#f3f4f6',
            'color' => '#6b7280',
        ],
        'signed_unchecked' => [
            'bg' => '#e8eef6',
            'color' => '#1b2f50',
        ],
        'checking' => [
            'bg' => '#e8eef6',
            'color' => '#1b2f50',
        ],
    ];

    /** Assertion label prefixes this normalizer knows how to read. */
    private const ACTION_LABELS = ['c2pa.actions'];
    private const INGREDIENT_LABELS = ['c2pa.ingredient'];
    private const TRAINING_LABELS = ['cawg.training-mining', 'c2pa.training-mining'];
    private const CREATIVE_WORK_LABELS = ['stds.schema-org.CreativeWork'];

    /**
     * Normalize a provenance report into display sections.
     *
     * @param array $report Output of Rest::handle_provenance_request()->get_data().
     * @return array{trust_verdict:array,signer_info:array,content_summary:array,rights_permissions:array,provenance_chain:array,manifest_details:array,technical_json:string}
     */
    public static function normalize(array $report): array
    {
        $post = isset($report['post']) && is_array($report['post']) ? $report['post'] : [];
        $c2pa = isset($report['c2pa']) && is_array($report['c2pa']) ? $report['c2pa'] : [];
        $verification = isset($report['verification']) && is_array($report['verification']) ? $report['verification'] : null;
        $metadata = ($verification && isset($verification['metadata']) && is_array($verification['metadata']))
            ? $verification['metadata']
            : [];

        $verdict = self::resolve_verdict($verification, $report, $c2pa);
        $manifest = self::read_manifest($metadata);
        $validated = in_array($verdict['status'], ['trusted', 'valid_untrusted'], true);

        return [
            'trust_verdict' => self::build_verdict($verdict),
            'signer_info' => self::build_signer($verdict, $verification, $metadata, $c2pa),
            'content_summary' => self::build_summary($post, $c2pa),
            'rights_permissions' => $validated ? self::build_rights($manifest) : self::empty_rights(),
            'provenance_chain' => $validated ? self::build_chain($post, $verdict, $verification, $manifest) : [],
            'manifest_details' => self::build_manifest_details($verdict, $verification, $metadata, $manifest, $c2pa, $validated),
            'technical_json' => self::build_technical_json($report, $verification),
        ];
    }

    /**
     * Resolve the ONE verdict this page renders.
     *
     * A live `reader_verdict` wins. Absent one, the report falls back to the
     * same local pre-check the reader badge uses, so the two surfaces cannot
     * disagree: a post whose served bytes are not its committed signed snapshot
     * reads `changed`, an incomplete signing run reads `unavailable`, and
     * anything else reads `signed_unchecked`. None of those is a positive trust
     * claim, which is the point: trust requires a completed live check.
     *
     * @param array<string,mixed>|null $verification
     * @param array<string,mixed>      $report
     * @param array<string,mixed>      $c2pa
     * @return array<string,mixed>
     */
    private static function resolve_verdict(?array $verification, array $report, array $c2pa): array
    {
        foreach ([$verification, $report] as $candidate) {
            if (! is_array($candidate)) {
                continue;
            }
            $read = ReaderVerdict::verdict_from_payload($candidate);
            if (null !== $read) {
                return $read;
            }
        }

        $status = isset($c2pa['status']) ? (string) $c2pa['status'] : '';
        $signed_statuses = ['c2pa_protected', 'c2pa_verified', 'signed', 'verified'];

        if (in_array($status, ['embedding_failed', 'media_signing_failed'], true)) {
            return ReaderVerdict::verdict_from_error('signing_incomplete');
        }
        if ('modified' === $status || 'tampered' === $status) {
            return ReaderVerdict::verdict_from_error('provenance_not_current');
        }
        if (in_array($status, $signed_statuses, true)) {
            return self::local_verdict('signed_unchecked');
        }
        if ('not_signed' === $status || '' === $status) {
            return self::local_verdict('not_present');
        }

        return self::local_verdict('unavailable');
    }

    /**
     * A verdict record for a locally determined state, carrying no wire status
     * and no reason code so copy resolution uses the state's own sentence.
     *
     * @return array<string,mixed>
     */
    private static function local_verdict(string $state): array
    {
        return [
            'status' => $state,
            'wire_status' => '',
            'provenance_present' => false,
            'valid' => false,
            'tampered' => false,
            'revoked' => false,
            'reason_code' => '',
            'signer_trust' => 'unknown',
            'trust_policy' => '',
            'trust_anchor' => '',
            'trust_validation_time' => '',
            'retryable' => false,
        ];
    }

    /**
     * The banner: label, sentence, and colours, all keyed off the one verdict.
     *
     * @param array<string,mixed> $verdict
     * @return array{state:string,label:string,description:string,bg:string,color:string,trusted:bool,glyph:string,tone:string}
     */
    private static function build_verdict(array $verdict): array
    {
        $state = (string) $verdict['status'];
        $presentation = ReaderVerdict::presentation($state);
        $colours = self::VERDICT_STATES[$state] ?? self::VERDICT_STATES['unavailable'];

        return [
            'state' => $presentation['state'],
            'label' => $presentation['shortLabel'],
            'description' => ReaderVerdict::detail_copy($verdict),
            'bg' => $colours['bg'],
            'color' => $colours['color'],
            'trusted' => $presentation['trusted'],
            'glyph' => $presentation['glyph'],
            'tone' => $presentation['tone'],
        ];
    }

    /**
     * The signer card.
     *
     * The name's LABEL depends on the verdict's signer trust, not on whether a
     * name exists. A certificate subject with no accepted anchor is a claim, and
     * presenting it as "Publisher" was how an unanchored self-signed certificate
     * came to read as an identified publisher.
     *
     * @param array<string,mixed>      $verdict
     * @param array<string,mixed>|null $verification
     * @param array<string,mixed>      $metadata
     * @param array<string,mixed>      $c2pa
     */
    private static function build_signer(array $verdict, ?array $verification, array $metadata, array $c2pa): array
    {
        $identity = ReaderVerdict::localized_identity_table();
        $trust_states = $identity['signerTrust'];
        $signer_trust = (string) $verdict['signer_trust'];
        $trust = $trust_states[$signer_trust] ?? $trust_states['unknown'];

        $publisher_name = null;
        $signed_date = null;

        if (null !== $verification) {
            $name = $verification['signer_name'] ?? ($verification['signing_identity'] ?? null);
            $publisher_name = (is_string($name) && '' !== $name) ? $name : null;
            $signed_date = ! empty($verification['verified_at']) ? (string) $verification['verified_at'] : null;
        }
        if (null === $signed_date && ! empty($c2pa['last_signed'])) {
            $signed_date = (string) $c2pa['last_signed'];
        }

        $certificate_subject = null;
        if (isset($metadata['certificate_chain']) && is_string($metadata['certificate_chain']) && '' !== $metadata['certificate_chain']) {
            $certificate_subject = $metadata['certificate_chain'];
        }

        return [
            'publisher_name' => $publisher_name,
            'name_label' => $trust['nameLabel'],
            'trust_label' => $trust['trustLabel'],
            'trust_value' => $trust['trustValue'],
            'trust_tone' => $trust['tone'],
            'signer_trust' => $signer_trust,
            'certificate_subject' => $certificate_subject,
            // Membership is a fact about an accepted anchor, so it is read off
            // the verdict, never off a bare `validated` flag in the manifest.
            'trust_list_member' => 'trusted' === $signer_trust,
            'trust_anchor' => (string) $verdict['trust_anchor'],
            'trust_policy' => (string) $verdict['trust_policy'],
            'signed_date' => $signed_date ? self::format_date($signed_date) : null,
            'instance_id' => ! empty($metadata['instance_id']) ? (string) $metadata['instance_id'] : null,
        ];
    }

    /**
     * Build the content summary from the post record. title === null hides it.
     */
    private static function build_summary(array $post, array $c2pa): array
    {
        $title = isset($post['title']) && '' !== $post['title'] ? (string) $post['title'] : null;

        return [
            'title' => $title,
            'author' => isset($post['author']) && '' !== $post['author'] ? (string) $post['author'] : null,
            'published' => isset($post['published']) && '' !== $post['published'] ? self::format_date((string) $post['published']) : null,
            'modified' => isset($post['modified']) && '' !== $post['modified'] ? self::format_date((string) $post['modified']) : null,
            'url' => isset($post['url']) && '' !== $post['url'] ? (string) $post['url'] : null,
            'total_sentences' => isset($c2pa['total_sentences']) ? (int) $c2pa['total_sentences'] : 0,
        ];
    }

    /**
     * Read the facts a publisher recognizes out of the manifest's assertions.
     *
     * Only exact known labels and keys are read. An assertion shape this build
     * does not recognize is skipped rather than guessed at: a missing row is
     * correct, an invented one is not.
     *
     * @param array<string,mixed> $metadata verification.metadata
     * @return array{actions:list<string>,source_type:string,software_agent:string,ingredients:list<array{title:string,relationship:string}>,training:array<string,bool>,license_type:string,usage_terms:string,license_url:string,copyright_holder:string}
     */
    private static function read_manifest(array $metadata): array
    {
        $manifest = [
            'actions' => [],
            'source_type' => '',
            'software_agent' => '',
            'ingredients' => [],
            'training' => [],
            'license_type' => '',
            'usage_terms' => '',
            'license_url' => '',
            'copyright_holder' => '',
        ];

        $assertions = isset($metadata['assertions']) && is_array($metadata['assertions']) ? $metadata['assertions'] : [];
        foreach ($assertions as $assertion) {
            if (! is_array($assertion)) {
                continue;
            }
            $label = (string) ($assertion['label'] ?? '');
            $data = isset($assertion['data']) && is_array($assertion['data']) ? $assertion['data'] : [];

            if (self::label_matches($label, self::ACTION_LABELS)) {
                self::read_actions($data, $manifest);
                continue;
            }
            if (self::label_matches($label, self::INGREDIENT_LABELS)) {
                $title = trim((string) ($data['title'] ?? ''));
                if ('' !== $title) {
                    $manifest['ingredients'][] = [
                        'title' => $title,
                        'relationship' => trim((string) ($data['relationship'] ?? '')),
                    ];
                }
                continue;
            }
            if (self::label_matches($label, self::TRAINING_LABELS)) {
                $use = isset($data['use']) && is_array($data['use']) ? $data['use'] : $data;
                foreach (['ai_training', 'ai_inference', 'data_mining'] as $key) {
                    if (array_key_exists($key, $use)) {
                        $manifest['training'][$key] = (bool) $use[$key];
                    }
                }
                continue;
            }
            if (self::label_matches($label, self::CREATIVE_WORK_LABELS)) {
                $manifest['license_type'] = self::first_string($manifest['license_type'], $data['license'] ?? '', $data['license_type'] ?? '');
                $manifest['usage_terms'] = self::first_string($manifest['usage_terms'], $data['usageTerms'] ?? '', $data['usage_terms'] ?? '');
                $manifest['license_url'] = self::first_string($manifest['license_url'], $data['licenseUrl'] ?? '', $data['license_url'] ?? '');
                $manifest['copyright_holder'] = self::first_string(
                    $manifest['copyright_holder'],
                    is_array($data['copyrightHolder'] ?? null) ? ($data['copyrightHolder']['name'] ?? '') : ($data['copyrightHolder'] ?? ''),
                    $data['copyright_holder'] ?? ''
                );
            }
        }

        $manifest['ingredients'] = array_values($manifest['ingredients']);
        $manifest['actions'] = array_values(array_unique($manifest['actions']));

        return $manifest;
    }

    /**
     * @param array<string,mixed> $data     One actions assertion's data block.
     * @param array<string,mixed> $manifest Accumulator, by reference.
     */
    private static function read_actions(array $data, array &$manifest): void
    {
        $actions = isset($data['actions']) && is_array($data['actions']) ? $data['actions'] : [];
        foreach ($actions as $action) {
            if (! is_array($action)) {
                continue;
            }
            $name = trim((string) ($action['action'] ?? ''));
            if ('' !== $name) {
                $manifest['actions'][] = self::plain_action($name);
            }
            if ('' === $manifest['source_type'] && ! empty($action['digitalSourceType'])) {
                $manifest['source_type'] = self::plain_source_type((string) $action['digitalSourceType']);
            }
            if ('' === $manifest['software_agent'] && isset($action['softwareAgent'])) {
                $agent = $action['softwareAgent'];
                if (is_array($agent)) {
                    $version = trim((string) ($agent['version'] ?? ''));
                    $manifest['software_agent'] = trim((string) ($agent['name'] ?? '') . ('' !== $version ? ' ' . $version : ''));
                } elseif (is_string($agent)) {
                    $manifest['software_agent'] = $agent;
                }
            }
        }
    }

    /** Whether an assertion label belongs to one of the known families. */
    private static function label_matches(string $label, array $prefixes): bool
    {
        foreach ($prefixes as $prefix) {
            if (0 === strpos($label, $prefix)) {
                return true;
            }
        }

        return false;
    }

    /** The first non-empty candidate, keeping an already-resolved value. */
    private static function first_string(string $current, ...$candidates): string
    {
        if ('' !== $current) {
            return $current;
        }
        foreach ($candidates as $candidate) {
            if (is_string($candidate) && '' !== trim($candidate)) {
                return trim($candidate);
            }
        }

        return '';
    }

    /**
     * Plain-language name for a C2PA action. An unrecognized action is shown
     * verbatim: a real action name is more useful to a publisher than "Other".
     */
    private static function plain_action(string $action): string
    {
        $known = [
            'c2pa.created' => 'Created',
            'c2pa.edited' => 'Edited',
            'c2pa.published' => 'Published',
            'c2pa.converted' => 'Format converted',
            'c2pa.cropped' => 'Cropped',
            'c2pa.resized' => 'Resized',
            'c2pa.filtered' => 'Filtered',
            'c2pa.placed' => 'Placed into this work',
            'c2pa.opened' => 'Opened',
            'c2pa.transcoded' => 'Transcoded',
            'c2pa.repackaged' => 'Repackaged',
        ];

        return $known[$action] ?? $action;
    }

    /** Plain-language digital source type; '' for a URI this build cannot name. */
    private static function plain_source_type(string $uri): string
    {
        $map = [
            'trainedAlgorithmicMedia' => 'Created with generative AI',
            'compositeWithTrainedAlgorithmicMedia' => 'Includes generative AI',
            'digitalCapture' => 'Captured by a camera or recording device',
            'humanEdits' => 'Edited by a person',
            'algorithmicMedia' => 'Generated by an algorithm',
            'digitalArt' => 'Digital art',
            'composite' => 'Composite of several sources',
            'minorHumanEdits' => 'Lightly edited by a person',
        ];
        foreach ($map as $suffix => $plain) {
            if (str_ends_with($uri, '/' . $suffix)) {
                return $plain;
            }
        }

        return '';
    }

    /** @return array<string,mixed> */
    private static function empty_rights(): array
    {
        return [
            'has_permissions' => false,
            'ai_training' => null,
            'ai_inference' => null,
            'data_mining' => null,
            'license_type' => null,
            'usage_terms' => null,
            'constraint_url' => null,
            'copyright_holder' => null,
        ];
    }

    /**
     * Rights as the publisher selected them and the manifest recorded them.
     *
     * Reached only for a verdict whose manifest validated; see normalize().
     *
     * @param array<string,mixed> $manifest Output of read_manifest().
     */
    private static function build_rights(array $manifest): array
    {
        $training = $manifest['training'];
        $permission = static function (?bool $value): ?string {
            if (null === $value) {
                return null;
            }

            return $value ? 'Allowed' : 'Prohibited';
        };

        $rights = [
            'has_permissions' => false,
            'ai_training' => $permission(array_key_exists('ai_training', $training) ? (bool) $training['ai_training'] : null),
            'ai_inference' => $permission(array_key_exists('ai_inference', $training) ? (bool) $training['ai_inference'] : null),
            'data_mining' => $permission(array_key_exists('data_mining', $training) ? (bool) $training['data_mining'] : null),
            'license_type' => '' !== $manifest['license_type'] ? $manifest['license_type'] : null,
            'usage_terms' => '' !== $manifest['usage_terms'] ? $manifest['usage_terms'] : null,
            'constraint_url' => '' !== $manifest['license_url'] ? $manifest['license_url'] : null,
            'copyright_holder' => '' !== $manifest['copyright_holder'] ? $manifest['copyright_holder'] : null,
        ];

        foreach (['ai_training', 'ai_inference', 'data_mining', 'license_type', 'usage_terms', 'constraint_url', 'copyright_holder'] as $key) {
            if (null !== $rights[$key]) {
                $rights['has_permissions'] = true;
                break;
            }
        }

        return $rights;
    }

    /**
     * The provenance chain: this work, plus the ingredients it was built from.
     *
     * Reached only for a verdict whose manifest validated; see normalize().
     *
     * @param array<string,mixed>      $post
     * @param array<string,mixed>      $verdict
     * @param array<string,mixed>|null $verification
     * @param array<string,mixed>      $manifest
     */
    private static function build_chain(array $post, array $verdict, ?array $verification, array $manifest): array
    {
        $signer = null !== $verification
            ? ($verification['signer_name'] ?? ($verification['signing_identity'] ?? null))
            : null;
        $actions = $manifest['actions'];

        $chain = [[
            'type' => 'current',
            'title' => isset($post['title']) && '' !== $post['title'] ? (string) $post['title'] : 'This content',
            'signer' => (is_string($signer) && '' !== $signer) ? $signer : '',
            'action' => ! empty($actions) ? implode(', ', $actions) : 'Signed',
        ]];

        foreach ($manifest['ingredients'] as $ingredient) {
            $chain[] = [
                'type' => 'ingredient',
                'title' => $ingredient['title'],
                'signer' => '',
                'action' => '' !== $ingredient['relationship'] ? $ingredient['relationship'] : 'Ingredient',
            ];
        }

        return $chain;
    }

    /**
     * Publisher-readable manifest details.
     *
     * This section replaced a five-row dump of internal identifiers (document
     * id, status token, two timestamps, a reason code) followed by the whole raw
     * payload. Those rows told a publisher nothing they could act on. The rows
     * below are the questions a publisher actually asks about a signed work:
     * who signed it, when, what was done to it, what it was built from, where it
     * came from, how it may be used, and why the trust verdict came out the way
     * it did. The raw payload survives as an explicit technical disclosure.
     *
     * Rows resolve to '' are dropped: an empty row invites the reader to
     * conclude the fact is absent from the manifest when it may simply be
     * something this build does not read.
     *
     * @param array<string,mixed>      $verdict
     * @param array<string,mixed>|null $verification
     * @param array<string,mixed>      $metadata
     * @param array<string,mixed>      $manifest
     * @param array<string,mixed>      $c2pa
     * @param bool                     $validated Whether the manifest validated.
     * @return array<string,string>
     */
    private static function build_manifest_details(
        array $verdict,
        ?array $verification,
        array $metadata,
        array $manifest,
        array $c2pa,
        bool $validated
    ): array {
        $rows = [];
        $add = static function (string $label, $value) use (&$rows): void {
            if (! is_scalar($value)) {
                return;
            }
            $value = trim((string) $value);
            if ('' !== $value) {
                $rows[$label] = $value;
            }
        };

        $identity = ReaderVerdict::localized_identity_table();
        $trust = $identity['signerTrust'][(string) $verdict['signer_trust']] ?? $identity['signerTrust']['unknown'];

        $signer = null !== $verification
            ? (string) ($verification['signer_name'] ?? ($verification['signing_identity'] ?? ''))
            : '';
        $add($trust['nameLabel'], $signer);
        $add($trust['trustLabel'], $trust['trustValue']);
        $add($identity['anchorLabel'], (string) $verdict['trust_anchor']);
        $add($identity['policyLabel'], (string) $verdict['trust_policy']);

        $signed_at = null !== $verification && ! empty($verification['verified_at'])
            ? (string) $verification['verified_at']
            : (string) ($c2pa['last_signed'] ?? '');
        $add('Signed', '' !== $signed_at ? self::format_date($signed_at) : '');

        if ($validated) {
            $add('What was done', implode(', ', $manifest['actions']));
            $add('Source type', $manifest['source_type']);
            $add('Signing tool', $manifest['software_agent']);
            $ingredient_titles = array_map(
                static function (array $ingredient): string {
                    return '' !== $ingredient['relationship']
                        ? $ingredient['title'] . ' (' . $ingredient['relationship'] . ')'
                        : $ingredient['title'];
                },
                $manifest['ingredients']
            );
            $add('Built from', implode(', ', $ingredient_titles));

            $rights_summary = array_filter([
                '' !== $manifest['license_type'] ? 'License: ' . $manifest['license_type'] : '',
                '' !== $manifest['usage_terms'] ? 'Usage: ' . $manifest['usage_terms'] : '',
                '' !== $manifest['copyright_holder'] ? 'Copyright: ' . $manifest['copyright_holder'] : '',
            ]);
            $add('Rights', implode('; ', $rights_summary));
        }

        // Why the verdict came out this way. The sentence is the reader-facing
        // one; the machine code is included beside it so a publisher opening a
        // support ticket can quote something exact.
        $add($identity['reasonLabel'], ReaderVerdict::detail_copy($verdict));
        $add('Reason code', (string) $verdict['reason_code']);
        $add('Manifest', isset($metadata['manifest_url']) ? (string) $metadata['manifest_url'] : '');

        return $rows;
    }

    /**
     * The optional technical disclosure.
     *
     * Kept, because a developer debugging a signing pipeline needs the exact
     * payload, and demoted, because it was never an answer to a publisher's
     * question. The template renders it behind a closed disclosure.
     */
    private static function build_technical_json(array $report, ?array $verification): string
    {
        $source = null !== $verification ? $verification : $report;
        $json = function_exists('wp_json_encode')
            ? wp_json_encode($source, JSON_PRETTY_PRINT)
            : json_encode($source, JSON_PRETTY_PRINT); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- fallback only when wp_json_encode is unavailable (no-WP/standalone context); wp_json_encode is used whenever WP is loaded.

        return is_string($json) ? $json : '';
    }

    /**
     * Format a date string for display. Pure PHP; passes through unparseable
     * input unchanged.
     */
    private static function format_date(string $value): string
    {
        $ts = strtotime($value);
        if (false === $ts) {
            return $value;
        }

        return gmdate('M j, Y g:i A', $ts) . ' UTC';
    }

    /**
     * Discoverability metadata for the report page <head>.
     *
     * Only a `trusted` verdict is indexable and gets positive structured data.
     * `valid_untrusted` is deliberately excluded: a search engine cannot render
     * "we could not verify who signed it", so a rich result for it would publish
     * a trust claim the page itself refuses to make. Every other state, and
     * every error page, stays noindex and emits nothing.
     *
     * Pure (no WP I/O) for testability.
     *
     * @param array $sections Output of normalize().
     * @param bool  $is_error Whether the underlying report was a WP_Error.
     * @return array{indexable:bool,title:string,description:string,jsonld:?array}
     */
    public static function seo_metadata(array $sections, bool $is_error): array
    {
        $verdict = isset($sections['trust_verdict']) && is_array($sections['trust_verdict']) ? $sections['trust_verdict'] : [];
        $summary = isset($sections['content_summary']) && is_array($sections['content_summary']) ? $sections['content_summary'] : [];
        $signer = isset($sections['signer_info']) && is_array($sections['signer_info']) ? $sections['signer_info'] : [];

        $state = $verdict['state'] ?? 'unavailable';
        $indexable = ! $is_error && ReaderVerdict::is_success_state((string) $state);

        $title = 'C2PA Provenance Report - Encypher';
        $description = 'Content authenticity and provenance verification by Encypher.';
        $jsonld = null;

        if ($indexable) {
            $content_title = isset($summary['title']) ? (string) $summary['title'] : '';
            $publisher = isset($signer['publisher_name']) ? (string) $signer['publisher_name'] : '';

            $title = ('' !== $content_title ? $content_title . ' - ' : '') . 'Verified by Encypher';
            $description = 'Verified C2PA provenance'
                . ('' !== $publisher ? ' from ' . $publisher : '')
                . ('' !== $content_title ? ' for "' . $content_title . '"' : '')
                . '. Cryptographically signed provenance, verified by Encypher.';

            $jsonld = [
                '@context' => 'https://schema.org',
                '@type' => 'CreativeWork',
                'name' => '' !== $content_title ? $content_title : 'Verified content',
                'creativeWorkStatus' => 'Verified by Encypher',
            ];
            if (! empty($summary['url'])) {
                $jsonld['url'] = (string) $summary['url'];
            }
            if ('' !== $publisher) {
                $jsonld['publisher'] = ['@type' => 'Organization', 'name' => $publisher];
                $jsonld['author'] = ['@type' => 'Organization', 'name' => $publisher];
            }
            $signed_date = isset($signer['signed_date']) ? (string) $signer['signed_date'] : '';
            if ('' !== $signed_date && preg_match('/\d{4}-\d{2}-\d{2}/', $signed_date, $m)) {
                $jsonld['datePublished'] = $m[0];
            }
            if (! empty($signer['instance_id'])) {
                $jsonld['identifier'] = (string) $signer['instance_id'];
            }
            $jsonld['subjectOf'] = [
                '@type' => 'CreativeWork',
                'name' => 'C2PA Provenance Verification',
                'creator' => ['@type' => 'Organization', 'name' => 'Encypher'],
            ];
        }

        return [
            'indexable' => $indexable,
            'title' => $title,
            'description' => $description,
            'jsonld' => $jsonld,
        ];
    }
}
