/**
 * Dynamic OG Image Generator - Gutenberg sidebar panel.
 *
 * Single file with inline Freemius `<fs_premium_only>` block-comment
 * markers around the premium panels and premium meta-key constants.
 * The Freemius deployment tool strips the marker content when building
 * the WP.org free zip, so the free build's sidebar has only the free
 * panel; the premium build keeps everything.
 */
( function () {
	const { registerPlugin }    = wp.plugins;
	const { PluginSidebar, PluginSidebarMoreMenuItem } = wp.editPost;
	const { PanelBody, PanelRow, ToggleControl, Button, Notice, TextControl, SelectControl, TextareaControl } = wp.components;
	const { useSelect, useDispatch } = wp.data;
	const { useState }              = wp.element;
	const { __, sprintf }           = wp.i18n;
	const apiFetch                  = wp.apiFetch;

	const PLUGIN_NAME  = 'doig-sidebar';
	const SIDEBAR_NAME = 'doig-sidebar/main';

	// Core meta keys (free)
	const META_ENABLED         = '_doig_enabled';            // legacy pre-2.4.0 tri-state; read-only back-compat
	const META_STATUS          = '_doig_status';
	const META_URL             = '_doig_cached_url';
	const META_AT              = '_doig_cached_at';
	const META_ERROR_CODE      = '_doig_error_code';
	const META_ERROR_MESSAGE   = '_doig_error_message';
	const META_SOURCE          = '_doig_source';             // 2.4.0: per-post Source override
	const META_CUSTOM_IMAGE_ID = '_doig_custom_image_id';    // 2.4.0: per-post Custom Image attachment ID

	// Source mode constants - must match DOIG_Source_Resolver::MODE_* in PHP.
	const SOURCE_INHERIT                  = '';
	const SOURCE_SCREENSHOT               = 'screenshot';
	const SOURCE_FEATURED                 = 'featured';
	const SOURCE_FEATURED_WITH_FALLBACK   = 'featured_with_fallback';
	const SOURCE_CUSTOM                   = 'custom';
	const SOURCE_OFF                      = 'off';

	

	/**
	 * Formats a unix timestamp as a human-readable "X days ago" string.
	 */
	function timeAgo( timestamp ) {
		if ( ! timestamp ) return '';
		const seconds = Math.floor( Date.now() / 1000 ) - parseInt( timestamp, 10 );
		if ( seconds < 60 )    return __( 'just now', 'dynamic-og-image-generator' );
		if ( seconds < 3600 )  return Math.floor( seconds / 60 )   + ' ' + __( 'min ago', 'dynamic-og-image-generator' );
		if ( seconds < 86400 ) return Math.floor( seconds / 3600 )  + ' ' + __( 'hr ago', 'dynamic-og-image-generator' );
		return Math.floor( seconds / 86400 ) + ' ' + __( 'days ago', 'dynamic-og-image-generator' );
	}

	/**
	 * Returns a status badge element.
	 */
	function StatusBadge( { status } ) {
		const labels = {
			ready:          { text: __( 'Ready',        'dynamic-og-image-generator' ), cls: 'doig-badge doig-badge--ready'   },
			pending:        { text: __( 'Pending',       'dynamic-og-image-generator' ), cls: 'doig-badge doig-badge--pending' },
			failed:         { text: __( 'Failed',        'dynamic-og-image-generator' ), cls: 'doig-badge doig-badge--failed'  },
			quota_exceeded: { text: __( 'Quota reached', 'dynamic-og-image-generator' ), cls: 'doig-badge doig-badge--quota'   },
			'':             { text: __( 'Not yet taken', 'dynamic-og-image-generator' ), cls: 'doig-badge doig-badge--none'    },
		};
		const info = labels[ status ] || labels[''];
		return wp.element.createElement( 'span', { className: info.cls }, info.text );
	}

	/**
	 * The main sidebar panel component.
	 */
	function DoigSidebar() {
		const postId = useSelect( ( select ) =>
			select( 'core/editor' ).getCurrentPostId()
		);

		const meta = useSelect( ( select ) =>
			select( 'core/editor' ).getEditedPostAttribute( 'meta' ) || {}
		);

		// The LAST-SAVED meta (vs `meta` above, which reflects any
		// unsaved edits in the editor). Used below to detect when the
		// enabled-toggle has been changed but not saved yet, so the
		// Regenerate Now button can refuse cleanly: the REST handler
		// reads the persisted meta and would otherwise bail with
		// "OG image generation is disabled for this post."
		const savedMeta = useSelect( ( select ) =>
			select( 'core/editor' ).getCurrentPostAttribute( 'meta' ) || {}
		);

		const { editPost } = useDispatch( 'core/editor' );

		// 2.4.0: Source is the canonical per-post override (Q2 Option C
		// re-lock 2026-06-23). Legacy _doig_enabled is read-only at this
		// point; it stays as a back-compat shim for posts that had a
		// pre-2.4.0 force-off set. Every time the sidebar writes to
		// META_SOURCE it also clears META_ENABLED so the resolver shim
		// doesn't continue to override the new explicit choice.
		const sourceValue          = meta[ META_SOURCE ]              ?? '';
		const savedSourceValue     = savedMeta[ META_SOURCE ]         ?? '';
		const sourceHasUnsavedChange = sourceValue !== savedSourceValue;
		const customImageId        = meta[ META_CUSTOM_IMAGE_ID ]     ?? 0;

		// Fetch the Custom Image attachment record so the sidebar can
		// show a meaningful filename instead of the raw attachment ID.
		// Returns undefined while loading, an object once resolved, or
		// null when the attachment was deleted from the Media Library.
		// useSelect re-runs on customImageId change so swapping the
		// attachment refreshes the display.
		const customImage = useSelect( function ( select ) {
			if ( customImageId > 0 ) {
				return select( 'core' ).getMedia( customImageId );
			}
			return null;
		}, [ customImageId ] );

		// Extract just the filename (no path) from the attachment's
		// source_url. Falls back to null when the attachment hasn't
		// finished loading or has been deleted from the Media Library.
		const customImageFilename = ( customImage && customImage.source_url )
			? customImage.source_url.split( '/' ).pop()
			: null;
		const customImageMissing = customImageId > 0 && customImage === null;

		// 2.4.0 M3.2c: post's Featured Image, fetched for the multi-source
		// preview rendering. Needed for Source = Featured and the
		// Featured-with-fallback fallback path. Same useSelect pattern
		// as the Custom Image fetch above.
		const featuredMediaId = useSelect( function ( select ) {
			return select( 'core/editor' ).getEditedPostAttribute( 'featured_media' ) || 0;
		}, [] );

		const featuredImage = useSelect( function ( select ) {
			if ( featuredMediaId > 0 ) {
				return select( 'core' ).getMedia( featuredMediaId );
			}
			return null;
		}, [ featuredMediaId ] );

		/**
		 * Picks the best preview-sized URL for an attachment. Prefers
		 * media_details.sizes.medium.source_url so the sidebar doesn't
		 * load multi-MB original images for a small preview thumb;
		 * falls back to source_url when the medium size isn't generated
		 * (very small attachments, SVGs, recently-uploaded images
		 * without all sizes computed yet).
		 *
		 * @param {Object|null|undefined} attachment wp.data attachment record
		 * @return {string|null}
		 */
		function attachmentPreviewUrl( attachment ) {
			if ( ! attachment ) {
				return null;
			}
			const sizes = attachment.media_details && attachment.media_details.sizes;
			if ( sizes && sizes.medium && sizes.medium.source_url ) {
				return sizes.medium.source_url;
			}
			return attachment.source_url || null;
		}

		/**
		 * Multi-source preview resolver. Mirrors
		 * DOIG_Source_Resolver::build_payload_for_mode() in JS: picks the
		 * URL that DOIG WOULD emit if the front-end emit path were
		 * already wired to the resolver (M4 territory; until M4 the
		 * actual emit path still uses the legacy cached-screenshot
		 * logic, so the sidebar preview leads what the front-end
		 * eventually shows).
		 *
		 * Returns either { url, isScreenshot } or null. Null means DOIG
		 * would not emit anything for this post in the current state -
		 * Source=Off, or Featured/Custom with no attachment available,
		 * or pending Screenshot before the first capture lands.
		 *
		 * The isScreenshot flag drives whether the "Last updated" stamp
		 * renders below the preview - only meaningful for screenshots,
		 * which have a DOIG-tracked capture timestamp.
		 *
		 * @return {{url:string,isScreenshot:boolean}|null}
		 */
		function getPreviewImage() {
			switch ( resolvedMode ) {
				case SOURCE_SCREENSHOT:
					return cachedUrl
						? { url: cachedUrl, isScreenshot: true }
						: null;

				case SOURCE_FEATURED: {
					const fiUrl = attachmentPreviewUrl( featuredImage );
					return fiUrl ? { url: fiUrl, isScreenshot: false } : null;
				}

				case SOURCE_FEATURED_WITH_FALLBACK: {
					const fiUrl = attachmentPreviewUrl( featuredImage );
					if ( fiUrl ) {
						return { url: fiUrl, isScreenshot: false };
					}
					return cachedUrl
						? { url: cachedUrl, isScreenshot: true }
						: null;
				}

				case SOURCE_CUSTOM: {
					const customUrl = attachmentPreviewUrl( customImage );
					return customUrl ? { url: customUrl, isScreenshot: false } : null;
				}

				case SOURCE_OFF:
					return null;

				default:
					return null;
			}
		}
		// NB: `const previewImage = getPreviewImage();` is intentionally
		// invoked LATER in the body, after `resolvedMode` and `cachedUrl`
		// are declared. Function declaration above is hoisted; the
		// invocation isn't, and would hit the temporal dead zone for
		// resolvedMode if called here.

		/**
		 * Returns the dimensions of the resolved attachment when below
		 * the 200x200 Open Graph spec floor; null otherwise. Drives the
		 * soft warning notice in the sidebar that surfaces when a
		 * customer's chosen Source mode would emit an image too small
		 * for social media platforms to render reliably (Facebook and
		 * LinkedIn ignore sub-200x200 images; smaller platforms show
		 * degraded previews).
		 *
		 * Q3 locked 2026-06-22: soft warning only, no behaviour change,
		 * no dismissal. DOIG still emits the customer's chosen image -
		 * the customer picked Featured / Custom for a reason. The
		 * warning just makes the platform-side consequence visible.
		 *
		 * Returns null when:
		 * - No image is going to emit (Off mode, or pending screenshot)
		 * - The resolved image is a screenshot (always 1200x630, above
		 *   the threshold)
		 * - The relevant attachment isn't loaded yet
		 * - The attachment has no media_details dimensions
		 * - The attachment is at or above 200x200 in both dimensions
		 *
		 * @return {{width:number,height:number}|null}
		 */
		function getSmallImageWarning() {
			// Only image-attachment modes can hit this. Screenshot mode
			// is always 1200x630; Off mode emits nothing.
			if ( ! previewImage || previewImage.isScreenshot ) {
				return null;
			}

			// Pick the attachment record that's actually emitting. For
			// Featured / FwF, that's the post's Featured Image. For
			// Custom, it's the per-post upload.
			let attachment = null;
			if ( resolvedMode === SOURCE_FEATURED || resolvedMode === SOURCE_FEATURED_WITH_FALLBACK ) {
				attachment = featuredImage;
			} else if ( resolvedMode === SOURCE_CUSTOM ) {
				attachment = customImage;
			}

			if ( ! attachment || ! attachment.media_details ) {
				return null;
			}

			const width  = parseInt( attachment.media_details.width,  10 ) || 0;
			const height = parseInt( attachment.media_details.height, 10 ) || 0;

			// If wp.data hasn't populated dimensions yet, skip the
			// warning. False positives are worse than false negatives
			// for a soft hint.
			if ( width === 0 || height === 0 ) {
				return null;
			}

			if ( width < 200 || height < 200 ) {
				return { width: width, height: height };
			}

			return null;
		}

		// Legacy: only read for the resolver-mirror logic that decides
		// what the radio shows when the user opens a pre-2.4.0 post for
		// the first time. Once they touch the radio, META_ENABLED gets
		// cleared via the editPost call.
		const legacyEnabledValue   = meta[ META_ENABLED ]             ?? '';

		const status               = meta[ META_STATUS ]              ?? '';
		const cachedUrl            = meta[ META_URL ]                 ?? '';
		const cachedAt             = meta[ META_AT ]                  ?? 0;
		const errorCode            = meta[ META_ERROR_CODE ]          ?? '';
		const errorMessage         = meta[ META_ERROR_MESSAGE ]       ?? '';

		

		const globalEnabled  = window.doigSidebar?.globalEnabled  ?? false;
		const globalSource   = window.doigSidebar?.globalSource   ?? SOURCE_OFF;
		const isConnected    = window.doigSidebar?.isConnected    ?? false;
		const isSiteUrlHttps = window.doigSidebar?.isSiteUrlHttps ?? true;
		const isTestProxy    = window.doigSidebar?.isTestProxy    ?? false;
		const testProxyUrl   = window.doigSidebar?.testProxyUrl   ?? '';
		const isPremium      = window.doigSidebar?.isPremium      ?? false;
		const planName       = window.doigSidebar?.planName       ?? 'free';
		const upgradeUrl     = window.doigSidebar?.upgradeUrl     ?? '';
		const wpUrlsSettings = window.doigSidebar?.wpUrlsSettings ?? '';

		// Resolved source mode for this post - JS-side mirror of
		// DOIG_Source_Resolver::resolve_mode() in PHP. Priority:
		//   1. Explicit META_SOURCE (terminal mode wins)
		//   2. Legacy META_ENABLED = '0' (back-compat force-off)
		//   3. Type default from localised globalSource
		function resolveSourceMode() {
			if ( sourceValue !== '' && sourceValue !== SOURCE_INHERIT ) {
				return sourceValue;
			}
			if ( legacyEnabledValue === '0' ) {
				return SOURCE_OFF;
			}
			return globalSource;
		}
		const resolvedMode = resolveSourceMode();
		const effectiveEnabled = resolvedMode !== SOURCE_OFF;
		// JS-side mirror of DOIG_Source_Resolver::needs_screenshot() in
		// PHP. True when the resolved Source mode actually requires a
		// screenshot capture: Auto-screenshot always, FwF only when no
		// Featured Image is set (FwF with an FI uses the FI and never
		// reaches the screenshot fallback). Drives the Regenerate Now
		// button's disabled state so customers can't trigger a quota
		// burn for a post whose Source mode wouldn't emit the
		// screenshot anyway - matches the M4.2 server-side gate in
		// DOIG_Cron::schedule() and DOIG_Post_Meta::rest_regenerate().
		const needsScreenshot = (
			resolvedMode === SOURCE_SCREENSHOT
			|| ( resolvedMode === SOURCE_FEATURED_WITH_FALLBACK && ! featuredImage )
		);
		// Compute the preview image AFTER resolvedMode and cachedUrl are
		// available - getPreviewImage() references both. Function
		// declaration is hoisted; the invocation isn't.
		const previewImage       = getPreviewImage();
		const smallImageWarning  = getSmallImageWarning();

		const [ regenerating, setRegenerating ] = useState( false );
		const [ notice, setNotice ]             = useState( null );
		const [ clearing, setClearing ]         = useState( false );

		// Translated labels for the Source SelectControl. Kept in JS
		// rather than localised from PHP so each translation surface owns
		// its own strings - the Settings page renders its dropdown via
		// DOIG_Source_Resolver::mode_labels() in PHP; the sidebar uses
		// these JS labels.
		const SOURCE_LABELS = {
			[ SOURCE_SCREENSHOT ]:             __( 'Auto-screenshot', 'dynamic-og-image-generator' ),
			[ SOURCE_FEATURED ]:               __( 'Featured image', 'dynamic-og-image-generator' ),
			[ SOURCE_FEATURED_WITH_FALLBACK ]: __( 'Featured image (with screenshot when missing)', 'dynamic-og-image-generator' ),
			[ SOURCE_CUSTOM ]:                 __( 'Custom image', 'dynamic-og-image-generator' ),
			[ SOURCE_OFF ]:                    __( 'Off', 'dynamic-og-image-generator' ),
		};

		/**
		 * Write a new Source mode for this post. Always clears the legacy
		 * META_ENABLED at the same time so the resolver's back-compat shim
		 * (which honours legacy `_doig_enabled = '0'` as force-off) stops
		 * applying once the customer has interacted with the new control.
		 *
		 * @param {string} newMode One of the SOURCE_* constants.
		 */
		function handleSourceChange( newMode ) {
			editPost( { meta: {
				[ META_SOURCE ]:  newMode,
				[ META_ENABLED ]: '',
			} } );
		}

		/**
		 * Opens the WP Media Library frame so the customer can pick a
		 * Custom Image for this post. Writes the chosen attachment ID
		 * to META_CUSTOM_IMAGE_ID; the resolver reads it when Source is
		 * MODE_CUSTOM.
		 *
		 * When the post already has a customImageId set, pre-select that
		 * attachment in the frame so the customer sees their current
		 * choice highlighted and the "Replace" affordance is obvious.
		 * Matches the standard wp.media() pattern used by core (e.g.
		 * Featured Image, theme customiser image controls).
		 */
		function handleSelectCustomImage() {
			const frame = wp.media( {
				title:    __( 'Select Custom Image for this post', 'dynamic-og-image-generator' ),
				button:   { text: __( 'Use this image', 'dynamic-og-image-generator' ) },
				multiple: false,
				library:  { type: 'image' },
			} );

			// Pre-select the current attachment on frame open. Has to
			// happen on the 'open' event (not before frame.open()
			// returns) because the frame's selection state is reset to
			// empty each time the frame opens.
			if ( customImageId > 0 ) {
				frame.on( 'open', function () {
					const selection  = frame.state().get( 'selection' );
					const attachment = wp.media.attachment( customImageId );
					// fetch() pulls the attachment metadata from the
					// REST API if it's not already in wp.media's cache;
					// a no-op when the attachment is already loaded.
					// Returns a deferred but we don't have to wait on
					// it - the selection.add() call handles the lazy
					// load by re-rendering once the model populates.
					attachment.fetch();
					selection.reset( [ attachment ] );
				} );
			}

			frame.on( 'select', function () {
				const attachment = frame.state().get( 'selection' ).first().toJSON();
				editPost( { meta: { [ META_CUSTOM_IMAGE_ID ]: attachment.id } } );
			} );
			frame.open();
		}

		function handleClearCustomImage() {
			editPost( { meta: { [ META_CUSTOM_IMAGE_ID ]: 0 } } );
		}

		// Polling configuration for the Regenerate Now flow.
		// The plugin's /regenerate-status endpoint does a single direct poll
		// against the screenshot server when called while a job is pending,
		// so each tick gives us a real-time snapshot rather than waiting for
		// the next cron run.
		const POLL_INTERVAL_MS = 2000;
		const POLL_TIMEOUT_MS  = 60000;

		/**
		 * Poll the status endpoint until the job reaches a terminal state
		 * (ready / failed / quota_exceeded) or the hard timeout elapses.
		 * Updates post meta in place so the sidebar preview and timestamp
		 * refresh as soon as the server returns the result.
		 */
		function pollUntilDone() {
			const startedAt = Date.now();

			const tick = () => {
				apiFetch( {
					url:     window.doigSidebar.restBase + '/regenerate-status/' + postId,
					method:  'GET',
					headers: { 'X-WP-Nonce': window.doigSidebar.nonce },
				} )
				.then( ( r ) => {
					const s = r && r.status ? r.status : '';

					// Terminal: success.
					if ( 'ready' === s && r.cached_url ) {
						editPost( { meta: {
							[ META_STATUS ]:        'ready',
							[ META_URL ]:           r.cached_url,
							[ META_AT ]:            r.cached_at || 0,
							[ META_ERROR_CODE ]:    '',
							[ META_ERROR_MESSAGE ]: '',
						} } );
						setRegenerating( false );
						setNotice( { type: 'success', message: __( 'Screenshot updated.', 'dynamic-og-image-generator' ) } );
						return;
					}

					// Terminal: failed.
					if ( 'failed' === s ) {
						editPost( { meta: {
							[ META_STATUS ]:        'failed',
							[ META_ERROR_CODE ]:    r.error_code || '',
							[ META_ERROR_MESSAGE ]: r.error_message || '',
						} } );
						setRegenerating( false );
						setNotice( {
							type:    'error',
							message: r.error_message || __( 'Screenshot failed. Check the Logs tab for details.', 'dynamic-og-image-generator' ),
						} );
						return;
					}

					// Terminal: quota_exceeded.
					if ( 'quota_exceeded' === s ) {
						editPost( { meta: { [ META_STATUS ]: 'quota_exceeded' } } );
						setRegenerating( false );
						return;
					}

					// Not terminal yet (pending / queued / processing).
					if ( Date.now() - startedAt >= POLL_TIMEOUT_MS ) {
						setRegenerating( false );
						// 'warning' status gives WordPress's standard amber
						// treatment, which catches the eye - the previous
						// 'info' (blue/white) message blended into the
						// sidebar and was easy to miss.
						setNotice( {
							type:    'warning',
							message: __( 'Still working. Reload the editor in a minute to see the updated screenshot.', 'dynamic-og-image-generator' ),
						} );
						return;
					}

					setTimeout( tick, POLL_INTERVAL_MS );
				} )
				.catch( () => {
					// Transient errors are non-fatal - keep polling until the
					// timeout. A permanent error (auth lost, etc.) will keep
					// firing and the timeout cuts it off.
					if ( Date.now() - startedAt >= POLL_TIMEOUT_MS ) {
						setRegenerating( false );
						return;
					}
					setTimeout( tick, POLL_INTERVAL_MS );
				} );
			};

			setTimeout( tick, POLL_INTERVAL_MS );
		}

		function handleRegenerate() {
			setRegenerating( true );
			setNotice( null );

			apiFetch( {
				url:     window.doigSidebar.restBase + '/regenerate/' + postId,
				method:  'POST',
				headers: { 'X-WP-Nonce': window.doigSidebar.nonce },
			} )
			.then( ( response ) => {
				if ( response.success ) {
					// Submit accepted by the screenshot server. Start polling
					// the status endpoint; UI stays in "Regenerating…" until
					// the poll reaches a terminal state.
					editPost( { meta: { [ META_STATUS ]: response.status || 'pending' } } );
					setNotice( { type: 'info', message: __( 'Regenerating screenshot…', 'dynamic-og-image-generator' ) } );
					pollUntilDone();
					return;
				}

				// Synchronous failure (validation, quota, licence): no poll.
				if ( response.status ) {
					editPost( { meta: { [ META_STATUS ]: response.status } } );
				}
				setNotice( { type: 'error', message: response.message } );
				setRegenerating( false );
			} )
			.catch( ( err ) => {
				let message;
				if ( err.code === 'rest_not_logged_in' || err.code === 'rest_cookie_invalid_nonce' ) {
					message = __( 'Your session has expired. Please refresh the page and try again.', 'dynamic-og-image-generator' );
				} else if ( err.code === 'rest_forbidden' ) {
					message = __( 'You do not have permission to regenerate screenshots for this post.', 'dynamic-og-image-generator' );
				} else if ( err.message ) {
					message = err.message;
				} else {
					message = __( 'Something went wrong. Please check the Logs tab for details.', 'dynamic-og-image-generator' );
				}
				setNotice( { type: 'error', message } );
				setRegenerating( false );
			} );
		}

		// Clear the cached screenshot for this post (2.6.0). Distinct from
		// Regenerate Now: deletes the cached WebP and resets the status
		// WITHOUT queueing a fresh capture, so no quota is used. The daily
		// refresh or the next content update re-captures eventually.
		function handleClearCache() {
			// Destructive: confirm before deleting the cached file.
			// eslint-disable-next-line no-alert
			if ( ! window.confirm( __( 'Delete the cached screenshot for this post? No new capture will be queued and no quota will be used.', 'dynamic-og-image-generator' ) ) ) {
				return;
			}

			setClearing( true );
			setNotice( null );

			apiFetch( {
				url:     window.doigSidebar.restBase + '/clear-cache/' + postId,
				method:  'POST',
				headers: { 'X-WP-Nonce': window.doigSidebar.nonce },
			} )
			.then( ( response ) => {
				if ( response.success ) {
					// Mirror the server-side purge in the editor store so the
					// badge, timestamp and preview reset without a reload.
					editPost( { meta: {
						[ META_STATUS ]:        '',
						[ META_URL ]:           '',
						[ META_AT ]:            0,
						[ META_ERROR_CODE ]:    '',
						[ META_ERROR_MESSAGE ]: '',
					} } );
					setNotice( { type: 'success', message: response.message } );
				} else {
					setNotice( { type: 'error', message: response.message } );
				}
				setClearing( false );
			} )
			.catch( ( err ) => {
				let message;
				if ( err.code === 'rest_not_logged_in' || err.code === 'rest_cookie_invalid_nonce' ) {
					message = __( 'Your session has expired. Please refresh the page and try again.', 'dynamic-og-image-generator' );
				} else if ( err.code === 'rest_forbidden' ) {
					message = __( 'You do not have permission to clear the cached screenshot for this post.', 'dynamic-og-image-generator' );
				} else if ( err.message ) {
					message = err.message;
				} else {
					message = __( 'Something went wrong. Please check the Logs tab for details.', 'dynamic-og-image-generator' );
				}
				setNotice( { type: 'error', message } );
				setClearing( false );
			} );
		}

		// Build a persistent status notice based on current meta state.
		// Shown regardless of whether the user has clicked Regenerate.
		function renderStatusNotice() {
			if ( 'quota_exceeded' === status ) {
				// Two messages stitched together: state communication first
				// ("what's happening"), capacity affordance second ("higher
				// plans add monthly capacity"). The link verb is "Try Premium"
				// on non-Agency plans where an upgrade path exists.
				const upgradeLink = ( upgradeUrl && 'agency' !== planName )
					? wp.element.createElement(
						'a',
						{ href: upgradeUrl, target: '_blank', rel: 'noreferrer', style: { marginLeft: '4px' } },
						__( 'Try Premium →', 'dynamic-og-image-generator' )
					)
					: null;

				const tail = ( upgradeUrl && 'agency' !== planName )
					? __( ' Higher plans add monthly capacity. ', 'dynamic-og-image-generator' )
					: '';

				return wp.element.createElement(
					Notice,
					{ status: 'warning', isDismissible: false },
					__( 'Monthly quota reached. Existing screenshots are still being served. New screenshots will resume automatically when your quota resets on your next billing date.', 'dynamic-og-image-generator' ),
					tail,
					upgradeLink
				);
			}

			if ( 'failed' === status && 'licence_inactive' === errorCode ) {
				return wp.element.createElement(
					Notice,
					{ status: 'error', isDismissible: false },
					__( 'Your PlugUpp licence is inactive. Screenshots have been paused. ', 'dynamic-og-image-generator' ),
					wp.element.createElement(
						'a',
						{ href: 'https://plugupp.com/account', target: '_blank', rel: 'noreferrer' },
						__( 'Manage your subscription', 'dynamic-og-image-generator' )
					)
				);
			}

			if ( 'failed' === status && 'proxy_unavailable' === errorCode ) {
				return wp.element.createElement(
					Notice,
					{ status: 'error', isDismissible: false },
					__( 'Screenshot failed: the PlugUpp screenshot server was unreachable after 3 attempts. This may be a temporary outage. You can try again using Regenerate Now.', 'dynamic-og-image-generator' )
				);
			}

			// 2.6.2: generic failed notice for every other error code. Before
			// this, only the two codes above rendered a notice - a
			// host_returned_error (site firewall/bot protection blocking the
			// capture) showed nothing but the Failed badge. Shows the stored
			// error message plus, when the code is in the guidance map, a
			// short what-to-do hint and the matching troubleshooting link.
			if ( 'failed' === status ) {
				const guidance = ( doigSidebar.errorGuidance || {} )[ errorCode ] || null;
				return wp.element.createElement(
					Notice,
					{ status: 'error', isDismissible: false },
					errorMessage || __( 'Screenshot failed. Check the Logs tab for details.', 'dynamic-og-image-generator' ),
					guidance
						? wp.element.createElement(
							'p',
							{ style: { marginBottom: 0 } },
							guidance.hint + ' ',
							wp.element.createElement(
								'a',
								{ href: guidance.url, target: '_blank', rel: 'noreferrer' },
								__( 'Troubleshooting guide →', 'dynamic-og-image-generator' )
							)
						)
						: null
				);
			}

			// Note: the http_site_url error_code is intentionally NOT handled
			// here. It is fully covered by the proactive top-of-panel Notice
			// gated on `isSiteUrlHttps`, which renders whenever the current
			// site URL is http and disappears the moment it is fixed. Adding
			// a second meta-driven notice here would duplicate the message
			// while the URL is broken and leave a stale "your URL is http"
			// banner showing for already-failed posts even after the user
			// has fixed the Site Address - until the next successful regen
			// clears the meta. The proactive notice is the source of truth.

			return null;
		}

		return wp.element.createElement(
			PluginSidebar,
			{
				name:  SIDEBAR_NAME,
				title: __( 'OG Image', 'dynamic-og-image-generator' ),
				icon:  'camera',
			},

			// ---- Main panel ----
			wp.element.createElement(
				PanelBody,
				{ title: __( 'Dynamic OG Image', 'dynamic-og-image-generator' ), initialOpen: true },

				// Persistent test-environment badge: the install is pointing
				// at a non-production proxy via the DOIG_PROXY_URL wp-config
				// constant. Shown at the very top of the panel so an operator
				// or developer cannot miss the override - matches the same
				// banner on the Settings page.
				isTestProxy && wp.element.createElement(
					Notice,
					{ status: 'warning', isDismissible: false, className: 'doig-test-env-badge' },
					wp.element.createElement( 'strong', null,
						__( 'Test environment', 'dynamic-og-image-generator' )
					),
					wp.element.createElement( 'br' ),
					__( 'Connected to: ', 'dynamic-og-image-generator' ),
					wp.element.createElement( 'code', null, testProxyUrl )
				),

				// Persistent site-level warning: the WP Site Address is http://.
				// The screenshot service requires https URLs and every job will
				// fail until the user fixes WordPress Settings -> General. Shown
				// at the top of the panel regardless of other state so the user
				// sees it as soon as they open a post for edit.
				! isSiteUrlHttps && wp.element.createElement(
					Notice,
					{ status: 'error', isDismissible: false },
					__( 'Your WordPress site URL is set to http://. The screenshot service requires https://. ', 'dynamic-og-image-generator' ),
					wp.element.createElement(
						'a',
						{ href: wpUrlsSettings, target: '_blank', rel: 'noreferrer' },
						__( 'Update Settings → General → Site Address', 'dynamic-og-image-generator' )
					)
				),

				notice && wp.element.createElement(
					Notice,
					{
						status:        notice.type,
						isDismissible: true,
						onRemove:      () => setNotice( null ),
					},
					notice.message
				),

				renderStatusNotice(),

				// 2.4.0: Source SelectControl. Six options - Inherit + the
				// five terminal modes. Replaces the pre-2.4.0 ToggleControl
				// (which mapped a binary toggle onto a tri-state meta via
				// implicit "matches global = inherit" logic - confusing
				// and trap-prone under bulk operations).
				//
				// SelectControl rather than RadioControl: matches the
				// Settings page Source dropdowns (consistency across
				// surfaces) and the existing Premium sidebar controls,
				// which all use SelectControl with "Inherit site default"
				// as the first option. Compact in the narrow sidebar
				// column.
				//
				// Resolved-state hint: when Inherit is the selected
				// value, the `help` prop shows "Currently inheriting:
				// <type default label>" below the dropdown so the
				// customer can see what Inherit means for this post
				// without opening the dropdown or consulting the
				// Settings page. The hint disappears for explicit
				// choices where it's not useful.
				wp.element.createElement(
					PanelRow,
					null,
					wp.element.createElement( SelectControl, {
						label:    __( 'Image source', 'dynamic-og-image-generator' ),
						value:    sourceValue || SOURCE_INHERIT,
						onChange: handleSourceChange,
						options: [
							{ label: __( 'Inherit content-type default', 'dynamic-og-image-generator' ), value: SOURCE_INHERIT },
							{ label: SOURCE_LABELS[ SOURCE_SCREENSHOT ],             value: SOURCE_SCREENSHOT },
							{ label: SOURCE_LABELS[ SOURCE_FEATURED ],               value: SOURCE_FEATURED },
							{ label: SOURCE_LABELS[ SOURCE_FEATURED_WITH_FALLBACK ], value: SOURCE_FEATURED_WITH_FALLBACK },
							{ label: SOURCE_LABELS[ SOURCE_CUSTOM ],                 value: SOURCE_CUSTOM },
							{ label: SOURCE_LABELS[ SOURCE_OFF ],                    value: SOURCE_OFF },
						],
					} )
				),

				// Inherit-resolved state, shown only when the customer's
				// per-post override is "Inherit" (explicit or empty).
				// Rendered as a styled <p> matching the Custom image
				// block's bold-label-plus-value shape - same visual
				// rhythm across both informational supplementary rows.
				// The resolved label is a translated human string, not
				// an identifier, so no <code> styling on the value.
				( sourceValue === '' || sourceValue === SOURCE_INHERIT ) && wp.element.createElement(
					PanelRow,
					null,
					wp.element.createElement( 'p', { style: { margin: '0 0 6px' } },
						wp.element.createElement( 'strong', null, __( 'Currently inheriting:', 'dynamic-og-image-generator' ) ),
						' ',
						SOURCE_LABELS[ globalSource ] || globalSource
					)
				),

				// Media Library picker for the Custom Image mode. Only
				// renders when Source is Custom (either explicit or
				// resolved-via-Inherit-to-the-type-default-being-Custom).
				resolvedMode === SOURCE_CUSTOM && wp.element.createElement(
					PanelRow,
					null,
					wp.element.createElement( 'div', { className: 'doig-custom-image-picker' },
						customImageId > 0
							? wp.element.createElement( wp.element.Fragment, null,
								// Display the attachment's filename if we
								// could fetch it; show a "missing"
								// warning when the attachment has been
								// deleted from the Media Library; show
								// "Loading..." while wp.data is fetching.
								wp.element.createElement( 'p', { style: { margin: '0 0 6px' } },
									wp.element.createElement( 'strong', null, __( 'Custom image:', 'dynamic-og-image-generator' ) ),
									' ',
									customImageFilename
										? wp.element.createElement( 'code', {
											style: { fontSize: '11px' },
											title: customImage && customImage.source_url ? customImage.source_url : '',
										}, customImageFilename )
										: customImageMissing
											? wp.element.createElement( 'span', { style: { color: '#b32d2e' } },
												__( 'Attachment missing - pick a replacement.', 'dynamic-og-image-generator' )
											)
											: wp.element.createElement( 'em', null,
												__( 'Loading…', 'dynamic-og-image-generator' )
											)
								),
								wp.element.createElement( Button, {
									variant:  'secondary',
									onClick:  handleSelectCustomImage,
								}, __( 'Change image', 'dynamic-og-image-generator' ) ),
								' ',
								wp.element.createElement( Button, {
									variant:  'tertiary',
									isDestructive: true,
									onClick:  handleClearCustomImage,
								}, __( 'Remove', 'dynamic-og-image-generator' ) )
							)
							: wp.element.createElement( wp.element.Fragment, null,
								wp.element.createElement( Button, {
									variant:  'primary',
									onClick:  handleSelectCustomImage,
								}, __( 'Select custom image', 'dynamic-og-image-generator' ) ),
								wp.element.createElement( 'p', {
									className: 'description',
									style:     { margin: '6px 0 0', color: '#b32d2e' },
								}, __( 'No custom image selected. Pick one from the Media Library, or DOIG will not emit an og:image for this post.', 'dynamic-og-image-generator' ) )
							)
					)
				),

				wp.element.createElement( PanelRow, null,
					wp.element.createElement( 'div', { className: 'doig-status-row' },
						wp.element.createElement( 'span', { className: 'doig-status-label' }, __( 'Status:', 'dynamic-og-image-generator' ) ),
						wp.element.createElement( StatusBadge, { status } )
					)
				),

				// "Last updated" timestamp - only meaningful for the
				// Screenshot source, which has a DOIG-tracked capture
				// time. Featured / Custom modes show an attachment the
				// customer chose; there's no DOIG-tracked "last
				// updated" for those.
				cachedAt > 0 && previewImage && previewImage.isScreenshot && wp.element.createElement( PanelRow, null,
					wp.element.createElement( 'p', { className: 'doig-timestamp' },
						__( 'Last updated:', 'dynamic-og-image-generator' ) + ' ' + timeAgo( cachedAt )
					)
				),

				// Small-image warning (Q3 lock 2026-06-22). Soft notice
				// surfaced when the resolved image is below the 200x200
				// OG spec floor. DOIG still emits the customer's chosen
				// image - this just makes the platform-side consequence
				// (Facebook / LinkedIn ignoring sub-200x200 images)
				// visible before the customer publishes and wonders why
				// their preview never appeared.
				smallImageWarning && wp.element.createElement( Notice, {
					status:        'warning',
					isDismissible: false,
					className:     'doig-small-image-warning',
				},
					sprintf(
						/* translators: 1: image width in pixels, 2: image height in pixels */
						__( 'This image is %1$d × %2$d pixels - below the 200×200 minimum recommended for social media previews. Facebook and LinkedIn may ignore it or show a degraded preview.', 'dynamic-og-image-generator' ),
						smallImageWarning.width,
						smallImageWarning.height
					)
				),

				// Preview image: mirrors the resolver's decision chain.
				// Source=Screenshot -> cached screenshot. Source=Featured
				// -> the post's Featured Image. Source=FwF -> Featured
				// if present, else cached screenshot. Source=Custom ->
				// the per-post custom upload. Source=Off -> no preview.
				// Inherit resolves to the type default and then follows
				// the same branches.
				//
				// Hides entirely when the resolver would return null
				// (Off, or Featured/Custom with no attachment, or
				// Screenshot before the first capture lands).
				//
				// Until M4 wires the resolver into the front-end emit
				// path, this preview LEADS the actual emit - the
				// front-end <meta property="og:image"> still reflects
				// the legacy logic. Sidebar shows what DOIG WILL emit
				// once M4 ships.
				previewImage && wp.element.createElement( PanelRow, null,
					wp.element.createElement( 'div', { className: 'doig-preview-wrap' },
						wp.element.createElement( 'img', {
							src:       previewImage.url,
							alt:       __( 'OG image preview', 'dynamic-og-image-generator' ),
							className: 'doig-preview-img',
						} )
					)
				),

				// Regenerate Now button + contextual help text below it.
				//
				// wp.components.Button does not render a `help` prop (only
				// ToggleControl / SelectControl etc. do), so we render the
				// help below the button as a sibling.
				//
				// The unsaved-toggle case uses the WordPress 'warning' Notice
				// (amber) rather than a plain `<p>` so it catches the user's
				// eye - it's an actionable state, not just an informational
				// hint, and the writer needs to do something to clear it.
				// The other help cases (not enabled / not connected) stay as
				// plain description text - they describe state, not a
				// pending action.
				( function () {
					let helpText  = '';
					let helpAmber = false;

					if ( ! isConnected ) {
						helpText = __( 'Complete plugin setup on the Settings page to start generating screenshots.', 'dynamic-og-image-generator' );
					} else if ( ! isSiteUrlHttps ) {
						// The persistent error Notice at the top of the panel
						// covers this case - skip the help text to avoid
						// duplicating the message.
						helpText = '';
					} else if ( sourceHasUnsavedChange ) {
						helpText  = __( 'Save the post to apply the new Source mode before regenerating.', 'dynamic-og-image-generator' );
						helpAmber = true;
					} else if ( ! needsScreenshot ) {
						// Two help-text branches under the new screenshot
						// gate (2.4.0 M4.2b). Off mode keeps the specific
						// "DOIG is Off" framing because it tells the customer
						// the whole plugin is stepping aside for this post.
						// Featured / Custom / FwF-with-FI share a generic
						// "Available when..." message that names the only
						// mode where Regenerate Now is meaningful.
						if ( resolvedMode === SOURCE_OFF ) {
							helpText = __( 'DOIG is Off for this post. Set the Image Source above to Auto-screenshot to enable regeneration.', 'dynamic-og-image-generator' );
						} else {
							helpText = __( 'Regenerate is only available when the Image Source is set to Auto-screenshot.', 'dynamic-og-image-generator' );
						}
					}

					return wp.element.createElement( wp.element.Fragment, null,
						wp.element.createElement( PanelRow, null,
							wp.element.createElement( Button, {
								variant:  'secondary',
								onClick:  handleRegenerate,
								// Disabled when:
								// - a regen is already in flight, OR
								// - the Source has been changed but the post
								//   has not been saved yet (the REST handler
								//   reads the persisted meta, so an unsaved
								//   choice would be ignored), OR
								// - the resolved Source for this post does not
								//   need a screenshot capture: Featured /
								//   Custom / Off, or FwF with a Featured Image
								//   already present (M4.2b - matches the
								//   server-side gate in DOIG_Cron::schedule()
								//   and DOIG_Post_Meta::rest_regenerate()), OR
								// - the plugin has not been activated (no Freemius
								//   connection), OR
								// - the WP Site Address is http:// (the screenshot
								//   service rejects http URLs at /v1/job).
								disabled: regenerating
									|| sourceHasUnsavedChange
									|| ! needsScreenshot
									|| ! isConnected
									|| ! isSiteUrlHttps,
								isBusy:   regenerating,
							},
								regenerating
									? __( 'Regenerating…', 'dynamic-og-image-generator' )
									: __( 'Regenerate Now', 'dynamic-og-image-generator' )
							)
						),
						helpText && ( helpAmber
							? wp.element.createElement( Notice,
								{ status: 'warning', isDismissible: false, className: 'doig-regen-help-notice' },
								helpText
							)
							: wp.element.createElement( PanelRow, null,
								wp.element.createElement( 'p',
									{ className: 'components-base-control__help doig-regen-help' },
									helpText
								)
							)
						)
					);
				} )(),

				// More actions (2.6.0): destructive secondary actions below
				// Regenerate Now. Clear cache = delete the cached WebP + reset
				// status; no fresh capture, no quota. Disabled when there is
				// nothing cached to clear. Distinct from "Reset Image source
				// to default", which is about resolver behaviour, not cache
				// state.
				wp.element.createElement( PanelRow, { className: 'doig-more-actions' },
					wp.element.createElement( Button, {
						variant:       'link',
						isDestructive: true,
						onClick:       handleClearCache,
						disabled:      clearing || regenerating || ! cachedUrl,
						isBusy:        clearing,
					},
						clearing
							? __( 'Clearing…', 'dynamic-og-image-generator' )
							: __( 'Clear cached screenshot', 'dynamic-og-image-generator' )
					)
				),

				// Non-paying-user upsell card. Yoast-pattern feature card at
				// the bottom of the main panel (descriptive prose only - no
				// mock UI, no locked form fields, no greyed inputs). Named
				// features in plain text are compliant per the
				// `_standards/docs/CONVENTIONS.md` "Allowed in the block
				// editor sidebar > Premium feature card" pattern; the
				// trialware finding was about implementation traces in code,
				// not about feature naming in copy. Hidden once the user
				// upgrades.
				! isPremium && wp.element.createElement( PanelRow, { className: 'doig-sidebar-premium-card__row' },
					wp.element.createElement( 'div', { className: 'doig-sidebar-premium-card' },
						wp.element.createElement( 'div', { className: 'doig-sidebar-premium-card__head' },
							wp.element.createElement( 'span', { className: 'dashicons dashicons-awards doig-sidebar-premium-card__head-icon', 'aria-hidden': 'true' } ),
							wp.element.createElement( 'strong', null, __( 'Premium', 'dynamic-og-image-generator' ) )
						),
						wp.element.createElement( 'p', { className: 'doig-sidebar-premium-card__lead' },
							__( 'Premium adds per-post capture control and a richer global capture surface.', 'dynamic-og-image-generator' )
						),
						wp.element.createElement( 'ul', { className: 'doig-sidebar-premium-card__features' },
							wp.element.createElement( 'li', null,
								wp.element.createElement( 'strong', null, __( 'Per-post overrides', 'dynamic-og-image-generator' ) ),
								': ',
								__( 'tweak any capture setting on individual posts from this sidebar.', 'dynamic-og-image-generator' )
							),
							wp.element.createElement( 'li', null,
								wp.element.createElement( 'strong', null, __( 'Custom viewport and capture delay', 'dynamic-og-image-generator' ) ),
								': ',
								__( 'match the browser to how your site renders.', 'dynamic-og-image-generator' )
							),
							wp.element.createElement( 'li', null,
								wp.element.createElement( 'strong', null, __( 'Country routing', 'dynamic-og-image-generator' ) ),
								': ',
								__( 'capture pages from a specific country to test geo-locked content.', 'dynamic-og-image-generator' )
							),
							wp.element.createElement( 'li', null,
								wp.element.createElement( 'strong', null, __( 'Hide selectors', 'dynamic-og-image-generator' ) ),
								': ',
								__( 'drop newsletter bars, promo strips or any element by CSS selector.', 'dynamic-og-image-generator' )
							),
							wp.element.createElement( 'li', null,
								wp.element.createElement( 'strong', null, __( 'Media Library integration', 'dynamic-og-image-generator' ) ),
								': ',
								__( 'save each capture as an attachment, optionally set as Featured Image.', 'dynamic-og-image-generator' )
							)
						),
						wp.element.createElement( 'a',
							{ className: 'doig-sidebar-premium-card__cta', href: upgradeUrl, target: '_blank', rel: 'noreferrer' },
							wp.element.createElement( 'span', { className: 'dashicons dashicons-lock', 'aria-hidden': 'true' } ),
							' ',
							__( 'Try Premium', 'dynamic-og-image-generator' )
						)
					)
				)
			), // end main PanelBody

			

		); // end PluginSidebar
	}

	// Register the sidebar menu item (appears in the three-dot menu)
	registerPlugin( PLUGIN_NAME, {
		render: () => wp.element.createElement(
			wp.element.Fragment,
			null,
			wp.element.createElement(
				PluginSidebarMoreMenuItem,
				{ target: SIDEBAR_NAME, icon: 'camera' },
				__( 'OG Image', 'dynamic-og-image-generator' )
			),
			wp.element.createElement( DoigSidebar, null )
		),
	} );

} )();
