<?php
/**
 * NeccoSpeak — G2 version history: publish/discard/rollback handlers,
 * automatic snapshot recording, the version list + diff UI, and the
 * "unpublished changes" publish bar.
 *
 * Required unconditionally (is_admin() guard, file_exists()-gated) from the
 * bottom of includes/settings.php (design phase5 §1.1/§9.4), same pattern as
 * includes/admin-cta.php / includes/admin-preview.php in earlier phases — so
 * the admin_post_* hooks below register even on requests (admin-post.php)
 * that never call neccospeak_render_settings_page().
 *
 * Owner: impl-versions.
 * Public contract (design phase5 §1.3, consumed by includes/settings.php and
 * impl-viewers' includes/admin-logs.php):
 *   neccospeak_render_publish_bar( $published, $draft )
 *   neccospeak_render_versions_view()
 *   neccospeak_versions_publish()
 *   neccospeak_versions_rollback( $id )
 *
 * Consumes (all guarded with function_exists(), per design phase5 §1.3's
 * cross-agent contract table — this file degrades to safe, inert fallbacks
 * if any of these are not yet loaded):
 *   includes/db.php:       neccospeak_db_ready() / neccospeak_db_add_version() /
 *                           neccospeak_db_get_version() / neccospeak_db_get_versions() /
 *                           neccospeak_db_log_audit()
 *   includes/settings.php: neccospeak_get_options() / neccospeak_get_draft_options() /
 *                           neccospeak_draft_workflow_active() / neccospeak_draft_diff_keys() /
 *                           neccospeak_sync_draft_from_published() / neccospeak_default_options() /
 *                           neccospeak_audit_mask_value() / neccospeak_sanitize_custom_css() /
 *                           NECCOSPEAK_OPTION / NECCOSPEAK_OPTION_DRAFT / NECCOSPEAK_SETTINGS_SLUG
 *
 * All PHP output in this file follows the same escaping convention as
 * includes/admin-tabs.php (design rev.2 §9.7): esc_html()/esc_html__() for
 * text nodes, esc_attr()/esc_attr__() for attribute values, esc_url() for
 * links -- the one exception is wp_text_diff()'s return value (WP core
 * pre-escaped markup), passed through unescaped by design.
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/*
 * Guarded defines: this file may be loaded standalone (e.g. its own test
 * suite) before includes/settings.php has run, so it cannot assume these
 * constants already exist. Values must never drift from settings.php's own
 * definitions (design phase5 §4.6 / §5.1) -- if( ! defined() ) makes whichever
 * file loads first win, harmlessly, in production.
 */
if ( ! defined( 'NECCOSPEAK_OPTION' ) ) {
	define( 'NECCOSPEAK_OPTION', 'neccospeak_options' );
}
if ( ! defined( 'NECCOSPEAK_OPTION_DRAFT' ) ) {
	define( 'NECCOSPEAK_OPTION_DRAFT', 'neccospeak_options_draft' );
}
if ( ! defined( 'NECCOSPEAK_SETTINGS_SLUG' ) ) {
	define( 'NECCOSPEAK_SETTINGS_SLUG', 'neccospeak' );
}

/* -------------------------------------------------------------------------
 * Small helpers.
 * ---------------------------------------------------------------------- */

/**
 * The published option row, with the same fallback shape
 * neccospeak_get_options() guarantees, usable even if settings.php has not
 * loaded (defensive -- production always has it loaded by the time this file
 * runs, per design phase5 §1.1's require order).
 *
 * @return array
 */
function neccospeak_versions_published_options() {
	if ( function_exists( 'neccospeak_get_options' ) ) {
		return neccospeak_get_options();
	}
	$stored = get_option( NECCOSPEAK_OPTION, array() );
	return is_array( $stored ) ? $stored : array();
}

/**
 * The draft option row (design phase5 §5.1), falling back to the published
 * value when settings.php's real accessor is not available.
 *
 * @return array
 */
function neccospeak_versions_draft_options() {
	if ( function_exists( 'neccospeak_get_draft_options' ) ) {
		return neccospeak_get_draft_options();
	}
	$published = neccospeak_versions_published_options();
	$stored    = get_option( NECCOSPEAK_OPTION_DRAFT, null );
	return is_array( $stored ) ? $stored : $published;
}

/**
 * @return int
 */
function neccospeak_versions_current_user_id() {
	return function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0;
}

/**
 * @return string
 */
function neccospeak_versions_current_user_login() {
	if ( function_exists( 'wp_get_current_user' ) ) {
		$user = wp_get_current_user();
		if ( is_object( $user ) && isset( $user->user_login ) ) {
			return (string) $user->user_login;
		}
	}
	return '';
}

/**
 * Keys that differ between two option arrays (design phase5 §5.6/§9.4):
 * prefers settings.php's real neccospeak_draft_diff_keys() (which already
 * limits comparison to neccospeak_default_options() keys and excludes
 * 'openai_api_key' -- design phase5 §5.1), falling back to a local
 * reimplementation of the same rule when that is not loaded.
 *
 * @param array $a
 * @param array $b
 * @return string[]
 */
