<?php
namespace EncypherProvenance;

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

/**
 * The reader provenance verdict, as this plugin renders it.
 *
 * ONE state table, ONE copy table, ONE glyph per state, shared by the PHP post
 * badge, the media-library column, the report template, and the reader
 * JavaScript (which receives this table localized, so a translated build and an
 * English build say the same thing).
 *
 * The verdict is READ, never re-derived. `verdict_from_payload()` looks for the
 * API's `reader_verdict` object and nothing else: it does not inspect `valid`,
 * `tampered`, `revoked`, `trust_level`, `ca_backed`, or any signer field. A
 * client that guesses a verdict from raw fields is a second precedence, and
 * four surfaces disagreeing about the same bytes is precisely the defect this
 * replaces. When no usable verdict is present the reader falls closed to
 * `unavailable` rather than inventing one.
 *
 * Vocabulary
 * ----------
 * Terminal states are exactly the five in the project contract:
 *
 *   trusted          valid provenance AND a trusted signing credential
 *   valid_untrusted  valid provenance, signer not anchored to a trust list
 *   changed          a record exists and does not vouch for these bytes
 *   unavailable      no check could be completed
 *   not_present      no provenance record exists at all
 *
 * Only `trusted` is a trusted-publisher success, and only `trusted` is allowed
 * trusted-source language or the check glyph.
 *
 * `checking` and `signed_unchecked` are LOCAL display states. They describe a
 * view, not an answer: neither may be stored on a post, returned by the API, or
 * counted as an outcome. `is_terminal_state()` is the gate for both.
 *
 * The API wire enum (enterprise_api/app/schemas/reader_verdict.py) carries seven
 * statuses. `invalid` and `revoked` both mean "a record exists and does not
 * vouch for these bytes", which is what `changed` denotes here; they are NOT
 * `unavailable` (a check DID complete) and NOT `not_present` (a record exists).
 * The raw wire status survives as `wire_status` so the visible sentence stays
 * specific: a revoked credential is never described as an edit.
 *
 * @see enterprise_api/app/schemas/reader_verdict.py The wire SSOT this consumes.
 */
final class ReaderVerdict
{
    /** Wire schema version this build implements. */
    public const SCHEMA_VERSION = 1;

    /** Terminal reader states. Contract-frozen; do not extend or alias. */
    public const STATES = [
        'trusted',
        'valid_untrusted',
        'changed',
        'unavailable',
        'not_present',
    ];

    /** Local display states. Never terminal, never stored, never in a payload. */
    public const LOCAL_STATES = ['checking', 'signed_unchecked'];

    /** Trust classification of the one claim signer, as the reader shows it. */
    public const SIGNER_TRUST_STATES = ['trusted', 'untrusted', 'unknown'];

    /** API wire statuses, in the order the schema declares them. */
    public const WIRE_STATUSES = [
        'verified_trusted',
        'valid_untrusted',
        'changed',
        'invalid',
        'revoked',
        'no_provenance',
        'unavailable',
    ];

    /**
     * Wire status to contract terminal state.
     *
     * `invalid` and `revoked` collapse onto `changed` because a completed
     * rejection is neither an incomplete check nor an absent record. Copy stays
     * distinguishable through `wire_status`; see detail_copy().
     */
    private const WIRE_STATUS_MAP = [
        'verified_trusted' => 'trusted',
        'valid_untrusted'  => 'valid_untrusted',
        'changed'          => 'changed',
        'invalid'          => 'changed',
        'revoked'          => 'changed',
        'no_provenance'    => 'not_present',
        'unavailable'      => 'unavailable',
    ];

