<?php
/**
 * Admin screen: menu, assets, scan AJAX endpoints. The full three-tab
 * dashboard (Search / Social / Issues list tables) is the next build chunk;
 * the current view is a summary header, scan controls, and a basic results
 * table that exercises the whole pipeline.
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Admin {

	const PAGE_SLUG       = 'search-social-appearance-audit';
	const EXCLUSIONS_SLUG = 'ssaa-exclusions';
	const NONCE_NAME      = 'ssaa_scan';

	/** @var string[] Hook suffixes of every screen this plugin registers. */
	private static $page_hooks = array();

	public static function init() {
		add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
		add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
		add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );

		add_action( 'wp_ajax_ssaa_start_scan', array( __CLASS__, 'ajax_start_scan' ) );
		add_action( 'wp_ajax_ssaa_scan_batch', array( __CLASS__, 'ajax_scan_batch' ) );
		add_action( 'wp_ajax_ssaa_cancel_scan', array( __CLASS__, 'ajax_cancel_scan' ) );
		add_action( 'wp_ajax_ssaa_rescan_item', array( __CLASS__, 'ajax_rescan_item' ) );
		add_action( 'wp_ajax_ssaa_ignore_issue', array( __CLASS__, 'ajax_ignore_issue' ) );

		// The exclusion reminder line, first thing above the results (priority 1,
		// ahead of the premium changes bar and filter bar at 10).
		add_action( 'ssaa_before_results_table', array( __CLASS__, 'render_exclusion_notice' ), 1 );

		// Sitewide-issue banner: the shared component owns the render +
		// cross-plugin dismissal (vendored class-site-notices.php; see
		// _standards DECISIONS "Sitewide-issue notices"). SSAA feeds the Site
		// row's active WARNING findings - warnings only: the neutral notices
		// are deliberate-choice information and would nag as a banner.
		SSAA_Site_Notices::boot( array(
			'screen_ids'      => array( 'toplevel_page_' . self::PAGE_SLUG ),
			'findings_getter' => array( __CLASS__, 'site_notice_findings' ),
			'findings_url'    => add_query_arg( array( 'page' => self::PAGE_SLUG, 'tab' => 'issues' ), admin_url( 'admin.php' ) ),
		) );
	}

	/**
	 * The Site row's active CRITICAL and WARNING findings in the shared
	 * component's shape. Free code reading stored data.
	 *
	 * Criticals were admitted in 1.4.0: the getter took warnings only, which
	 * was harmless while every site-level finding happened to be a warning, but
	 * it meant the most severe class of sitewide defect would have been the one
	 * class that never reached the sitewide banner. search_engines_discouraged
	 * is the first critical to ride this surface.
	 *
	 * @return array[]
	 */
	public static function site_notice_findings() {
		$row = SSAA_Results::get_by_ref( SSAA_Engine::url_ref( home_url( '/robots.txt' ) ) );
		if ( null === $row ) {
			return array();
		}

		$checked_at = strtotime( (string) $row['scanned_at'] . ' UTC' );
		$findings   = array();
		foreach ( (array) $row['issues'] as $issue ) {
			if ( ! empty( $issue['ignored'] ) || ! isset( $issue['severity'] ) || ! in_array( $issue['severity'], array( 'critical', 'warning' ), true ) ) {
				continue;
			}
			$code       = isset( $issue['code'] ) ? (string) $issue['code'] : '';
			$findings[] = array(
				'code'       => $code,
				'label'      => SSAA_Analyzer::code_label( $code ),
				'message'    => isset( $issue['message'] ) ? (string) $issue['message'] : '',
				'checked_at' => (int) $checked_at,
			);
		}
		return $findings;
	}



	/**
	 * The exclusion reminder above the results tables: "(n) manually excluded"
	 * (free; links to the Exclusions page) plus any premium segments via the
	 * ssaa_exclusion_notice_segments filter ("(n) excluded by rules"). Hidden
	 * when nothing is excluded; not shown on the Ignored tab.
	 *
	 * @param string $tab Current dashboard tab.
	 */
	public static function render_exclusion_notice( $tab ) {
		if ( 'ignored' === $tab ) {
			return;
		}

		$segments = array();

		$manual = count( SSAA_Engine_Exclusions::stored() );
		if ( $manual > 0 ) {
			$segments[] = '<a href="' . esc_url( admin_url( 'admin.php?page=' . self::EXCLUSIONS_SLUG ) ) . '">' . esc_html( sprintf(
				/* translators: %d: number of manually excluded items */
				_n( '%d manually excluded', '%d manually excluded', $manual, 'search-social-appearance-audit' ),
				$manual
			) ) . '</a>';
		}

		/**
		 * Extra segments for the exclusion reminder (pre-escaped HTML). The
		 * premium rule layer appends "(n) excluded by rules". No subscribers in
		 * the free build.
		 *
		 * @param string[] $segments Pre-escaped segments.
		 */
		$segments = (array) apply_filters( 'ssaa_exclusion_notice_segments', $segments );
		if ( empty( $segments ) ) {
			return;
		}

		echo '<p class="description">' . esc_html__( 'Excluded from the audit:', 'search-social-appearance-audit' ) . ' '
			. implode( ' &middot; ', $segments ) . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- segments are pre-escaped above / by their subscribers.
	}

	// -------------------------------------------------------------------------
	// Menu + assets
	// -------------------------------------------------------------------------

	public static function register_menu() {
		// Provisional placement: own top-level menu (dashboard-style plugin).
		// Revisit before 1.0 if Tools or Settings placement fits better.
		// Position 60.15: directly beneath core's Appearance (60), before
		// Plugins (65). The unusual decimal avoids the collision where two
		// plugins claim the same integer position and one silently vanishes.
		$hook = add_menu_page(
			__( 'PlugUpp Search & Social Appearance Audit', 'search-social-appearance-audit' ),
			__( 'Appearance Audit', 'search-social-appearance-audit' ),
			'manage_options',
			self::PAGE_SLUG,
			array( __CLASS__, 'render_page' ),
			// The eye = "how your pages are seen in search + social" - the plugin's
			// umbrella metaphor, and a solid glyph that holds its own next to DOIG
			// (format-image) and LIHA (editor-unlink). Was dashicons-search (a thin
			// magnifier outline that read light/lost at the 20px menu size).
			'dashicons-visibility',
			'60.15'
		);

		add_submenu_page(
			self::PAGE_SLUG,
			__( 'PlugUpp Search & Social Appearance Audit', 'search-social-appearance-audit' ),
			__( 'Dashboard', 'search-social-appearance-audit' ),
			'manage_options',
			self::PAGE_SLUG,
			array( __CLASS__, 'render_page' )
		);

		// Exclusions is a scan-SCOPE concern (what gets skipped), not a result, so
		// it is its own always-present sub-page. Free: a Manual Exclusions tab;
		// premium adds an Exclusion Rules tab (the shared rule component).
		$exclusions_hook = add_submenu_page(
			self::PAGE_SLUG,
			__( 'Appearance Audit Exclusions', 'search-social-appearance-audit' ),
			__( 'Exclusions', 'search-social-appearance-audit' ),
			'manage_options',
			self::EXCLUSIONS_SLUG,
			array( __CLASS__, 'render_exclusions_page' )
		);

		$settings_hook = add_submenu_page(
			self::PAGE_SLUG,
			__( 'Appearance Audit Settings', 'search-social-appearance-audit' ),
			__( 'Settings', 'search-social-appearance-audit' ),
			'manage_options',
			'ssaa-settings',
			array( __CLASS__, 'render_settings_page' )
		);

		// Track every screen we register so assets enqueue on all of them.
		// (0.6.0 bug: only the toplevel hook was checked, so the Settings
		// page - hook suffix "{menu}_page_ssaa-settings" - was unstyled.)
		self::$page_hooks = array_filter( array( $hook, $exclusions_hook, $settings_hook ) );

		// Process bulk/row actions before any output so redirects work.
		add_action( 'load-' . $hook, array( __CLASS__, 'handle_actions' ) );
		add_action( 'load-' . $exclusions_hook, array( __CLASS__, 'handle_exclusions_actions' ) );
	}

	public static function render_exclusions_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		require SSAA_PLUGIN_DIR . 'admin/views/exclusions-page.php';
	}

	/**
	 * Re-include a manually-excluded item from the Exclusions page (a GET link with
	 * a per-ref nonce), run on load-{hook} before any output so the redirect works.
	 */
	public static function handle_exclusions_actions() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! isset( $_GET['ssaa_action'], $_GET['ref'], $_GET['_wpnonce'] ) ) {
			return;
		}
		if ( 'reinclude' !== sanitize_key( wp_unslash( $_GET['ssaa_action'] ) ) ) {
			return;
		}
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on this line via sanitize_text_field(); the rawurldecode() (reversing the link's rawurlencode) is not on the sniff's recognised-sanitiser list. The ref is then validated by the per-ref nonce below and normalised by reinclude().
		$ref = sanitize_text_field( rawurldecode( wp_unslash( $_GET['ref'] ) ) );
		if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ssaa_reinclude_' . $ref ) ) {
			SSAA_Engine_Exclusions::reinclude( array( $ref ) );
			wp_safe_redirect( add_query_arg(
				array( 'page' => self::EXCLUSIONS_SLUG, 'ssaa_reincluded' => 1 ),
				admin_url( 'admin.php' )
			) );
			exit;
		}
	}

	public static function register_settings() {
		register_setting( 'ssaa_settings_group', SSAA_OPTION_KEY, array(
			'type'              => 'array',
			'sanitize_callback' => array( 'SSAA_Settings', 'sanitise' ),
		) );
	}

	public static function render_settings_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		require SSAA_PLUGIN_DIR . 'admin/views/settings-page.php';
	}

	// -------------------------------------------------------------------------
	// Bulk + row actions (run on load-{page}, before output)
	// -------------------------------------------------------------------------

	public static function handle_actions() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$redirect = remove_query_arg( array( 'ssaa_action', 'post', '_wpnonce', 'action', 'action2', 'ssaa_results' ) );

		// Un-ignore links from the Ignored tab.
		if ( isset( $_GET['ssaa_action'], $_GET['scope'], $_GET['code'], $_GET['_wpnonce'] ) && 'unignore' === sanitize_key( wp_unslash( $_GET['ssaa_action'] ) ) ) {
			$scope   = sanitize_key( wp_unslash( $_GET['scope'] ) );
			$code    = sanitize_key( wp_unslash( $_GET['code'] ) );
			$post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0;

			if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ssaa_unignore_' . $code ) ) {
				SSAA_Ignores::toggle( $scope, $code, $post_id, true );
				wp_safe_redirect( add_query_arg( 'ssaa_unignored', 1, remove_query_arg( array( 'ssaa_action', 'scope', 'code', 'post', '_wpnonce' ) ) ) );
				exit;
			}
			return;
		}

		// Per-row exclude by ref - the v9 form of the row action ('post:{ID}' /
		// 'url:{sha1}'), covering archive rows. The ref is sanitized here, then
		// validated by the per-ref nonce and normalized by the engine store.
		if ( isset( $_GET['ssaa_action'], $_GET['ref'], $_GET['_wpnonce'] ) && 'exclude' === sanitize_key( wp_unslash( $_GET['ssaa_action'] ) ) ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized on this line via sanitize_text_field(); the rawurldecode() (reversing the link's rawurlencode) is not on the sniff's recognised-sanitiser list. The ref is then validated by the per-ref nonce below.
			$ref = sanitize_text_field( rawurldecode( wp_unslash( $_GET['ref'] ) ) );
			if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ssaa_exclude_' . $ref ) ) {
				$count = SSAA_Engine_Exclusions::exclude( array( $ref ) );
				wp_safe_redirect( add_query_arg( 'ssaa_excluded', $count, remove_query_arg( array( 'ssaa_action', 'ref', '_wpnonce' ), $redirect ) ) );
				exit;
			}
			return;
		}

		// Per-row exclude / re-include links (legacy post-ID form - kept for
		// links cached from an older page).
		if ( isset( $_GET['ssaa_action'], $_GET['post'], $_GET['_wpnonce'] ) ) {
			$row_action = sanitize_key( wp_unslash( $_GET['ssaa_action'] ) );
			$post_id    = absint( wp_unslash( $_GET['post'] ) );

			if ( 'exclude' === $row_action && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ssaa_exclude_' . $post_id ) ) {
				$count = SSAA_Engine_Exclusions::exclude( array( $post_id ) );
				wp_safe_redirect( add_query_arg( 'ssaa_excluded', $count, $redirect ) );
				exit;
			}
			if ( 'include' === $row_action && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ssaa_include_' . $post_id ) ) {
				$count = SSAA_Engine_Exclusions::reinclude( array( $post_id ) );
				wp_safe_redirect( add_query_arg( 'ssaa_included', $count, $redirect ) );
				exit;
			}
			return;
		}

		// List-table bulk actions. These superglobal reads only DETECT whether
		// this is our bulk submit and which action it is - a normal page load
		// has neither, and calling check_admin_referer() unconditionally would
		// die on every page view. The nonce is verified with
		// check_admin_referer() below before anything is read for use or changed.
		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
		$bulk = '';
		if ( isset( $_REQUEST['action'] ) && '-1' !== $_REQUEST['action'] ) {
			$bulk = sanitize_key( wp_unslash( $_REQUEST['action'] ) );
		} elseif ( isset( $_REQUEST['action2'] ) && '-1' !== $_REQUEST['action2'] ) {
			$bulk = sanitize_key( wp_unslash( $_REQUEST['action2'] ) );
		}

		if ( '' === $bulk || empty( $_REQUEST['ssaa_results'] ) ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended

		check_admin_referer( 'bulk-ssaa_results' );

		// Checkbox values are refs since v9 ('post:{ID}' / 'url:{sha1}'); bare
		// IDs still accepted (a results form cached from an older page).
		// Sanitized at the read via array_map, then whitelisted by shape.
		$values = array_map( 'sanitize_text_field', (array) wp_unslash( $_REQUEST['ssaa_results'] ) );
		$refs   = array();
		foreach ( $values as $value ) {
			if ( ctype_digit( $value ) ) {
				$value = 'post:' . absint( $value );
			}
			if ( preg_match( '/^(post:\d+|url:[0-9a-f]{40})$/', $value ) ) {
				$refs[] = $value;
			}
		}

		if ( 'ssaa_exclude' === $bulk ) {
			$count = SSAA_Engine_Exclusions::exclude( $refs );
			wp_safe_redirect( add_query_arg( 'ssaa_excluded', $count, $redirect ) );
			exit;
		}
		if ( 'ssaa_include' === $bulk ) {
			$count = SSAA_Engine_Exclusions::reinclude( $refs );
			wp_safe_redirect( add_query_arg( 'ssaa_included', $count, $redirect ) );
			exit;
		}
		if ( 'ssaa_rescan' === $bulk ) {
			SSAA_Scan::start_with_refs( $refs );
			// The dashboard JS sees the running state and drives the batches.
			wp_safe_redirect( $redirect );
			exit;
		}

		// Post IDs for extension handlers (the premium CSV bulk export reads
		// these); url refs have no post ID and are passed separately.
		$ids = array();
		foreach ( $refs as $ref ) {
			if ( 0 === strpos( $ref, 'post:' ) ) {
				$ids[] = absint( substr( $ref, 5 ) );
			}
		}

		/**
		 * Extension point: add-on files handle their own bulk actions
		 * (registered via the ssaa_bulk_actions filter). Handlers that
		 * produce output (downloads) exit themselves.
		 *
		 * @param string   $bulk Bulk action key.
		 * @param int[]    $ids  Selected post IDs (url refs excluded).
		 * @param string[] $refs Selected refs including archives (since v9).
		 */
		do_action( 'ssaa_handle_bulk_action', $bulk, $ids, $refs );
	}

	public static function enqueue_assets( $hook_suffix ) {
		if ( ! in_array( $hook_suffix, self::$page_hooks, true ) ) {
			return;
		}

		wp_enqueue_style( 'ssaa-admin', SSAA_PLUGIN_URL . 'assets/css/admin.css', array(), SSAA_VERSION );
		wp_enqueue_script( 'ssaa-admin', SSAA_PLUGIN_URL . 'assets/js/admin.js', array( 'jquery' ), SSAA_VERSION, true );

		/**
		 * Extension point: add-on files extend the localized admin data
		 * (keeps their payload out of the base build - see _standards
		 * "Sidebar localization data" pattern).
		 */
		wp_localize_script( 'ssaa-admin', 'ssaaScan', (array) apply_filters( 'ssaa_admin_localize_data', array(
			'ajaxUrl' => admin_url( 'admin-ajax.php' ),
			'nonce'   => wp_create_nonce( self::NONCE_NAME ),
			'state'   => SSAA_Scan::state(),
			'i18n'    => array(
				'scanning'   => __( 'Scanning…', 'search-social-appearance-audit' ),
				'progress'   => /* translators: 1: done count, 2: total count */ __( '%1$s of %2$s scanned', 'search-social-appearance-audit' ),
				'deferred'   => __( 'Paused - the site is briefly unavailable (e.g. an update is running). The scan resumes automatically.', 'search-social-appearance-audit' ),
				'failed'     => __( 'The scan request failed. Check the log and try again.', 'search-social-appearance-audit' ),
				'rescanning' => __( 'Re-scanning…', 'search-social-appearance-audit' ),
				'problem'    => __( 'Problem', 'search-social-appearance-audit' ),
				'evidence'   => __( 'Evidence', 'search-social-appearance-audit' ),
				'sources'    => __( 'Possible sources', 'search-social-appearance-audit' ),
				'sourcesNote' => __( 'Detected on the rendered page - informational clues only, never definitive. Other candidates: the theme, another plugin, or custom code.', 'search-social-appearance-audit' ),
				'noSources'  => __( 'No known SEO plugin signature detected. Likely the theme, another plugin, or custom code.', 'search-social-appearance-audit' ),
				'noIssues'   => __( 'No issues detected for this item.', 'search-social-appearance-audit' ),
				'ignorePage'   => __( 'Ignore on this page', 'search-social-appearance-audit' ),
				'ignoreSite'   => __( 'Ignore sitewide', 'search-social-appearance-audit' ),
				'unignore'     => __( 'Un-ignore', 'search-social-appearance-audit' ),
				'ignoring'     => __( 'Saving…', 'search-social-appearance-audit' ),
				'ignoredPage'  => __( 'Ignored on this page', 'search-social-appearance-audit' ),
				'ignoredSite'  => __( 'Ignored sitewide', 'search-social-appearance-audit' ),
				'severity'   => array(
					'critical' => __( 'Critical', 'search-social-appearance-audit' ),
					'warning'  => __( 'Warning', 'search-social-appearance-audit' ),
					'notice'   => __( 'Notice', 'search-social-appearance-audit' ),
				),
			),
		) ) );
	}

	public static function render_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		require SSAA_PLUGIN_DIR . 'admin/views/dashboard-page.php';
	}

	// -------------------------------------------------------------------------
	// Log download / clear (admin-post, behind capability + nonce)
	// -------------------------------------------------------------------------



	// -------------------------------------------------------------------------
	// AJAX
	// -------------------------------------------------------------------------

	/**
	 * Shared guard for every scan AJAX action.
	 */
	private static function guard_ajax() {
		check_ajax_referer( self::NONCE_NAME, 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Not allowed.', 'search-social-appearance-audit' ) ), 403 );
		}
	}

	public static function ajax_start_scan() {
		self::guard_ajax();
		wp_send_json_success( SSAA_Scan::start() );
	}

	public static function ajax_scan_batch() {
		self::guard_ajax();
		// SSAA_Scan::tick() applies the ssaa_ajax_batch_budget filter over the
		// configured AJAX budget and processes one browser-driven batch.
		wp_send_json_success( SSAA_Scan::tick() );
	}

	public static function ajax_cancel_scan() {
		self::guard_ajax();
		wp_send_json_success( SSAA_Scan::cancel() );
	}

	public static function ajax_ignore_issue() {
		self::guard_ajax();

		// guard_ajax() verifies the AJAX nonce via check_ajax_referer() above;
		// PHPCS cannot trace that across the helper call.
		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
		$scope   = isset( $_POST['scope'] ) ? sanitize_key( wp_unslash( $_POST['scope'] ) ) : '';
		$code    = isset( $_POST['code'] ) ? sanitize_key( wp_unslash( $_POST['code'] ) ) : '';
		$post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0;
		$remove  = ! empty( $_POST['remove'] );
		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended

		if ( ! SSAA_Ignores::toggle( $scope, $code, $post_id, $remove ) ) {
			wp_send_json_error( array( 'message' => __( 'Invalid ignore request.', 'search-social-appearance-audit' ) ), 400 );
		}

		wp_send_json_success();
	}

	public static function ajax_rescan_item() {
		self::guard_ajax();

		// guard_ajax() verifies the AJAX nonce via check_ajax_referer() above;
		// PHPCS cannot trace that across the helper call.
		// phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
		$ref     = isset( $_POST['ref'] ) ? sanitize_text_field( wp_unslash( $_POST['ref'] ) ) : '';
		$post_id = isset( $_POST['post_id'] ) ? absint( wp_unslash( $_POST['post_id'] ) ) : 0;
		// phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended

		// Ref form preferred (covers archive rows); post_id kept for older JS.
		if ( preg_match( '/^(post:\d+|url:[0-9a-f]{40})$/', $ref ) ) {
			SSAA_Scan::rescan_ref( $ref );
			SSAA_Results::refresh_summary();
			wp_send_json_success( array( 'ref' => $ref ) );
		}

		if ( ! $post_id ) {
			wp_send_json_error( array( 'message' => __( 'Missing post ID.', 'search-social-appearance-audit' ) ), 400 );
		}

		SSAA_Scan::rescan_item( $post_id );
		SSAA_Results::refresh_summary();

		wp_send_json_success( array( 'post_id' => $post_id ) );
	}
}