function neccospeak_versions_diff_keys( array $a, array $b ) {
	if ( function_exists( 'neccospeak_draft_diff_keys' ) ) {
		return neccospeak_draft_diff_keys( $a, $b );
	}
	$known = function_exists( 'neccospeak_default_options' )
		? array_keys( neccospeak_default_options() )
		: array_unique( array_merge( array_keys( $a ), array_keys( $b ) ) );

	$diff = array();
	foreach ( $known as $key ) {
		if ( 'openai_api_key' === $key ) {
			continue;
		}
		$av = array_key_exists( $key, $a ) ? $a[ $key ] : null;
		$bv = array_key_exists( $key, $b ) ? $b[ $key ] : null;
		if ( $av !== $bv ) {
			$diff[] = $key;
		}
	}
	sort( $diff );
	return $diff;
}

/**
 * Mask a single option value for display/audit (design phase5 §5.7): prefers
 * settings.php's real neccospeak_audit_mask_value(), falling back to a local
 * copy of the same rules kept in sync with it.
 *
 * @param string $key
 * @param mixed  $value
 * @return mixed
 */
function neccospeak_versions_mask_value( $key, $value ) {
	if ( function_exists( 'neccospeak_audit_mask_value' ) ) {
		return neccospeak_audit_mask_value( $key, $value );
	}
	if ( 'openai_api_key' === $key ) {
		return '***';
	}
	if ( 'custom_css' === $key ) {
		/* translators: %d: size of the custom CSS value, in bytes. */
		return sprintf( __( '(CSS: %d bytes)', 'neccospeak' ), strlen( (string) $value ) );
	}
	if ( is_array( $value ) ) {
		$json = function_exists( 'wp_json_encode' ) ? wp_json_encode( $value ) : json_encode( $value ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- wp_json_encode() is tried first; this is only the test-bootstrap fallback.
		$json = is_string( $json ) ? $json : '';
		return ( strlen( $json ) > 500 ) ? ( substr( $json, 0, 500 ) . '…' ) : $json;
	}
	if ( is_string( $value ) && strlen( $value ) > 500 ) {
		return substr( $value, 0, 500 ) . '…';
	}
	return $value;
}

/**
 * Normalize an option array into "key = value" lines, one key per line,
 * sorted for determinism, for wp_text_diff() to compare (design phase5
 * §9.4). Every value goes through neccospeak_versions_mask_value() first, so
 * a diff can never leak the API key or raw custom CSS.
 *
 * @param array $opts
 * @return string
 */
function neccospeak_versions_diff_text( array $opts ) {
	$keys = array_keys( $opts );
	sort( $keys );
	$lines = array();
	foreach ( $keys as $key ) {
		$lines[] = $key . ' = ' . neccospeak_versions_mask_value( $key, $opts[ $key ] );
	}
	return implode( "\n", $lines );
}

/**
 * Human label for a versions.origin value (design phase5 §2.4).
 *
 * @param string $origin
 * @return string
 */
function neccospeak_versions_origin_label( $origin ) {
	$labels = array(
		'save'     => __( 'Save', 'neccospeak' ),
		'publish'  => __( 'Publish', 'neccospeak' ),
		'rollback' => __( 'Rollback', 'neccospeak' ),
		'discard'  => __( 'Discard', 'neccospeak' ),
	);
	return isset( $labels[ $origin ] ) ? $labels[ $origin ] : $origin;
}

/**
 * The publish/rollback merge rules (design phase5 §5.6). $source is the
 * incoming value (the draft, for a publish; a past snapshot, for a
 * rollback); $published is the current live options row.
 *
 * @param array $source    Draft options or a decoded version snapshot.
 * @param array $published neccospeak_get_options().
 * @return array Merged option array, ready for update_option( NECCOSPEAK_OPTION, … ).
 */
function neccospeak_versions_merge_options( array $source, array $published ) {
	// Fill any keys a stale snapshot might be missing with current defaults,
	// before rule 4 below drops anything that is NOT a current default key
	// (this only ever adds, never removes).
	if ( function_exists( 'neccospeak_default_options' ) ) {
		$source = array_merge( neccospeak_default_options(), $source );
	}

	$merged = $source;

	// Rule 1: the API key is never taken from a draft/snapshot -- always
	// whatever is currently live. This is the read side of the invariant;
	// the paired write side (the only path that can actually change the
	// published key) is neccospeak_divert_option_save() (design phase5
	// §5.3(4) / §5.4(b), includes/settings.php).
	$merged['openai_api_key'] = isset( $published['openai_api_key'] ) ? $published['openai_api_key'] : '';

	// Rule 2: custom_css requires the unfiltered_html capability to accept a
	// new value; otherwise the currently published CSS is kept untouched.
	$current_css = isset( $published['custom_css'] ) ? (string) $published['custom_css'] : '';
	if ( current_user_can( 'unfiltered_html' ) ) {
		$src_css              = isset( $source['custom_css'] ) ? $source['custom_css'] : $current_css;
		$merged['custom_css'] = function_exists( 'neccospeak_sanitize_custom_css' )
			? neccospeak_sanitize_custom_css( $src_css, $current_css )
			: ( is_string( $src_css ) ? $src_css : $current_css );
	} else {
		$merged['custom_css'] = $current_css;
	}

	// Rule 3: the draft-workflow on/off switch is a management-mode setting,
	// not a publishable one -- a publish/rollback must never change it
	// (design phase5 §5.4(a)).
	if ( array_key_exists( 'draft_workflow_enabled', $published ) ) {
		$merged['draft_workflow_enabled'] = $published['draft_workflow_enabled'];
	}

	// Rule 4: drop any key that is not a currently-recognized option (a stale
	// key left over in an old snapshot from a since-removed setting).
	if ( function_exists( 'neccospeak_default_options' ) ) {
		$known = array_keys( neccospeak_default_options() );
		foreach ( array_keys( $merged ) as $key ) {
			if ( ! in_array( $key, $known, true ) ) {
				unset( $merged[ $key ] );
			}
		}
	}

	return $merged;
}

/**
 * Write the merged result to the published option row (design phase5 §5.6
 * rule 5). No '_tab' key is present, so neccospeak_divert_option_save()
 * (design phase5 §5.3, T8 branch) passes this straight through untouched --
 * a publish/rollback is never diverted into the draft row.
 *
 * The snapshot listener below is temporarily unhooked around this call: this
 * write already gets its own explicit, correctly-originated
 * neccospeak_db_add_version() call from neccospeak_versions_publish() /
 * neccospeak_versions_rollback() themselves (origin='publish'/'rollback'),
 * so leaving the generic 'save' listener attached here would double the
 * version history row for every publish/rollback (same recursion hazard as
 * neccospeak_sync_draft_from_published(), design phase5 §5.3/§5.7).
 *
 * @param array $value
 * @return void
 */
function neccospeak_versions_write_published( array $value ) {
	remove_filter( 'updated_option', 'neccospeak_versions_snapshot_listener', 20 );
	update_option( NECCOSPEAK_OPTION, $value );
	add_action( 'updated_option', 'neccospeak_versions_snapshot_listener', 20, 3 );
}

/**
 * Record a masked [{k,f,t}, …] change list for the audit log (design phase5
 * §2.4), for the given key list, comparing $before/$after.
 *
 * @param string[] $keys
 * @param array    $before
 * @param array    $after
 * @return array<int,array{k:string,f:mixed,t:mixed}>
 */
function neccospeak_versions_change_list( array $keys, array $before, array $after ) {
	$changes = array();
	foreach ( $keys as $key ) {
		$changes[] = array(
			'k' => (string) $key,
			'f' => neccospeak_versions_mask_value( $key, array_key_exists( $key, $before ) ? $before[ $key ] : null ),
			't' => neccospeak_versions_mask_value( $key, array_key_exists( $key, $after ) ? $after[ $key ] : null ),
		);
	}
	return $changes;
}

/* -------------------------------------------------------------------------
 * Automatic snapshot recording (design phase5 §1.1/§9.4: "スナップショット記録
 * (updated_option リスナ)"). Handles the "plain save" cases -- design phase5
 * §5.2 T2 (workflow off, ordinary form save), T8 (WP-CLI/programmatic
 * update_option()), and T10 (the OFF→ON save) -- all of which write the
 * published row directly without going through neccospeak_versions_publish()
 * or neccospeak_versions_rollback() below (those already record their own
 * origin='publish'/'rollback' version explicitly). T3/T9 (workflow on) never
 * reach here because the published row is not written at all in that branch
 * (design phase5 §5.3); T9's own discard-preservation snapshot is recorded
 * inline by neccospeak_warn_and_preserve_draft() (includes/settings.php),
 * not here, to keep that one atomic with the save that triggers it.
 * ---------------------------------------------------------------------- */

/**
 * @param string $option
 * @param mixed  $old_value
 * @param mixed  $value
 * @return void
 */
function neccospeak_versions_snapshot_listener( $option, $old_value, $value ) {
	if ( NECCOSPEAK_OPTION !== $option ) {
		return;
	}
	// design phase5 §5.2 T8: only an admin-context save gets a version row --
	// keeps unattended/CLI/cron writes from silently filling up the (30-row)
	// version history.
	if ( ! is_admin() ) {
		return;
	}
	if ( ! is_array( $old_value ) || ! is_array( $value ) ) {
		return;
	}
	if ( ! function_exists( 'neccospeak_db_add_version' ) ) {
		return;
	}
	$diff = neccospeak_versions_diff_keys( $old_value, $value );
	if ( empty( $diff ) ) {
		return;
	}
	$snapshot = $value;
	unset( $snapshot['openai_api_key'] ); // design phase5 §2.4 -- defense in depth; neccospeak_db_add_version() also strips this.

	neccospeak_db_add_version(
		array(
			'user_id'      => neccospeak_versions_current_user_id(),
			'user_login'   => neccospeak_versions_current_user_login(),
			'changed_keys' => $diff,
			'snapshot'     => $snapshot,
			'origin'       => 'save',
			'note'         => '',
		)
	);
}
add_action( 'updated_option', 'neccospeak_versions_snapshot_listener', 20, 3 );

/* -------------------------------------------------------------------------
 * Core actions (design phase5 §1.3 public contract).
 * ---------------------------------------------------------------------- */

/**
 * "Publish" (design phase5 §5.2 T4): P := merge(D). No-op (returns 0) when
 * the draft already matches the published options.
 *
 * @return int The new version's id, or 0 (nothing to publish / not ready).
 */
function neccospeak_versions_publish() {
	if ( ! function_exists( 'neccospeak_db_add_version' ) ) {
		return 0;
	}

	$published = neccospeak_versions_published_options();
	$draft     = neccospeak_versions_draft_options();

	$diff = neccospeak_versions_diff_keys( $published, $draft );
	if ( empty( $diff ) ) {
		return 0;
	}

	$merged = neccospeak_versions_merge_options( $draft, $published );

	neccospeak_versions_write_published( $merged );
	// Design phase5 §5.2 T4: "D 変化なし (結果的に D=P)" -- deliberately no
	// explicit draft sync here. $merged already equals $draft except for the
	// 3 rule-1/2/3 exceptions above; any of THOSE that differ from $draft
	// (e.g. a CSS change rejected for lacking unfiltered_html) is meant to
	// keep showing as a pending draft difference after this publish.

	$version_id = neccospeak_db_add_version(
		array(
			'user_id'      => neccospeak_versions_current_user_id(),
			'user_login'   => neccospeak_versions_current_user_login(),
			'changed_keys' => $diff,
			'snapshot'     => $merged,
			'origin'       => 'publish',
			'note'         => '',
		)
	);

	if ( $version_id > 0 && function_exists( 'neccospeak_db_log_audit' ) ) {
		neccospeak_db_log_audit(
			array(
				'name'    => 'publish',
				'subject' => NECCOSPEAK_OPTION,
				'user_id' => neccospeak_versions_current_user_id(),
				'changes' => neccospeak_versions_change_list( $diff, $published, $merged ),
				'ref_id'  => $version_id,
			)
		);
	}

	return $version_id;
}

/**
 * "Discard the draft" (design phase5 §5.2 T5): D := P. No-op when the draft
 * workflow is not active (there is nothing meaningful to discard).
 *
 * Before overwriting the draft, records a version row (origin='discard') with
 * the about-to-be-abandoned draft as its snapshot, mirroring
 * neccospeak_warn_and_preserve_draft() (includes/settings.php, T9's "draft
 * workflow disabled" path) — this is what makes the Discard confirmation
 * dialog's "A restorable snapshot is kept in the version history." true. It
 * previously recorded only an empty-changes audit row and never called
 * neccospeak_db_add_version() at all, so the draft's unpublished changes were
 * gone with no way back despite that claim (review: "high" — a false safety
 * claim immediately before an irreversible action).
 *
 * @return bool
 */
function neccospeak_versions_discard() {
	if ( function_exists( 'neccospeak_draft_workflow_active' ) && ! neccospeak_draft_workflow_active() ) {
		return false;
	}

	$published = neccospeak_versions_published_options();
	$draft     = neccospeak_versions_draft_options();
	$diff      = neccospeak_versions_diff_keys( $published, $draft );

	if ( ! empty( $diff ) && function_exists( 'neccospeak_db_add_version' ) ) {
		$snapshot = $draft;
		unset( $snapshot['openai_api_key'] ); // design phase5 §2.4/§5.6 rule 1 -- never persist a key in a snapshot.
		neccospeak_db_add_version(
			array(
				'user_id'      => neccospeak_versions_current_user_id(),
				'user_login'   => neccospeak_versions_current_user_login(),
				'changed_keys' => $diff,
				'snapshot'     => $snapshot,
				'origin'       => 'discard',
				'note'         => 'discarded from the Versions tab',
			)
		);
	}

	if ( function_exists( 'neccospeak_sync_draft_from_published' ) ) {
		neccospeak_sync_draft_from_published( $published );
	} else {
		update_option( NECCOSPEAK_OPTION_DRAFT, $published, false );
	}

	if ( function_exists( 'neccospeak_db_log_audit' ) ) {
		neccospeak_db_log_audit(
			array(
				'name'    => 'discard',
				'subject' => NECCOSPEAK_OPTION_DRAFT,
				'user_id' => neccospeak_versions_current_user_id(),
				'changes' => array(),
			)
		);
	}

	return true;
}

/**
 * "Roll back to version #$id" (design phase5 §5.2 T6): P := merge(snapshot),
 * D := P, and a new version row (origin='rollback') is recorded so the
 * rollback itself can be undone.
 *
 * @param int $id versions.id to restore.
 * @return int The newly-recorded version's id, or 0 (not found / not ready).
 */
function neccospeak_versions_rollback( $id ) {
	$id = (int) $id;
	if ( $id <= 0 || ! function_exists( 'neccospeak_db_get_version' ) || ! function_exists( 'neccospeak_db_add_version' ) ) {
		return 0;
	}

	$version = neccospeak_db_get_version( $id );
	if ( ! is_array( $version ) || ! isset( $version['snapshot'] ) || ! is_array( $version['snapshot'] ) ) {
		return 0;
	}

	$published = neccospeak_versions_published_options();
	$merged    = neccospeak_versions_merge_options( $version['snapshot'], $published );
	$diff      = neccospeak_versions_diff_keys( $published, $merged );

	neccospeak_versions_write_published( $merged );

	// Unlike a publish, a rollback always fast-forwards the draft too
	// (design phase5 §5.2 T6's "D := P"), so the public bar does not
	// immediately reappear showing the very change that was just rolled back
	// as a "pending" draft difference.
	if ( function_exists( 'neccospeak_sync_draft_from_published' ) ) {
		neccospeak_sync_draft_from_published( $merged );
	} else {
		update_option( NECCOSPEAK_OPTION_DRAFT, $merged, false );
	}

	$new_id = neccospeak_db_add_version(
		array(
			'user_id'      => neccospeak_versions_current_user_id(),
			'user_login'   => neccospeak_versions_current_user_login(),
			'changed_keys' => $diff,
			'snapshot'     => $merged,
			'origin'       => 'rollback',
			'note'         => '#' . $id,
		)
	);

	if ( $new_id > 0 && function_exists( 'neccospeak_db_log_audit' ) ) {
		neccospeak_db_log_audit(
			array(
				'name'    => 'rollback',
				'subject' => NECCOSPEAK_OPTION,
				'user_id' => neccospeak_versions_current_user_id(),
				'changes' => neccospeak_versions_change_list( $diff, $published, $merged ),
				'ref_id'  => $id,
			)
		);
	}

	return $new_id;
}

/* -------------------------------------------------------------------------
 * admin-post.php handlers (design phase5 §9.4: "すべて nonce + manage_options").
 * Registered unconditionally at file scope (see this file's own docblock) so
 * they fire on an admin-post.php request that never renders the settings
 * page.
 * ---------------------------------------------------------------------- */

/**
 * Redirect back to the versions sub-view with a result notice, mirroring the
 * ?neccospeak_notice= convention includes/admin-diagnostics.php established
 * (design phase5 §9.4's "既存 neccospeak_render_notice_query_param() の仕組みに
 * 乗る" -- this file cannot add entries to that function's own message table,
 * which is includes/admin-diagnostics.php's (impl-admin's), so it renders its
 * own small notice block instead, reading the same query param under its own,
 * distinctly-prefixed set of notice codes -- see neccospeak_versions_render_notice()).
 *
 * @param string $notice
 * @return void
 */
function neccospeak_versions_redirect( $notice ) {
	// 0.3.0-dev.36: the sticky change-summary bar (agent-ui.php) posts a
	// `neccospeak_return_tab` so publish/discard land back on the tab the
	// admin was on. sanitize_key() only — an unknown slug is harmless because
	// neccospeak_agent_ui_render_page() falls back to 'dashboard' for any
	// tab it does not recognize. Absent (legacy forms), the historical
	// 'logs' target is kept, which the agent-ui shell also resolves to the
	// dashboard tab.
	$return_tab = isset( $_POST['neccospeak_return_tab'] ) ? sanitize_key( wp_unslash( $_POST['neccospeak_return_tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- every caller runs check_admin_referer() before reaching this redirect helper.
	$args       = array(
		'page'              => NECCOSPEAK_SETTINGS_SLUG,
		'tab'               => ( '' !== $return_tab ) ? $return_tab : 'logs',
		'neccospeak_notice' => sanitize_key( $notice ),
	);
	if ( '' === $return_tab ) {
		$args['view'] = 'versions';
	}
	wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
	exit;
}

/**
 * @return void
 */
function neccospeak_handle_versions_publish() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_versions_publish' );

	$id = neccospeak_versions_publish();
	neccospeak_versions_redirect( $id > 0 ? 'neccospeak_published' : 'neccospeak_publish_noop' );
}
add_action( 'admin_post_neccospeak_publish', 'neccospeak_handle_versions_publish' );

/**
 * @return void
 */
function neccospeak_handle_versions_discard() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_versions_discard' );

	$ok = neccospeak_versions_discard();
	neccospeak_versions_redirect( $ok ? 'neccospeak_discarded' : 'neccospeak_discard_noop' );
}
add_action( 'admin_post_neccospeak_discard', 'neccospeak_handle_versions_discard' );

/**
 * @return void
 */
function neccospeak_handle_versions_rollback() {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'Forbidden.', 'neccospeak' ), '', array( 'response' => 403 ) );
	}
	check_admin_referer( 'neccospeak_versions_rollback' );

	$id = isset( $_POST['version_id'] ) ? (int) $_POST['version_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified by check_admin_referer() just above.
	$new_id = $id > 0 ? neccospeak_versions_rollback( $id ) : 0;
	neccospeak_versions_redirect( $new_id > 0 ? 'neccospeak_rolled_back' : 'neccospeak_rollback_failed' );
}
add_action( 'admin_post_neccospeak_rollback', 'neccospeak_handle_versions_rollback' );