    /**
     * This plugin's own reader-visible REST failure codes, mapped to a verdict.
     *
     * These never reach the verification API: they are local refusals raised
     * before or instead of a check. Each entry fixes the terminal state and
     * whether re-running could plausibly change the answer.
     *
     * `media_delivery_mismatch` is the transformed-rendition rule: the bytes the
     * public URL served are not the signed original, so the claim does not vouch
     * for what the reader is looking at. It fails closed to `changed` and is
     * never retried into a success.
     *
     * @var array<string,array{state:string,retryable:bool}>
     */
    private const ERROR_CODE_MAP = [
        'media_not_signed'           => ['state' => 'not_present', 'retryable' => false],
        'media_delivery_mismatch'    => ['state' => 'changed', 'retryable' => false],
        'provenance_not_current'     => ['state' => 'changed', 'retryable' => false],
        'media_delivery_unavailable' => ['state' => 'unavailable', 'retryable' => true],
        'media_unreadable'           => ['state' => 'unavailable', 'retryable' => false],
        'signing_incomplete'         => ['state' => 'unavailable', 'retryable' => false],
        'full_asset_upload_disabled' => ['state' => 'unavailable', 'retryable' => false],
        'file_too_large'             => ['state' => 'unavailable', 'retryable' => false],
        'verify_media_bad_response'  => ['state' => 'unavailable', 'retryable' => true],
        'rest_invalid_param'         => ['state' => 'unavailable', 'retryable' => false],
        'invalid_post'               => ['state' => 'unavailable', 'retryable' => false],
        'invalid_post_status'        => ['state' => 'unavailable', 'retryable' => false],
        'too_many_requests'          => ['state' => 'unavailable', 'retryable' => true],
    ];

    /**
     * Canonical presentation for one reader state.
     *
     * `glyph` is the reader's ONE status mark. `check` belongs to `trusted` and
     * to nothing else: a check beside "we could not verify who signed it" is the
     * contradiction this table exists to prevent. Unknown states fail closed to
     * `unavailable` rather than rendering a raw state token at a reader.
     *
     * @return array{state:string,shortLabel:string,detailLabel:string,tone:string,glyph:string,trusted:bool,showCheck:bool,legacyClass:string}
     */
    public static function presentation(string $state): array
    {
        $table = self::table();

        return $table[$state] ?? $table['unavailable'];
    }

    /**
     * Pre-existing public CSS class for one state.
     *
     * `verified`, `signed`, `changed_after_signing`, and
     * `verification_unavailable` are published names that themes and customer
     * CSS already target, so they keep working. They are NOT a verdict oracle:
     * several states share one legacy class. `data-encypher-verdict` carries the
     * full enum alongside, and that is what tests and integrations read.
     */
    public static function legacy_class(string $state): string
    {
        return self::presentation($state)['legacyClass'];
    }

    /** Whether a state is one this build may render at all. */
    public static function is_known_state(string $state): bool
    {
        return in_array($state, self::STATES, true) || in_array($state, self::LOCAL_STATES, true);
    }

    /** Whether a state is a completed answer, as opposed to a view. */
    public static function is_terminal_state(string $state): bool
    {
        return in_array($state, self::STATES, true);
    }

    /**
     * Whether a state is a trusted-publisher success.
     *
     * The single predicate every success metric and every piece of
     * trusted-source copy must gate on. Validity alone is never enough.
     */
    public static function is_success_state(string $state): bool
    {
        return 'trusted' === $state;
    }

