/**
 * Reader-facing provenance surface: the verification dialog, the post badge,
 * and the optional media marks.
 *
 * ONE canonical verdict. The dialog reads `reader_verdict` off a completed
 * response and nothing else. It does not inspect `valid`, `tampered`,
 * `trust_level`, `ca_backed`, or `trusted_signer`, and it does not synthesize a
 * trust judgement from a signer name: a client that derives its own verdict is
 * a second precedence, and the badge, the dialog, and the admin screens
 * disagreeing about the same bytes is the defect this replaces.
 *
 * ONE request per reader action. A settled answer is cached per target and
 * re-opening the dialog re-renders it without a second call. There is no silent
 * automatic retry: a retry is a reader's decision, offered only when the failure
 * is actually retryable.
 */
(function () {
    'use strict';

    const config = window.EncypherProvenanceFrontend || {};
    const modal = document.getElementById('encypher-c2pa-modal');
    if (!modal) {
        return;
    }

    const dialog = modal.querySelector('.encypher-c2pa-modal-content');
    const closeButton = modal.querySelector('.encypher-c2pa-modal-close');
    const overlay = modal.querySelector('.encypher-c2pa-modal-overlay');
    const loading = modal.querySelector('.encypher-c2pa-modal-loading');
    const result = modal.querySelector('.encypher-c2pa-modal-result');
    let trigger = null;

    // -----------------------------------------------------------------------
    // Canonical reader verdict
    //
    // PHP localizes its own translated tables onto `config.verdicts` and
    // `config.reasons`, so the words the browser renders are the words the
    // server-rendered control used. The tables below are the fallback for a page
    // cached by an older build; they never override a localized table.
    // -----------------------------------------------------------------------

    const SCHEMA_VERSION = 1;

    /** Terminal reader states. Contract-frozen. */
    const STATES = ['trusted', 'valid_untrusted', 'changed', 'unavailable', 'not_present'];

    /** Local display states: a view, never an answer. */
    const LOCAL_STATES = ['checking', 'signed_unchecked'];

    const TRUST_VALUES = ['trusted', 'untrusted', 'unknown'];

    /**
     * API wire status to contract terminal state. `invalid` and `revoked` are
     * completed rejections, so they are neither `unavailable` (no check
     * completed) nor `not_present` (no record exists); they join `changed`, and
     * the raw wire status is kept so the sentence stays specific.
     */
    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. */
    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 }
    };

    const FALLBACK_VERDICTS = {
        trusted: {
            state: 'trusted',
            shortLabel: 'Verified provenance',
            detailLabel: 'This content matches the version signed by a trusted publisher.',
            tone: 'positive',
            glyph: 'check',
            trusted: true,
            showCheck: true,
            legacyClass: 'verified'
        },
        valid_untrusted: {
            state: 'valid_untrusted',
            shortLabel: 'Valid provenance, publisher unverified',
            detailLabel: 'This content matches its signed version, but we could not verify who signed it.',
            tone: 'caution',
            glyph: 'caution',
            trusted: false,
            showCheck: false,
            legacyClass: 'signed'
        },
        changed: {
            state: 'changed',
            shortLabel: 'Changed after signing',
            detailLabel: 'This content no longer matches the version that was signed.',
            tone: 'warning',
            glyph: 'changed',
            trusted: false,
            showCheck: false,
            legacyClass: 'changed_after_signing'
        },
        unavailable: {
            state: 'unavailable',
            shortLabel: 'Verification unavailable',
            detailLabel: 'We could not check this content right now.',
            tone: 'neutral',
            glyph: 'unknown',
            trusted: false,
            showCheck: false,
            legacyClass: 'verification_unavailable'
        },
        not_present: {
            state: 'not_present',
            shortLabel: 'No provenance record',
            detailLabel: 'This content carries no provenance record.',
            tone: 'neutral',
            glyph: 'absent',
            trusted: false,
            showCheck: false,
            legacyClass: 'verification_unavailable'
        },
        checking: {
            state: 'checking',
            shortLabel: 'Checking provenance',
            detailLabel: 'Checking this content against its signed version.',
            tone: 'neutral',
            glyph: 'checking',
            trusted: false,
            showCheck: false,
            legacyClass: 'signed'
        },
        signed_unchecked: {
            state: 'signed_unchecked',
            shortLabel: 'Signed provenance',
            detailLabel: 'This content carries a provenance record. Select to check it now.',
            tone: 'info',
            glyph: 'record',
            trusted: false,
            showCheck: false,
            legacyClass: 'signed'
        }
    };

    const FALLBACK_REASONS = {
        media_delivery_unavailable: 'This file carries a provenance record. We could not fetch the published file to check it right now.',
        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.',
        provenance_not_current: 'This content was edited after it was signed, so the signed version no longer describes it.',
        media_not_signed: 'This file carries no provenance record.',
        media_unreadable: 'This file could not be read for checking.',
        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.',
        full_asset_upload_disabled: 'Media verification is switched off for this site.',
        file_too_large: 'This file is larger than the verification size limit, so it could not be checked.',
        too_many_requests: 'Too many checks have run just now. Try again in a moment.',
        verify_media_bad_response: 'The verification service returned something we could not read.',
        'wire:invalid': 'This content carries a provenance record that failed validation.',
        'wire:revoked': 'The credential that signed this content has been revoked.'
    };

    const FALLBACK_LABELS = {
        retry: 'Retry verification',
        viewProvenance: 'View provenance',
        toolUsed: 'Tool used',
        signedAt: 'Signed',
        standard: 'Technical standard',
        documentId: 'C2PA document',
        cached: 'Check',
        cachedValue: 'Cached verification result',
        rightsHeading: 'Rights and permissions',
        chainHeading: 'Edit history',
        license: 'License',
        usageTerms: 'Usage terms',
        licenseTerms: 'License terms',
        copyrightHolder: 'Copyright holder',
        aiTraining: 'AI training',
        aiInference: 'AI inference',
        dataMining: 'Data mining',
        allowed: 'Allowed',
        notAllowed: 'Not allowed',
        sourceType: 'Source type',
        ingredients: 'Built from'
    };

    const FALLBACK_IDENTITY = {
        heading: 'Who signed this',
        undisclosedName: 'Identity not disclosed',
        anchorLabel: 'Trust anchor',
        policyLabel: 'Checked against',
        reasonLabel: 'Reason',
        signerTrust: {
            trusted: { nameLabel: 'Published by', trustLabel: 'Publisher trust', trustValue: 'Trusted', tone: 'positive' },
            untrusted: { nameLabel: 'Claimed publisher', trustLabel: 'Publisher trust', trustValue: 'Not trusted', tone: 'caution' },
            unknown: { nameLabel: 'Claimed publisher', trustLabel: 'Publisher trust', trustValue: 'Could not be verified', tone: 'neutral' }
        }
    };

    function verdictTable() {
        return (config.verdicts && typeof config.verdicts === 'object') ? config.verdicts : FALLBACK_VERDICTS;
    }

    function reasonTable() {
        return (config.reasons && typeof config.reasons === 'object') ? config.reasons : FALLBACK_REASONS;
    }

    function label(key) {
        const table = (config.labels && typeof config.labels === 'object') ? config.labels : {};
        return table[key] || FALLBACK_LABELS[key] || '';
    }

    function identityTable() {
        return (config.identity && typeof config.identity === 'object') ? config.identity : FALLBACK_IDENTITY;
    }

    /**
     * Canonical presentation for one reader state. An unrecognized state fails
     * closed to `unavailable` rather than rendering a raw state token.
     */
    function presentation(state) {
        const table = verdictTable();
        const entry = table[state] || FALLBACK_VERDICTS[state] || table.unavailable || FALLBACK_VERDICTS.unavailable;
        return {
            state: entry.state || state,
            shortLabel: entry.shortLabel,
            detailLabel: entry.detailLabel,
            tone: entry.tone || 'neutral',
            glyph: entry.glyph || 'unknown',
            trusted: entry.trusted === true,
            showCheck: entry.showCheck === true,
            legacyClass: entry.legacyClass || 'verification_unavailable'
        };
    }

    function isKnownState(state) {
        return STATES.indexOf(state) !== -1 || LOCAL_STATES.indexOf(state) !== -1;
    }

    /**
     * Read and validate `reader_verdict` off a decoded response body.
     *
     * Accepts the four envelopes this plugin's REST layer produces: flat, under
     * `data`, under `details` (the raw API payload the media normalizer keeps),
     * 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.
     */
    function verdictFromPayload(payload) {
        if (!payload || typeof payload !== 'object') {
            return null;
        }

        const envelopes = [payload, payload.data, payload.details, payload.metadata];
        let verdict = null;
        for (const envelope of envelopes) {
            if (envelope && typeof envelope === 'object' &&
                envelope.reader_verdict && typeof envelope.reader_verdict === 'object') {
                verdict = envelope.reader_verdict;
                break;
            }
        }
        if (!verdict) {
            return null;
        }
        if (Number(verdict.schema_version) !== SCHEMA_VERSION) {
            return null;
        }
        const wireStatus = String(verdict.status || '');
        if (!Object.prototype.hasOwnProperty.call(WIRE_STATUS_MAP, wireStatus)) {
            return null;
        }
        const trust = String(verdict.signer_trust || 'unknown');

        return {
            status: WIRE_STATUS_MAP[wireStatus],
            wire_status: wireStatus,
            provenance_present: verdict.provenance_present === true,
            valid: verdict.valid === true,
            tampered: verdict.tampered === true,
            revoked: verdict.revoked === true,
            reason_code: verdict.reason_code ? String(verdict.reason_code) : '',
            signer_trust: TRUST_VALUES.indexOf(trust) === -1 ? 'unknown' : trust,
            trust_policy: verdict.trust_policy ? String(verdict.trust_policy) : '',
            trust_anchor: verdict.trust_anchor ? String(verdict.trust_anchor) : '',
            trust_validation_time: verdict.trust_validation_time ? String(verdict.trust_validation_time) : '',
            retryable: verdict.retryable === true
        };
    }

    /**
     * Build a verdict from one of this plugin's own REST error envelopes. A
     * local refusal is a fact about our request handling, not a guess at a
     * verification result.
     */
    function verdictFromError(errorCode, httpStatus) {
        const code = String(errorCode || '');
        const mapped = ERROR_CODE_MAP[code] || {
            state: 'unavailable',
            retryable: httpStatus === 0 || httpStatus >= 500
        };
        return {
            status: mapped.state,
            wire_status: '',
            provenance_present: false,
            valid: false,
            tampered: false,
            revoked: false,
            reason_code: code,
            signer_trust: 'unknown',
            trust_policy: '',
            trust_anchor: '',
            trust_validation_time: '',
            retryable: mapped.retryable === true
        };
    }

    /**
     * The reader sentence for a settled verdict. Only `not_present` ever states
     * that no provenance record exists: telling a reader "no verification claim
     * has been made" during a delivery outage contradicted the manifest the
     * publisher could see in the admin screen.
     */
    function detailCopy(verdict) {
        const state = verdict ? String(verdict.status || 'unavailable') : 'unavailable';
        const reasons = reasonTable();
        const reasonKey = verdict ? String(verdict.reason_code || '').toLowerCase() : '';
        const wire = verdict ? String(verdict.wire_status || '') : '';

        if (reasonKey && reasons[reasonKey]) {
            return reasons[reasonKey];
        }
        if (wire && reasons['wire:' + wire]) {
            return reasons['wire:' + wire];
        }
        return presentation(state).detailLabel;
    }

    /**
     * Retry is offered ONLY for `unavailable`, and only when the failure is
     * retryable. A Retry button beside "changed after signing" or "no provenance
     * record" tells the reader a settled answer is provisional, and re-running
     * returns the same verdict.
     */
    function retryAllowed(verdict, transportFailed) {
        if (!verdict) {
            return transportFailed === true;
        }
        if (verdict.status !== 'unavailable') {
            return false;
        }
        return verdict.retryable === true;
    }

    // -----------------------------------------------------------------------
    // Dialog rendering
    // -----------------------------------------------------------------------

    function focusableElements() {
        return Array.from(dialog.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'));
    }

    function setResultState(state, title, description) {
        const view = presentation(state);
        loading.hidden = true;
        result.hidden = false;
        result.replaceChildren();
        result.setAttribute('data-encypher-verdict', view.state);
        result.setAttribute('data-encypher-tone', view.tone);
        if (view.trusted) {
            result.setAttribute('data-encypher-trusted', '1');
        } else {
            result.removeAttribute('data-encypher-trusted');
        }

        const status = document.createElement('div');
        status.className = 'encypher-verification-state encypher-verification-state--' + view.state
            + ' encypher-verification-state--tone-' + view.tone;
        status.setAttribute('data-encypher-verdict', view.state);
        const headingRow = document.createElement('div');
        headingRow.className = 'encypher-verification-state__heading';
        const mark = document.createElement('img');
        mark.className = 'encypher-verification-state__mark';
        mark.setAttribute('src', String(config.markUrl || ''));
        mark.setAttribute('alt', '');
        mark.setAttribute('aria-hidden', 'true');
        // One status glyph per verdict, drawn by CSS. The check variant is
        // reachable only from `trusted`.
        const glyph = document.createElement('span');
        glyph.className = 'encypher-verification-state__glyph encypher-verification-state__glyph--' + view.glyph;
        glyph.setAttribute('data-encypher-glyph', view.glyph);
        glyph.setAttribute('aria-hidden', 'true');
        const heading = document.createElement('h3');
        heading.textContent = title;
        headingRow.append(mark, glyph, heading);
        const copy = document.createElement('p');
        copy.className = 'encypher-verification-state__detail';
        copy.textContent = description;
        status.append(headingRow, copy);
        result.append(status);
    }

    function factList(name) {
        let list = result.querySelector('.encypher-verification-facts--' + name);
        if (!list) {
            list = document.createElement('dl');
            list.className = 'encypher-verification-facts encypher-verification-facts--' + name;
            list.setAttribute('data-encypher-facts', name);
            result.append(list);
        }
        return list;
    }

    function appendFact(labelText, value, name) {
        if (!value || !labelText) {
            return;
        }
        const list = factList(name || 'primary');
        const term = document.createElement('dt');
        term.textContent = labelText;
        const detail = document.createElement('dd');
        detail.textContent = value;
        list.append(term, detail);
    }

    function appendSectionHeading(text, name) {
        if (!text) {
            return;
        }
        const heading = document.createElement('h4');
        heading.className = 'encypher-verification-section';
        heading.setAttribute('data-encypher-section', name);
        heading.textContent = text;
        result.append(heading);
    }

    function appendProvenanceLink(url) {
        if (!url) {
            return;
        }
        const link = document.createElement('a');
        link.className = 'encypher-verification-link';
        link.textContent = label('viewProvenance');
        link.setAttribute('href', String(url));
        link.setAttribute('target', '_blank');
        link.setAttribute('rel', 'noopener noreferrer');
        result.append(link);
    }

    function appendRetryButton(postId, attachmentId) {
        const button = document.createElement('button');
        button.className = 'encypher-verification-retry';
        button.textContent = label('retry');
        button.setAttribute('type', 'button');
        button.addEventListener('click', function (event) {
            event.preventDefault();
            beginVerification(postId, attachmentId, true);
        });
        result.append(button);
    }

    /**
     * Repaint every control bound to this target so the badge a reader looks at
     * after closing the dialog says what the dialog said.
     */
    function updateControls(postId, attachmentId, state) {
        const view = presentation(state);
        const selector = attachmentId
            ? '.encypher-c2pa-media-badge[data-attachment-id="' + attachmentId + '"]'
            : '.encypher-c2pa-badge[data-post-id="' + postId + '"]';
        const controls = document.querySelectorAll ? document.querySelectorAll(selector) : [];
        Array.prototype.forEach.call(controls, function (control) {
            control.classList.remove(
                'encypher-c2pa-badge-signed',
                'encypher-c2pa-badge-verified',
                'encypher-c2pa-badge-changed_after_signing',
                'encypher-c2pa-badge-verification_unavailable'
            );
            control.classList.add('encypher-c2pa-badge-' + view.legacyClass);
            control.setAttribute('data-encypher-verdict', view.state);
            control.setAttribute('data-encypher-tone', view.tone);
            if (view.trusted) {
                control.setAttribute('data-encypher-trusted', '1');
            } else {
                control.removeAttribute('data-encypher-trusted');
            }
            control.setAttribute('aria-label', view.shortLabel);
            control.setAttribute('title', view.shortLabel);
            const labelNode = control.querySelector('.encypher-c2pa-badge__label');
            if (labelNode) {
                labelNode.textContent = view.shortLabel;
            }
            const glyphNode = control.querySelector('.encypher-c2pa-badge__glyph');
            if (glyphNode) {
                glyphNode.setAttribute('data-encypher-glyph', view.glyph);
            }
            const check = control.querySelector('.encypher-c2pa-badge__check');
            if (check) {
                check.hidden = !view.showCheck;
            }
        });
    }

    function payloadObject(payload, key) {
        const value = payload && typeof payload === 'object' ? payload[key] : null;
        return value && typeof value === 'object' ? value : {};
    }

    /**
     * Signer identity facts.
     *
     * The name label depends on the verdict's own `signer_trust`, never on the
     * presence of a name: an unanchored certificate subject is a claim, and
     * "Published by" beside an unverified credential is the contradiction this
     * removes.
     */
    function appendSignerFacts(verdict, payload) {
        const identity = identityTable();
        const trustStates = identity.signerTrust || FALLBACK_IDENTITY.signerTrust;
        const trust = trustStates[verdict.signer_trust] || trustStates.unknown;
        const summary = payloadObject(payload, 'summary');
        const meta = payloadObject(payload, 'metadata');
        const name = payload.signing_identity || payload.signer_name || payload.signer ||
            summary.signer || meta.signer_name || '';

        appendFact(trust.nameLabel, name || identity.undisclosedName, 'signer');
        appendFact(trust.trustLabel, trust.trustValue, 'signer');
        if (verdict.trust_anchor) {
            appendFact(identity.anchorLabel, verdict.trust_anchor, 'signer');
        }
        if (verdict.trust_policy) {
            appendFact(identity.policyLabel, verdict.trust_policy, 'signer');
        }
    }

    function activeManifest(payload) {
        const details = payloadObject(payload, 'details');
        const store = payloadObject(details, 'c2pa_manifest');
        const manifests = payloadObject(store, 'manifests');
        const active = String(store.active_manifest || '');
        return active && manifests[active] && typeof manifests[active] === 'object' ? manifests[active] : {};
    }

    /**
     * Rights the publisher actually selected, shown only for a verdict whose
     * manifest validated. Rights read out of an unvalidated manifest are an
     * unproven claim about how the work may be used, which is worse than silence.
     */
    function appendRightsFacts(payload) {
        const rights = payloadObject(payload, 'rights');
        const embedded = payloadObject(rights, 'embedded');
        const embeddedRights = payloadObject(embedded, 'rights');
        const display = payloadObject(embeddedRights, 'display');
        const training = payloadObject(embedded, 'training_mining').use;
        const use = training && typeof training === 'object' ? training : {};

        const license = rights.license_type || embeddedRights.license_type || '';
        const usage = rights.usage_terms || display.usage_terms || embeddedRights.usage_terms || '';
        const licenseUrl = rights.license_url || display.license_url || embeddedRights.license_url || '';
        const holder = rights.copyright_holder || display.copyright_holder || embeddedRights.copyright_holder || '';

        const permissions = [
            ['ai_training', 'aiTraining'],
            ['ai_inference', 'aiInference'],
            ['data_mining', 'dataMining']
        ].filter(function (pair) {
            return Object.prototype.hasOwnProperty.call(use, pair[0]);
        });

        if (!license && !usage && !licenseUrl && !holder && !permissions.length) {
            return;
        }

        appendSectionHeading(label('rightsHeading'), 'rights');
        appendFact(label('license'), license, 'rights');
        appendFact(label('usageTerms'), usage, 'rights');
        if (!usage) {
            appendFact(label('licenseTerms'), licenseUrl, 'rights');
        }
        appendFact(label('copyrightHolder'), holder, 'rights');
        permissions.forEach(function (pair) {
            appendFact(label(pair[1]), use[pair[0]] ? label('allowed') : label('notAllowed'), 'rights');
        });
    }

    /**
     * Edit-chain facts: what was done to this asset and what it was built from.
     * Actions and ingredients come from the validated active manifest, so the
     * chain is only ever shown for a verdict that validated.
     */
    function appendChainFacts(payload) {
        const summary = payloadObject(payload, 'summary');
        const manifest = activeManifest(payload);
        const actions = Array.isArray(summary.actions) ? summary.actions : [];
        const ingredients = Array.isArray(manifest.ingredients) ? manifest.ingredients : [];
        const sourceType = summary.source_type || '';

        const ingredientNames = ingredients.map(function (entry) {
            if (!entry || typeof entry !== 'object') {
                return '';
            }
            const title = String(entry.title || '');
            const relationship = String(entry.relationship || '');
            return relationship && title ? title + ' (' + relationship + ')' : title;
        }).filter(Boolean);

        if (!actions.length && !ingredientNames.length && !sourceType) {
            return;
        }

        appendSectionHeading(label('chainHeading'), 'chain');
        appendFact(label('sourceType'), sourceType, 'chain');
        if (actions.length) {
            appendFact(label('chainHeading'), actions.join(', '), 'chain');
        }
        if (ingredientNames.length) {
            appendFact(label('ingredients'), ingredientNames.join(', '), 'chain');
        }
    }

    /**
     * Render one settled verdict.
     *
     * `verdict` is the ONLY input to the headline, the glyph, the trust wording,
     * and whether Retry exists. `payload` supplies descriptive facts and can
     * never change the verdict.
     */
    function renderVerification(verdict, payload, postId, attachmentId, transportFailed) {
        const state = verdict ? verdict.status : 'unavailable';
        const view = presentation(state);
        setResultState(state, view.shortLabel, detailCopy(verdict));
        updateControls(postId, attachmentId, state);

        const data = payload && typeof payload === 'object' ? payload : {};
        const summary = payloadObject(data, 'summary');
        const meta = payloadObject(data, 'metadata');

        if (verdict && (state === 'trusted' || state === 'valid_untrusted')) {
            appendSignerFacts(verdict, data);

            const toolIdentity = data.tool_identity || summary.software_agent || summary.claim_generator ||
                meta.tool_identity || meta.software_agent || meta.claim_generator || config.toolIdentity || '';
            appendFact(label('toolUsed'), toolIdentity, 'primary');

            const signedAt = data.signed_at || summary.signed_at || data.verified_at || '';
            appendFact(label('signedAt'), signedAt ? new Date(signedAt).toLocaleString() : '', 'primary');

            const specVersion = data.spec_version || summary.spec_version || meta.spec_version || '';
            appendFact(label('standard'), specVersion ? 'C2PA ' + specVersion : 'C2PA', 'primary');
            appendFact(label('documentId'), meta.document_id || data.instance_id || '', 'primary');
            if (data.cached) {
                appendFact(label('cached'), label('cachedValue'), 'primary');
            }

            appendRightsFacts(data);
            appendChainFacts(data);
            appendProvenanceLink(meta.manifest_url);
        } else if (verdict && verdict.wire_status && verdict.reason_code) {
            // Only a verifier's own machine code is worth showing: it is what a
            // publisher quotes in a support ticket. Our local refusal codes are
            // already stated in full by the sentence above, and repeating one as
            // a snake_case token tells a reader nothing.
            appendFact(identityTable().reasonLabel, verdict.reason_code, 'primary');
        }

        if (retryAllowed(verdict, transportFailed)) {
            appendRetryButton(postId, attachmentId);
        }
    }

    // -----------------------------------------------------------------------
    // Request dedup
    //
    // One reader action is one request. A settled outcome is remembered per
    // target so re-opening the dialog repaints it without calling again, and
    // concurrent opens share the single in-flight promise. Only an explicit
    // Retry clears the memory.
    // -----------------------------------------------------------------------

    const inFlight = new Map();
    const settled = new Map();
    let requestCount = 0;
    let openToken = 0;

    function targetKey(postId, attachmentId) {
        return String(Number(postId) || 0) + ':' + String(Number(attachmentId) || 0);
    }

    function performFetch(postId, attachmentId) {
        const endpoint = attachmentId ? 'verify-media' : 'verify';
        const body = attachmentId
            ? { post_id: Number(postId), attachment_id: Number(attachmentId) }
            : { post_id: Number(postId) };

        requestCount += 1;

        return fetch(config.restUrl + endpoint, {
            method: 'POST',
            credentials: 'same-origin',
            headers: {
                'Content-Type': 'application/json',
                'X-WP-Nonce': config.nonce
            },
            body: JSON.stringify(body)
        }).then(function (response) {
            return response.json().catch(function () { return {}; }).then(function (data) {
                if (response.ok) {
                    return {
                        verdict: verdictFromPayload(data),
                        payload: data,
                        transportFailed: false
                    };
                }
                // A WordPress REST error envelope: { code, message, data }.
                return {
                    verdict: verdictFromError(data && data.code, response.status),
                    payload: data,
                    transportFailed: false
                };
            });
        }).catch(function () {
            // The request never completed. Nothing was checked, and a manual
            // retry is the one case where re-running can plausibly differ.
            return { verdict: null, payload: null, transportFailed: true };
        });
    }

    function resolveVerification(postId, attachmentId, force) {
        const key = targetKey(postId, attachmentId);
        if (force) {
            settled.delete(key);
            inFlight.delete(key);
        } else if (settled.has(key)) {
            return Promise.resolve(settled.get(key));
        }
        if (inFlight.has(key)) {
            return inFlight.get(key);
        }

        const promise = performFetch(postId, attachmentId).then(function (outcome) {
            inFlight.delete(key);
            settled.set(key, outcome);
            return outcome;
        });
        inFlight.set(key, promise);
        return promise;
    }

    function beginVerification(postId, attachmentId, force) {
        openToken += 1;
        const token = openToken;
        const key = targetKey(postId, attachmentId);
        const cached = !force && settled.has(key);

        if (!cached) {
            loading.hidden = false;
            result.hidden = true;
            result.replaceChildren();
            result.setAttribute('data-encypher-verdict', 'checking');
        }

        return resolveVerification(postId, attachmentId, force === true).then(function (outcome) {
            if (token !== openToken) {
                return;
            }
            renderVerification(outcome.verdict, outcome.payload, postId, attachmentId, outcome.transportFailed);
        });
    }

    function openModal(control) {
        trigger = control;
        modal.hidden = false;
        modal.setAttribute('aria-hidden', 'false');
        document.body.classList.add('encypher-c2pa-dialog-open');
        closeButton.focus();
        beginVerification(
            control.getAttribute('data-post-id'),
            control.getAttribute('data-attachment-id'),
            false
        );
    }

    function closeModal() {
        // Discards the pending render, not the answer: the settled outcome
        // stays cached so re-opening does not re-request.
        openToken += 1;
        modal.hidden = true;
        modal.setAttribute('aria-hidden', 'true');
        document.body.classList.remove('encypher-c2pa-dialog-open');
        if (trigger) {
            trigger.focus();
        }
    }

    document.addEventListener('click', function (event) {
        const badge = event.target.closest('.encypher-c2pa-badge');
        if (!badge) {
            return;
        }
        event.preventDefault();
        openModal(badge);
    });

    closeButton.addEventListener('click', closeModal);
    overlay.addEventListener('click', closeModal);
    document.addEventListener('keydown', function (event) {
        if (modal.hidden) {
            return;
        }
        if (event.key === 'Escape') {
            event.preventDefault();
            closeModal();
            return;
        }
        if (event.key !== 'Tab') {
            return;
        }
        const elements = focusableElements();
        if (!elements.length) {
            event.preventDefault();
            dialog.focus();
            return;
        }
        const first = elements[0];
        const last = elements[elements.length - 1];
        if (event.shiftKey && document.activeElement === first) {
            event.preventDefault();
            last.focus();
        } else if (!event.shiftKey && document.activeElement === last) {
            event.preventDefault();
            first.focus();
        }
    });

    // -----------------------------------------------------------------------
    // Optional reader-facing media verification marks (default OFF).
    //
    // This client-side overlay never mutates article markup. The server ships
    // the exact delivery URL for each provenance-bearing attachment that STILL
    // has a resolvable claim. A mark appears only when the browser's selected
    // bytes came from that exact URL: attachment IDs, resized basenames, and
    // format variants are insufficient, because those renditions do not carry
    // the original's C2PA hard binding. The image is never reparented, so the
    // button creates no layout shift. Misses are silent and fail closed.
    // -----------------------------------------------------------------------

    const OVERLAY_INSET = 12;
    const OVERLAY_SIZE = 26;

    function normalizeDeliveryUrl(url) {
        if (!url) {
            return '';
        }
        try {
            const base = document.baseURI
                || (window.location && window.location.href)
                || 'http://localhost/';
            const parsed = new URL(String(url), base);
            parsed.hash = '';
            return parsed.href;
        } catch (error) {
            return '';
        }
    }

    /**
     * Delivery URL to attachment, for entries that pass both gates: a canonical
     * URL and a resolvable claim. An entry with no claim is dropped here rather
     * than marked and left to fail in the dialog.
     */
    function signedDeliveryMap() {
        const map = new Map();
        const list = Array.isArray(config.signedMedia) ? config.signedMedia : [];
        list.forEach(function (entry) {
            const url = normalizeDeliveryUrl(entry && entry.url);
            const attachmentId = Number(entry && entry.id);
            const type = String((entry && entry.type) || '').toLowerCase();
            const claim = String((entry && entry.claim) || '');
            if (url && claim && attachmentId > 0 && ['image', 'audio', 'video'].indexOf(type) !== -1) {
                map.set(url, { id: attachmentId, type: type, claim: claim });
            }
        });
        return map;
    }

    function signedDeliveryUrls() {
        return new Set(signedDeliveryMap().keys());
    }

    function mediaSourceUrls(media) {
        const urls = [];
        const direct = media.currentSrc || (media.getAttribute ? media.getAttribute('src') : '') || media.src || '';
        if (direct) {
            urls.push(direct);
        }
        if (media.querySelectorAll) {
            Array.prototype.forEach.call(media.querySelectorAll('source[src]'), function (source) {
                const src = source.getAttribute ? source.getAttribute('src') : source.src;
                if (src) {
                    urls.push(src);
                }
            });
        }
        return urls;
    }

    function matchedSignedMedia(media, deliveryMap) {
        const tag = String(media.tagName || '').toLowerCase();
        const type = tag === 'img' ? 'image' : tag;
        for (const sourceUrl of mediaSourceUrls(media)) {
            const match = deliveryMap.get(normalizeDeliveryUrl(sourceUrl));
            if (match && match.type === type) {
                return match;
            }
        }
        return null;
    }

    const overlayRepositioners = new Set();
    let overlayRafPending = false;
    const raf = window.requestAnimationFrame
        ? window.requestAnimationFrame.bind(window)
        : function (cb) { return window.setTimeout(cb, 16); };

    function scheduleOverlayReposition() {
        if (overlayRafPending) {
            return;
        }
        overlayRafPending = true;
        raf(function () {
            overlayRafPending = false;
            overlayRepositioners.forEach(function (fn) { fn(); });
        });
    }

    function positionOverlay(button, media) {
        if (!media.getBoundingClientRect) {
            return;
        }
        const rect = media.getBoundingClientRect();
        const sx = window.scrollX || window.pageXOffset || 0;
        const sy = window.scrollY || window.pageYOffset || 0;
        button.style.position = 'absolute';
        button.style.top = (rect.top + sy + OVERLAY_INSET) + 'px';
        button.style.left = (rect.right + sx - OVERLAY_SIZE - OVERLAY_INSET) + 'px';
    }

    function buildOverlayIcon() {
        if (config.brandLogoUrl) {
            const logo = document.createElement('img');
            logo.className = 'encypher-c2pa-media-badge__logo';
            logo.setAttribute('src', String(config.brandLogoUrl));
            logo.setAttribute('alt', '');
            return logo;
        }
        const mark = document.createElement('img');
        mark.className = 'encypher-c2pa-media-badge__icon';
        mark.setAttribute('src', String(config.markUrl || ''));
        mark.setAttribute('alt', '');
        mark.setAttribute('aria-hidden', 'true');
        return mark;
    }

    const overlaidMedia = typeof WeakSet !== 'undefined' ? new WeakSet() : null;

    function attachOverlay(media, postId, match) {
        if (overlaidMedia && overlaidMedia.has(media)) {
            return;
        }
        if (overlaidMedia) {
            overlaidMedia.add(media);
        }

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'encypher-c2pa-media-badge';
        button.setAttribute('aria-label', config.overlayLabel || 'View content provenance');
        button.setAttribute('title', config.overlayLabel || 'View content provenance');
        button.setAttribute('data-post-id', String(postId));
        button.setAttribute('data-attachment-id', String(match.id));
        button.setAttribute('data-media-type', match.type);
        // Pre-check state: a record exists and nothing local contradicts it. The
        // mark never claims a verdict before a check has run.
        button.setAttribute('data-encypher-verdict', 'signed_unchecked');
        button.appendChild(buildOverlayIcon());
        button.addEventListener('click', function (event) {
            event.preventDefault();
            openModal(button);
        });

        positionOverlay(button, media);
        if (document.body) {
            document.body.appendChild(button);
        }

        let io = null;
        let ro = null;
        const reposition = function () {
            if (media.isConnected === false) {
                cleanup();
                return;
            }
            positionOverlay(button, media);
        };
        function cleanup() {
            overlayRepositioners.delete(reposition);
            if (io) { io.disconnect(); }
            if (ro) { ro.disconnect(); }
        }

        if (typeof IntersectionObserver !== 'undefined') {
            io = new IntersectionObserver(function (entries) {
                entries.forEach(function (entry) {
                    button.style.display = entry.isIntersecting ? '' : 'none';
                    if (entry.isIntersecting) {
                        positionOverlay(button, media);
                    }
                });
            }, { threshold: 0 });
            io.observe(media);
        }
        if (typeof ResizeObserver !== 'undefined') {
            ro = new ResizeObserver(reposition);
            ro.observe(media);
        }
        overlayRepositioners.add(reposition);
    }

    function scanMedia() {
        if (!config.mediaBadges) {
            return 0;
        }
        const deliveryMap = signedDeliveryMap();
        if (!deliveryMap.size) {
            return 0;
        }
        const postId = config.postId;
        const mediaElements = document.querySelectorAll
            ? document.querySelectorAll('img, audio, video')
            : [];
        let count = 0;
        Array.prototype.forEach.call(mediaElements, function (media) {
            const match = matchedSignedMedia(media, deliveryMap);
            if (!match) {
                return;
            }
            attachOverlay(media, postId, match);
            count++;
        });
        return count;
    }

    function initMediaOverlay() {
        if (!config.mediaBadges || !Array.isArray(config.signedMedia) || !config.signedMedia.length) {
            return;
        }
        scanMedia();
        if (window.addEventListener) {
            window.addEventListener('scroll', scheduleOverlayReposition, { passive: true });
            window.addEventListener('resize', scheduleOverlayReposition, { passive: true });
        }
        if (typeof MutationObserver !== 'undefined' && document.body) {
            const observer = new MutationObserver(function () { scanMedia(); });
            observer.observe(document.body, {
                childList: true,
                subtree: true,
                attributes: true,
                attributeFilter: ['src', 'srcset']
            });
        }
    }

    window.EncypherProvenanceReader = {
        STATES: STATES,
        LOCAL_STATES: LOCAL_STATES,
        presentation: presentation,
        isKnownState: isKnownState,
        verdictFromPayload: verdictFromPayload,
        verdictFromError: verdictFromError,
        detailCopy: detailCopy,
        retryAllowed: retryAllowed,
        normalizeDeliveryUrl: normalizeDeliveryUrl,
        signedDeliveryUrls: signedDeliveryUrls,
        scan: scanMedia,
        requestCount: function () { return requestCount; }
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initMediaOverlay);
    } else {
        initMediaOverlay();
    }
})();