/* -------------------------------------------------------------------------
 * UI (design phase5 §9.4).
 * ---------------------------------------------------------------------- */

/**
 * Render the small notice block for this file's own admin_post_* redirects
 * (see neccospeak_versions_redirect()'s docblock for why this does not reuse
 * includes/admin-diagnostics.php's neccospeak_render_notice_query_param()).
 *
 * @return void
 */
function neccospeak_versions_render_notice() {
	if ( ! isset( $_GET['neccospeak_notice'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a redirect result; the state change already happened (and was nonce-checked) in the admin_post_* handler above.
		return;
	}
	$notice = sanitize_key( wp_unslash( $_GET['neccospeak_notice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.

	$messages = array(
		'neccospeak_published'       => array( 'success', __( 'The draft was published: visitors now see these settings.', 'neccospeak' ) ),
		'neccospeak_publish_noop'    => array( 'info', __( 'There was nothing to publish -- the draft already matches the published settings.', 'neccospeak' ) ),
		'neccospeak_discarded'       => array( 'success', __( 'The draft was reset to match the currently published settings.', 'neccospeak' ) ),
		'neccospeak_discard_noop'    => array( 'info', __( 'The draft workflow is off, so there was nothing to discard.', 'neccospeak' ) ),
		'neccospeak_rolled_back'     => array( 'success', __( 'Settings were rolled back to the selected version. A new version was recorded so this can be undone.', 'neccospeak' ) ),
		'neccospeak_rollback_failed' => array( 'error', __( 'Could not roll back to that version -- it may have been pruned.', 'neccospeak' ) ),
	);
	if ( ! isset( $messages[ $notice ] ) ) {
		return;
	}
	list( $type, $text ) = $messages[ $notice ];
	echo '<div class="notice notice-' . esc_attr( $type ) . ' is-dismissible"><p>' . esc_html( $text ) . '</p></div>';
}

/**
 * Render one wp_text_diff() comparison, with a plain-text fallback for
 * environments where wp_text_diff() (wp-admin/includes/revision.php) is not
 * available (design phase5 §9.4's function_exists() guard -- primarily this
 * file's own standalone test suite).
 *
 * @param string $left_text
 * @param string $right_text
 * @param string $title_left
 * @param string $title_right
 * @return void
 */
function neccospeak_versions_render_diff_table( $left_text, $right_text, $title_left, $title_right ) {
	if ( ! function_exists( 'wp_text_diff' ) && defined( 'ABSPATH' ) && file_exists( ABSPATH . 'wp-admin/includes/revision.php' ) ) {
		require_once ABSPATH . 'wp-admin/includes/revision.php';
	}
	if ( function_exists( 'wp_text_diff' ) ) {
		$diff = wp_text_diff(
			$left_text,
			$right_text,
			array(
				'title_left'  => $title_left,
				'title_right' => $title_right,
			)
		);
		if ( $diff ) {
			echo $diff; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_text_diff() (WP core) returns pre-escaped markup.
			return;
		}
		echo '<p class="description">' . esc_html__( 'No differences.', 'neccospeak' ) . '</p>';
		return;
	}
	echo '<div class="neccospeak-versions-diff-fallback">';
	echo '<div><strong>' . esc_html( $title_left ) . '</strong><pre class="neccospeak-log-pre">' . esc_html( $left_text ) . '</pre></div>';
	echo '<div><strong>' . esc_html( $title_right ) . '</strong><pre class="neccospeak-log-pre">' . esc_html( $right_text ) . '</pre></div>';
	echo '</div>';
}

/**
 * The G2 "unpublished changes" public bar (design phase5 §5.5(a)/§9.4).
 * Rendered from neccospeak_render_settings_page() right above the tab nav,
 * on every tab, whenever the draft workflow is on AND the draft actually
 * differs from the published options.
 *
 * @param array $published neccospeak_get_options().
 * @param array $draft     neccospeak_get_draft_options().
 * @return void
 */
function neccospeak_render_publish_bar( $published, $draft ) {
	$published = is_array( $published ) ? $published : array();
	$draft     = is_array( $draft ) ? $draft : $published;

	// Deliberately read the switch off the $published parameter itself,
	// not a fresh neccospeak_draft_workflow_active() call: the caller
	// (neccospeak_render_settings_page(), design phase5 §5.5(a)) always
	// passes the live neccospeak_get_options() here, so the two agree in
	// production, but going through the parameter keeps this function
	// pure/testable and avoids a second, redundant option read.
	if ( empty( $published['draft_workflow_enabled'] ) ) {
		return;
	}

	$diff = neccospeak_versions_diff_keys( $published, $draft );
	if ( empty( $diff ) ) {
		return;
	}

	$review_url = add_query_arg(
		array(
			'page' => NECCOSPEAK_SETTINGS_SLUG,
			'tab'  => 'logs',
			'view' => 'versions',
		),
		admin_url( 'admin.php' )
	) . '#draft-diff';

	$shown     = array_slice( $diff, 0, 5 );
	$remaining = count( $diff ) - count( $shown );

	echo '<div class="notice notice-warning neccospeak-publish-bar">';
	echo '<p><strong>';
	printf(
		esc_html(
			/* translators: %d: number of unpublished setting changes. */
			_n(
				'The draft has %d unpublished change:',
				'The draft has %d unpublished changes:',
				count( $diff ),
				'neccospeak'
			)
		),
		count( $diff )
	);
	echo '</strong> ';
	echo esc_html( implode( ', ', $shown ) );
	if ( $remaining > 0 ) {
		echo esc_html(
			sprintf(
				/* translators: %d: number of additional changed setting keys not individually listed. */
				_n( ', and %d more.', ', and %d more.', $remaining, 'neccospeak' ),
				$remaining
			)
		);
	}
	echo '</p>';
	echo '<p class="description">' . esc_html__( 'The live preview and the Prompt preview reflect your draft, not what visitors currently see.', 'neccospeak' ) . '</p>';

	echo '<p>';
	echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline-block;margin-right:6px;">';
	echo '<input type="hidden" name="action" value="neccospeak_publish" />';
	wp_nonce_field( 'neccospeak_versions_publish' );
	echo '<button type="submit" class="button button-primary">' . esc_html__( 'Publish', 'neccospeak' ) . '</button>';
	echo '</form>';

	echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline-block;margin-right:6px;" data-neccospeak-confirm="' . esc_attr__( 'Discard the draft? Its unpublished changes will be replaced with the currently published settings. A restorable snapshot is kept in the version history.', 'neccospeak' ) . '">';
	echo '<input type="hidden" name="action" value="neccospeak_discard" />';
	wp_nonce_field( 'neccospeak_versions_discard' );
	echo '<button type="submit" class="button">' . esc_html__( 'Discard', 'neccospeak' ) . '</button>';
	echo '</form>';

	echo '<a class="button" href="' . esc_url( $review_url ) . '">' . esc_html__( 'Review changes', 'neccospeak' ) . '</a>';
	echo '</p>';
	echo '</div>';
}

/**
 * The Logs tab's "Versions" sub-view (design phase5 §0.3 D3 / §9.1 / §9.4):
 * the draft-vs-published diff (anchored '#draft-diff', the publish bar's
 * "Review changes" target) plus the version history table with per-row
 * [View diff] / [Roll back]. Called by impl-viewers' includes/admin-logs.php
 * when ?tab=logs&view=versions, guarded with function_exists() there.
 *
 * @return void
 */
function neccospeak_render_versions_view() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	neccospeak_versions_render_notice();

	if ( ! function_exists( 'neccospeak_db_ready' ) || ! neccospeak_db_ready() ) {
		echo '<p>' . esc_html__( "This site's data tables have not been created yet, so nothing is being recorded.", 'neccospeak' ) . '</p>';
		return;
	}

	$published = neccospeak_versions_published_options();
	$draft     = neccospeak_versions_draft_options();
	$active    = function_exists( 'neccospeak_draft_workflow_active' )
		? neccospeak_draft_workflow_active()
		: ! empty( $published['draft_workflow_enabled'] );

	echo '<h2 id="draft-diff">' . esc_html__( 'Draft vs. published', 'neccospeak' ) . '</h2>';
	if ( ! $active ) {
		echo '<p class="description">' . esc_html__( 'The draft workflow is currently off: every save goes live immediately, so there is nothing pending to compare.', 'neccospeak' ) . '</p>';
	} else {
		$diff_keys = neccospeak_versions_diff_keys( $published, $draft );
		if ( empty( $diff_keys ) ) {
			echo '<p class="description">' . esc_html__( 'The draft matches the published settings -- nothing to publish.', 'neccospeak' ) . '</p>';
		} else {
			neccospeak_versions_render_diff_table(
				neccospeak_versions_diff_text( $published ),
				neccospeak_versions_diff_text( $draft ),
				__( 'Published', 'neccospeak' ),
				__( 'Draft', 'neccospeak' )
			);
			echo '<p>';
			echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline-block;margin-right:6px;">';
			echo '<input type="hidden" name="action" value="neccospeak_publish" />';
			wp_nonce_field( 'neccospeak_versions_publish' );
			echo '<button type="submit" class="button button-primary">' . esc_html__( 'Publish', 'neccospeak' ) . '</button>';
			echo '</form>';
			echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline-block;" data-neccospeak-confirm="' . esc_attr__( 'Discard the draft? Its unpublished changes will be replaced with the currently published settings. A restorable snapshot is kept in the version history.', 'neccospeak' ) . '">';
			echo '<input type="hidden" name="action" value="neccospeak_discard" />';
			wp_nonce_field( 'neccospeak_versions_discard' );
			echo '<button type="submit" class="button">' . esc_html__( 'Discard', 'neccospeak' ) . '</button>';
			echo '</form>';
			echo '</p>';
		}
	}

	echo '<h2 id="versions">' . esc_html__( 'Version history', 'neccospeak' ) . '</h2>';
	echo '<p class="description">' . esc_html__( 'The last 30 versions are kept. Every publish, rollback, and (while the draft workflow is off) every save creates one.', 'neccospeak' ) . '</p>';

	$page     = isset( $_GET['vp'] ) ? max( 1, (int) $_GET['vp'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only paging cursor, not a state change.
	$per_page = 20;
	$result   = neccospeak_db_get_versions(
		array(
			'page'          => $page,
			'per_page'      => $per_page,
			'with_snapshot' => true,
		)
	);
	$rows  = array_values( is_array( $result['rows'] ) ? $result['rows'] : array() );
	$total = isset( $result['total'] ) ? (int) $result['total'] : 0;

	if ( empty( $rows ) ) {
		echo '<p>' . esc_html__( 'No versions recorded yet.', 'neccospeak' ) . '</p>';
		return;
	}

	echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
	echo '<th>' . esc_html__( 'Date', 'neccospeak' ) . '</th>';
	echo '<th>' . esc_html__( 'User', 'neccospeak' ) . '</th>';
	echo '<th>' . esc_html__( 'Origin', 'neccospeak' ) . '</th>';
	echo '<th>' . esc_html__( 'Changed keys', 'neccospeak' ) . '</th>';
	echo '<th>' . esc_html__( 'Actions', 'neccospeak' ) . '</th>';
	echo '</tr></thead><tbody>';

	foreach ( $rows as $i => $raw_row ) {
		$row     = (array) $raw_row;
		$id      = isset( $row['id'] ) ? (int) $row['id'] : 0;
		$created = isset( $row['created_at'] ) ? (string) $row['created_at'] : '';

		$when = $created;
		if ( '' !== $created && function_exists( 'wp_date' ) ) {
			$ts = strtotime( $created . ' UTC' );
			if ( $ts ) {
				$format = trim( get_option( 'date_format', 'Y-m-d' ) . ' ' . get_option( 'time_format', 'H:i' ) );
				$when   = wp_date( '' !== $format ? $format : 'Y-m-d H:i', $ts );
			}
		}

		$user_login = isset( $row['user_login'] ) && '' !== $row['user_login'] ? (string) $row['user_login'] : __( '(unknown)', 'neccospeak' );
		$origin     = isset( $row['origin'] ) ? (string) $row['origin'] : 'save';

		$changed_keys = array();
		if ( isset( $row['changed_keys'] ) && is_string( $row['changed_keys'] ) && '' !== $row['changed_keys'] ) {
			$decoded = json_decode( $row['changed_keys'], true );
			if ( is_array( $decoded ) ) {
				$changed_keys = $decoded;
			}
		}
		$changed_count = isset( $row['changed_count'] ) ? (int) $row['changed_count'] : count( $changed_keys );
		$shown_keys    = array_slice( $changed_keys, 0, 5 );

		echo '<tr>';
		echo '<td>' . esc_html( $when ) . '</td>';
		echo '<td>' . esc_html( $user_login ) . '</td>';
		echo '<td>' . esc_html( neccospeak_versions_origin_label( $origin ) ) . '</td>';
		echo '<td>' . esc_html(
			sprintf(
				/* translators: 1: number of changed setting keys, 2: comma-separated list of up to 5 of their names. */
				_n( '%1$d key (%2$s)', '%1$d keys (%2$s)', max( 1, $changed_count ), 'neccospeak' ),
				$changed_count,
				implode( ', ', $shown_keys )
			)
		) . '</td>';
		echo '<td>';

		if ( isset( $row['snapshot'] ) && is_array( $row['snapshot'] ) ) {
			$older_snapshot = null;
			if ( isset( $rows[ $i + 1 ] ) ) {
				$older_row = (array) $rows[ $i + 1 ];
				if ( isset( $older_row['snapshot'] ) && is_array( $older_row['snapshot'] ) ) {
					$older_snapshot = $older_row['snapshot'];
				}
			}
			echo '<details><summary>' . esc_html__( 'View diff', 'neccospeak' ) . '</summary>';
			neccospeak_versions_render_diff_table(
				neccospeak_versions_diff_text( is_array( $older_snapshot ) ? $older_snapshot : array() ),
				neccospeak_versions_diff_text( $row['snapshot'] ),
				null === $older_snapshot ? __( '(none)', 'neccospeak' ) : __( 'Before', 'neccospeak' ),
				__( 'This version', 'neccospeak' )
			);
			echo '</details> ';
		}

		echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline-block;" data-neccospeak-confirm="' . esc_attr__( 'Roll back to this version? Your current published settings will be replaced (except the API key).', 'neccospeak' ) . '">';
		echo '<input type="hidden" name="action" value="neccospeak_rollback" />';
		echo '<input type="hidden" name="version_id" value="' . esc_attr( (string) $id ) . '" />';
		wp_nonce_field( 'neccospeak_versions_rollback' );
		echo '<button type="submit" class="button">' . esc_html__( 'Roll back', 'neccospeak' ) . '</button>';
		echo '</form>';

		echo '</td></tr>';
	}
	echo '</tbody></table>';

	$last_page = max( 1, (int) ceil( $total / $per_page ) );
	if ( $last_page > 1 ) {
		$base_args = array(
			'page' => NECCOSPEAK_SETTINGS_SLUG,
			'tab'  => 'logs',
			'view' => 'versions',
		);
		echo '<p class="neccospeak-versions-pager">';
		if ( $page > 1 ) {
			$prev_url = add_query_arg( array_merge( $base_args, array( 'vp' => $page - 1 ) ), admin_url( 'admin.php' ) ) . '#versions';
			echo '<a class="button" href="' . esc_url( $prev_url ) . '">' . esc_html__( '« Newer', 'neccospeak' ) . '</a> ';
		}
		echo esc_html(
			sprintf(
				/* translators: 1: current page number, 2: total page count. */
				__( 'Page %1$d of %2$d', 'neccospeak' ),
				$page,
				$last_page
			)
		);
		if ( $page < $last_page ) {
			$next_url = add_query_arg( array_merge( $base_args, array( 'vp' => $page + 1 ) ), admin_url( 'admin.php' ) ) . '#versions';
			echo ' <a class="button" href="' . esc_url( $next_url ) . '">' . esc_html__( 'Older »', 'neccospeak' ) . '</a>';
		}
		echo '</p>';
	}
}