    /**
     * Read and validate the whole `reader_verdict` object off a payload.
     *
     * Accepts the four envelopes the plugin's own REST layer produces: the
     * verdict flat at the top level, under `data`, under `details` (the raw API
     * payload that normalize_media_verify_response() preserves), or under
     * `metadata`. Those are lookup locations for one object, not four
     * precedences.
     *
     * Returns null for a missing verdict, an unimplemented schema version, or a
     * status outside the wire enum. Null is not a verdict: callers render
     * `unavailable`, which is the truthful thing to say when nothing was read.
     *
     * @param mixed $payload Decoded response body, or null on transport failure.
     * @return array{status:string,wire_status:string,provenance_present:bool,valid:bool,tampered:bool,revoked:bool,reason_code:string,signer_trust:string,trust_policy:string,trust_anchor:string,trust_validation_time:string,retryable:bool}|null
     */
    public static function verdict_from_payload($payload): ?array
    {
        if (! is_array($payload)) {
            return null;
        }

        $verdict = null;
        $envelopes = [
            $payload,
            $payload['data'] ?? null,
            $payload['details'] ?? null,
            $payload['metadata'] ?? null,
        ];
        foreach ($envelopes as $envelope) {
            if (is_array($envelope) && isset($envelope['reader_verdict']) && is_array($envelope['reader_verdict'])) {
                $verdict = $envelope['reader_verdict'];
                break;
            }
        }
        if (null === $verdict) {
            return null;
        }

        // A future version that redefines `status` must not be consumed under
        // version-1 meanings.
        $version = isset($verdict['schema_version']) ? (int) $verdict['schema_version'] : 0;
        if (self::SCHEMA_VERSION !== $version) {
            return null;
        }

        $wire_status = isset($verdict['status']) ? (string) $verdict['status'] : '';
        if (! isset(self::WIRE_STATUS_MAP[$wire_status])) {
            return null;
        }

        $trust = isset($verdict['signer_trust']) ? (string) $verdict['signer_trust'] : 'unknown';

        return [
            'status'                => self::WIRE_STATUS_MAP[$wire_status],
            'wire_status'           => $wire_status,
            'provenance_present'    => ! empty($verdict['provenance_present']),
            'valid'                 => ! empty($verdict['valid']),
            'tampered'              => ! empty($verdict['tampered']),
            'revoked'               => ! empty($verdict['revoked']),
            'reason_code'           => isset($verdict['reason_code']) ? (string) $verdict['reason_code'] : '',
            'signer_trust'          => in_array($trust, self::SIGNER_TRUST_STATES, true) ? $trust : 'unknown',
            'trust_policy'          => isset($verdict['trust_policy']) ? (string) $verdict['trust_policy'] : '',
            'trust_anchor'          => isset($verdict['trust_anchor']) ? (string) $verdict['trust_anchor'] : '',
            'trust_validation_time' => isset($verdict['trust_validation_time']) ? (string) $verdict['trust_validation_time'] : '',
            'retryable'             => ! empty($verdict['retryable']),
        ];
    }

    /**
     * The terminal state for a payload, falling closed to `unavailable`.
     *
     * @param mixed $payload Decoded response body, or null on transport failure.
     */
    public static function state_from_payload($payload): string
    {
        $verdict = self::verdict_from_payload($payload);

        return null === $verdict ? 'unavailable' : $verdict['status'];
    }

    /**
     * Build a verdict from one of this plugin's own reader-visible REST errors.
     *
     * A local refusal is a fact about our own request handling, not a guess at a
     * verification result, so mapping it here does not reintroduce a second
     * precedence. An unrecognized code with a 5xx or dead transport is treated
     * as a retryable outage; an unrecognized 4xx is a non-retryable outage,
     * because repeating a request the server already rejected returns the same
     * answer.
     *
     * @return array{status:string,wire_status:string,provenance_present:bool,valid:bool,tampered:bool,revoked:bool,reason_code:string,signer_trust:string,trust_policy:string,trust_anchor:string,trust_validation_time:string,retryable:bool}
     */
    public static function verdict_from_error(string $error_code, int $http_status = 0): array
    {
        $mapped = self::ERROR_CODE_MAP[$error_code] ?? null;
        if (null === $mapped) {
            $mapped = [
                'state'     => 'unavailable',
                'retryable' => 0 === $http_status || $http_status >= 500,
            ];
        }

        return [
            'status'                => $mapped['state'],
            'wire_status'           => '',
            'provenance_present'    => false,
            'valid'                 => false,
            'tampered'              => false,
            'revoked'               => false,
            'reason_code'           => $error_code,
            'signer_trust'          => 'unknown',
            'trust_policy'          => '',
            'trust_anchor'          => '',
            'trust_validation_time' => '',
            'retryable'             => $mapped['retryable'],
        ];
    }

    /**
     * Whether the reader may be offered a Retry control.
     *
     * Retry is offered ONLY for `unavailable`, and only when the failure is
     * actually retryable. A settled answer is not retried: a Retry button beside
     * "changed after signing" or "no provenance record" tells the reader the
     * answer is provisional, and re-running returns the same verdict. A null
     * verdict is retryable only when the transport itself died; a response that
     * arrived and was unreadable returns the same unreadable response next time.
     *
     * @param array<string,mixed>|null $verdict Output of verdict_from_payload().
     */
    public static function retry_allowed(?array $verdict, bool $transport_failed): bool
    {
        if (null === $verdict) {
            return $transport_failed;
        }
        if ('unavailable' !== ($verdict['status'] ?? '')) {
            return false;
        }

        return ! empty($verdict['retryable']);
    }

    /**
     * The reader sentence for a settled verdict.
     *
     * The state fixes the headline; the reason refines the sentence so it never
     * contradicts the evidence. Two invariants are enforced here:
     *
     * 1. Only `not_present` may state that no provenance record exists. Saying
     *    "no verification claim has been made" on a delivery outage told readers
     *    the publisher had signed nothing while the publisher was looking at the
     *    manifest in the admin screen. Every `unavailable` sentence describes the
     *    CHECK failing, and the delivery-outage sentence affirms the record.
     * 2. `changed` distinguishes an edit from a failed validation, a revoked
     *    credential, and a transformed rendition. All four are non-trusted, and
     *    none of them is the others.
     *
     * @param array<string,mixed>|null $verdict Output of verdict_from_payload()
     *                                          or verdict_from_error().
     */
    public static function detail_copy(?array $verdict): string
    {
        $state = null === $verdict ? 'unavailable' : (string) ($verdict['status'] ?? 'unavailable');
        $reasons = self::reason_copy();
        $reason_key = null === $verdict ? '' : strtolower((string) ($verdict['reason_code'] ?? ''));
        $wire = null === $verdict ? '' : (string) ($verdict['wire_status'] ?? '');

        if ('' !== $reason_key && isset($reasons[$reason_key])) {
            return $reasons[$reason_key];
        }
        if ('' !== $wire && isset($reasons['wire:' . $wire])) {
            return $reasons['wire:' . $wire];
        }

        return self::presentation($state)['detailLabel'];
    }

    /**
     * Reason-specific reader sentences, keyed by lowercased reason code and by
     * `wire:<status>` for the wire statuses that need their own words.
     *
     * Localized to the browser alongside the state table so PHP-rendered and
     * JS-rendered dialogs cannot drift.
     *
     * @return array<string,string>
     */
    public static function reason_copy(): array
    {
        return [
            // Delivery outage. The record exists; the fetch did not. Never
            // phrase this as an absent claim.
            'media_delivery_unavailable' => __('This file carries a provenance record. We could not fetch the published file to check it right now.', 'encypher-provenance'),
            // Transformed rendition: fail closed. A resize, crop, re-encode, or
            // format negotiation breaks the hard binding, so the claim cannot
            // vouch for the delivered bytes.
            'media_delivery_mismatch' => __('The published file does not match the signed original, so its provenance record does not cover it. Resized or re-encoded copies cannot be verified.', 'encypher-provenance'),
            'provenance_not_current' => __('This content was edited after it was signed, so the signed version no longer describes it.', 'encypher-provenance'),
            'media_not_signed' => __('This file carries no provenance record.', 'encypher-provenance'),
            'media_unreadable' => __('This file could not be read for checking.', 'encypher-provenance'),
            'signing_incomplete' => __('The last signing run did not protect every item in this content, so it cannot be checked yet. Its provenance record is incomplete, not absent.', 'encypher-provenance'),
            'full_asset_upload_disabled' => __('Media verification is switched off for this site.', 'encypher-provenance'),
            'file_too_large' => __('This file is larger than the verification size limit, so it could not be checked.', 'encypher-provenance'),
            'too_many_requests' => __('Too many checks have run just now. Try again in a moment.', 'encypher-provenance'),
            'verify_media_bad_response' => __('The verification service returned something we could not read.', 'encypher-provenance'),
            // Completed rejections that are not edits.
            'wire:invalid' => __('This content carries a provenance record that failed validation.', 'encypher-provenance'),
            'wire:revoked' => __('The credential that signed this content has been revoked.', 'encypher-provenance'),
        ];
    }

    /**
     * Whether a post carries a text provenance record at all.
     *
     * Deliberately NOT a currentness check. Currentness answers "may we serve
     * the stored signed bytes as this post's content" and gates marker
     * injection. It must not gate whether a reader is offered a provenance
     * control: a post edited after signing still carries a record, and hiding
     * its badge suppressed the one state a reader most needs, `changed`, before
     * any check could report it. The live check decides every terminal state;
     * this predicate only decides whether there is anything to check.
     */
    public static function post_carries_text_provenance(int $post_id): bool
    {
        if ($post_id <= 0) {
            return false;
        }
        // A signing run still in flight has committed nothing yet, so there is
        // no record to offer a reader a control for.
        if ('' !== (string) get_post_meta($post_id, '_encypher_provenance_generation_pending', true)) {
            return false;
        }
        if (! get_post_meta($post_id, '_encypher_marked', true)) {
            return false;
        }
        $signed_text = get_post_meta($post_id, '_encypher_signed_text', true);

        return is_string($signed_text) && '' !== $signed_text;
    }

    /**
     * The state a server-rendered control may claim BEFORE any live check.
     *
     * Server-side rendering may never claim `trusted` or `valid_untrusted`.
     * Trust is the outcome of a live check against a trust anchor, and a stored
     * `verified` status with a recent timestamp is a memory of a past check, not
     * a statement about the bytes being served now. Reading one as the other is
     * how a stale timestamp became a green check.
     *
     * The three reachable pre-check states are all conservative:
     *   unavailable       the last signing run left the record incomplete
     *   changed           the served bytes are not the committed signed snapshot
     *   signed_unchecked  a record exists and nothing local contradicts it
     *
     * @param bool $carries_record       post_carries_text_provenance().
     * @param bool $snapshot_is_current   Whether the served bytes are the
     *                                    committed signed snapshot.
     * @param bool $signing_incomplete    Whether the last signing run failed to
     *                                    protect every requested item.
     * @return string|null Null when no control may render at all.
     */
    public static function pre_check_state(
        bool $carries_record,
        bool $snapshot_is_current,
        bool $signing_incomplete
    ): ?string {
        if (! $carries_record) {
            return null;
        }
        if ($signing_incomplete) {
            return 'unavailable';
        }

        return $snapshot_is_current ? 'signed_unchecked' : 'changed';
    }

    /**
     * The presentation payload localized to reader JavaScript, so PHP and the
     * browser render the same words and the same glyph from the same table.
     *
     * @return array<string,array<string,mixed>>
     */
    public static function localized_table(): array
    {
        return self::table();
    }

    /**
     * Translated labels for signer identity facts.
     *
     * A trusted anchor and a claimed name are different facts. `trusted` is the
     * only state that may say "Published by"; the others say "Claimed
     * publisher", because an unanchored certificate subject is a claim.
     *
     * @return array<string,mixed>
     */
    public static function localized_identity_table(): array
    {
        return [
            'heading'         => __('Who signed this', 'encypher-provenance'),
            'undisclosedName' => __('Identity not disclosed', 'encypher-provenance'),
            'anchorLabel'     => __('Trust anchor', 'encypher-provenance'),
            'policyLabel'     => __('Checked against', 'encypher-provenance'),
            'reasonLabel'     => __('Reason', 'encypher-provenance'),
            'signerTrust'     => [
                'trusted' => [
                    'nameLabel'  => __('Published by', 'encypher-provenance'),
                    'trustLabel' => __('Publisher trust', 'encypher-provenance'),
                    'trustValue' => __('Trusted', 'encypher-provenance'),
                    'tone'       => 'positive',
                ],
                'untrusted' => [
                    'nameLabel'  => __('Claimed publisher', 'encypher-provenance'),
                    'trustLabel' => __('Publisher trust', 'encypher-provenance'),
                    'trustValue' => __('Not trusted', 'encypher-provenance'),
                    'tone'       => 'caution',
                ],
                'unknown' => [
                    'nameLabel'  => __('Claimed publisher', 'encypher-provenance'),
                    'trustLabel' => __('Publisher trust', 'encypher-provenance'),
                    'trustValue' => __('Could not be verified', 'encypher-provenance'),
                    'tone'       => 'neutral',
                ],
            ],
        ];
    }

    /**
     * @return array<string,array{state:string,shortLabel:string,detailLabel:string,tone:string,glyph:string,trusted:bool,showCheck:bool,legacyClass:string}>
     */
    private static function table(): array
    {
        return [
            'trusted' => [
                'state'       => 'trusted',
                'shortLabel'  => __('Verified provenance', 'encypher-provenance'),
                'detailLabel' => __('This content matches the version signed by a trusted publisher.', 'encypher-provenance'),
                'tone'        => 'positive',
                'glyph'       => 'check',
                'trusted'     => true,
                'showCheck'   => true,
                'legacyClass' => 'verified',
            ],
            'valid_untrusted' => [
                'state'       => 'valid_untrusted',
                'shortLabel'  => __('Valid provenance, publisher unverified', 'encypher-provenance'),
                'detailLabel' => __('This content matches its signed version, but we could not verify who signed it.', 'encypher-provenance'),
                'tone'        => 'caution',
                'glyph'       => 'caution',
                'trusted'     => false,
                'showCheck'   => false,
                // Deliberately NOT the `verified` class: existing theme CSS
                // paints that green, and green beside "publisher unverified" is
                // the contradiction this release removes.
                'legacyClass' => 'signed',
            ],
            'changed' => [
                'state'       => 'changed',
                'shortLabel'  => __('Changed after signing', 'encypher-provenance'),
                'detailLabel' => __('This content no longer matches the version that was signed.', 'encypher-provenance'),
                'tone'        => 'warning',
                'glyph'       => 'changed',
                'trusted'     => false,
                'showCheck'   => false,
                'legacyClass' => 'changed_after_signing',
            ],
            'unavailable' => [
                'state'       => 'unavailable',
                'shortLabel'  => __('Verification unavailable', 'encypher-provenance'),
                'detailLabel' => __('We could not check this content right now.', 'encypher-provenance'),
                'tone'        => 'neutral',
                'glyph'       => 'unknown',
                'trusted'     => false,
                'showCheck'   => false,
                'legacyClass' => 'verification_unavailable',
            ],
            'not_present' => [
                'state'       => 'not_present',
                'shortLabel'  => __('No provenance record', 'encypher-provenance'),
                'detailLabel' => __('This content carries no provenance record.', 'encypher-provenance'),
                'tone'        => 'neutral',
                'glyph'       => 'absent',
                'trusted'     => false,
                'showCheck'   => false,
                'legacyClass' => 'verification_unavailable',
            ],
            'checking' => [
                'state'       => 'checking',
                'shortLabel'  => __('Checking provenance', 'encypher-provenance'),
                'detailLabel' => __('Checking this content against its signed version.', 'encypher-provenance'),
                'tone'        => 'neutral',
                'glyph'       => 'checking',
                'trusted'     => false,
                'showCheck'   => false,
                'legacyClass' => 'signed',
            ],
            'signed_unchecked' => [
                'state'       => 'signed_unchecked',
                'shortLabel'  => __('Signed provenance', 'encypher-provenance'),
                'detailLabel' => __('This content carries a provenance record. Select to check it now.', 'encypher-provenance'),
                'tone'        => 'info',
                'glyph'       => 'record',
                'trusted'     => false,
                'showCheck'   => false,
                'legacyClass' => 'signed',
            ],
        ];
    }
}
